Files
orca/src/main/github/issue-work-item-details.ts
T
OrcaWinandOrcaWin 6d342a0039 Reuse issue metadata from existing detail conversation queries (#35546)
* Reuse GraphQL detail reads for work-item metadata

* Keep PR metadata aligned with cached file listings

* Recover incomplete issue assignee metadata without reloading conversation

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-28 21:20:17 -07:00

306 lines
9.7 KiB
TypeScript

import type { GitHubIssueTimelineItem, PRComment } from '../../shared/github/comment-types'
import type { GitHubAssignableUser } from '../../shared/github/pull-request-types'
import { z } from 'zod'
import { mapIssueWorkItem } from './client/map/work-item'
import { restIssueActor } from './client/map/issue-actor'
import type { MainWorkItem } from './client/map/work-item-field-coercion'
import { ghExecFileAsync, ghRepoExecOptions, githubRepoContext } from './gh-utils'
import type { LocalGitExecOptions } from './gh-utils'
import { githubHostExecOptions, type GitHubApiRepository } from './github-api-repository'
import { getIssueTimelineItems } from './issue-timeline'
import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit'
const ISSUE_DETAILS_QUERY = `query($owner: String!, $repo: String!, $number: Int!, $includeItem: Boolean!) {
repository(owner: $owner, name: $repo) {
issue(number: $number) {
number @include(if: $includeItem) title @include(if: $includeItem)
state @include(if: $includeItem)
url @include(if: $includeItem)
updatedAt @include(if: $includeItem)
author @include(if: $includeItem) { __typename login avatarUrl(size: 48) }
labels(first: 100) @include(if: $includeItem) {
nodes { name }
pageInfo { hasNextPage }
}
body
assignees(first: 50) {
nodes { login avatarUrl(size: 48) ... on User { name } }
pageInfo { hasNextPage }
}
participants(first: 100) {
nodes { login avatarUrl(size: 48) ... on User { name } }
}
comments(first: 100) {
nodes {
databaseId
body
createdAt
url
author {
login
avatarUrl(size: 48)
... on Bot { __typename }
}
}
}
}
}
}`
const issueItemSchema = z.object({
number: z.number().int().positive(),
title: z.string(),
state: z.enum(['OPEN', 'CLOSED']),
url: z.string(),
updatedAt: z.string(),
author: z.object({ __typename: z.string(), login: z.string(), avatarUrl: z.string() }).nullable(),
assignees: z.object({
nodes: z.array(z.object({ login: z.string().min(1) })),
pageInfo: z.object({ hasNextPage: z.literal(false) })
}),
labels: z.object({
nodes: z.array(z.object({ name: z.string() })),
pageInfo: z.object({ hasNextPage: z.literal(false) })
})
})
type GraphQLIssueDetailsResponse = {
data?: {
repository?: {
issue?: {
body?: string | null
assignees?: { nodes?: (Partial<GitHubAssignableUser> | null)[] | null }
participants?: { nodes?: GitHubAssignableUser[] }
comments?: {
nodes?: {
databaseId?: number | null
body?: string | null
createdAt?: string | null
url?: string | null
author?: {
login?: string | null
avatarUrl?: string | null
__typename?: string
} | null
}[]
}
} | null
} | null
}
errors?: { message?: string }[]
}
export type CollapsedIssueDetails = {
item?: MainWorkItem
body: string
comments: PRComment[]
assignees: string[]
assigneeUsers: GitHubAssignableUser[]
participants: GitHubAssignableUser[]
timelineItems: GitHubIssueTimelineItem[]
}
export async function getIssueDetailsViaGraphQL(
repoPath: string,
issueNumber: number,
repository: GitHubApiRepository | null,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
includeItem = false
): Promise<CollapsedIssueDetails | null> {
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(repository)
}
if (!repository) {
return null
}
if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) {
return null
}
try {
noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions)
const { stdout } = await ghExecFileAsync(
[
'api',
'graphql',
'-f',
`query=${ISSUE_DETAILS_QUERY}`,
'-f',
`owner=${repository.owner}`,
'-f',
`repo=${repository.repo}`,
'-F',
`number=${issueNumber}`,
'-F',
`includeItem=${includeItem}`
],
ghOptions
)
const parsed = JSON.parse(stdout) as GraphQLIssueDetailsResponse
if (parsed.errors && parsed.errors.length > 0) {
return null
}
const issue = parsed.data?.repository?.issue
if (!issue) {
return null
}
const itemData = includeItem ? issueItemSchema.safeParse(issue) : undefined
if (itemData?.success && itemData.data.number !== issueNumber) {
return null
}
const comments: PRComment[] = (issue.comments?.nodes ?? [])
.filter((comment) => typeof comment.databaseId === 'number')
.map((comment) => ({
id: comment.databaseId as number,
author: comment.author?.login ?? 'ghost',
authorAvatarUrl: comment.author?.avatarUrl ?? '',
body: comment.body ?? '',
createdAt: comment.createdAt ?? '',
url: comment.url ?? '',
isBot: comment.author?.__typename === 'Bot'
}))
const assigneeUsers: GitHubAssignableUser[] = (issue.assignees?.nodes ?? [])
.filter((assignee): assignee is { login: string; avatarUrl?: string; name?: string | null } =>
Boolean(assignee?.login)
)
.map((assignee) => ({
login: assignee.login,
name: assignee.name ?? null,
avatarUrl: assignee.avatarUrl ?? ''
}))
const participants: GitHubAssignableUser[] = (issue.participants?.nodes ?? [])
.filter((user) => Boolean(user.login))
.map((user) => ({
login: user.login,
name: user.name ?? null,
avatarUrl: user.avatarUrl ?? ''
}))
return {
...(itemData?.success
? {
item: mapIssueWorkItem({
...itemData.data,
state: itemData.data.state.toLowerCase(),
user: restIssueActor(itemData.data.author),
labels: itemData.data.labels.nodes,
assignees: assigneeUsers
})
}
: {}),
body: issue.body ?? '',
comments,
assignees: assigneeUsers.map((assignee) => assignee.login),
assigneeUsers,
participants,
timelineItems: await getIssueTimelineItems(repository, issueNumber, ghOptions)
}
} catch {
return null
}
}
export async function getIssueBodyAndComments(
repoPath: string,
issueNumber: number,
repository: GitHubApiRepository | null,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<Pick<CollapsedIssueDetails, 'body' | 'comments' | 'assignees' | 'timelineItems'>> {
const ghOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)),
...githubHostExecOptions(repository)
}
try {
if (repository) {
if (repositoryRateLimitGuard(repository, 'core', ghOptions).blocked) {
return { body: '', comments: [], assignees: [], timelineItems: [] }
}
noteRepositoryRateLimitSpend(repository, 'core', 2, ghOptions)
const [issueResult, commentsResult, timelineItems] = await Promise.all([
ghExecFileAsync(
[
'api',
'--cache',
'60s',
`repos/${repository.owner}/${repository.repo}/issues/${issueNumber}`
],
ghOptions
),
ghExecFileAsync(
[
'api',
'--cache',
'60s',
`repos/${repository.owner}/${repository.repo}/issues/${issueNumber}/comments?per_page=100`
],
ghOptions
),
getIssueTimelineItems(repository, issueNumber, ghOptions)
])
const issue = JSON.parse(issueResult.stdout) as {
body?: string | null
assignees?: { login: string }[]
}
type RESTComment = {
id: number
user: { login: string; avatar_url: string; type?: string } | null
body: string
created_at: string
html_url: string
}
const comments = (JSON.parse(commentsResult.stdout) as RESTComment[]).map(
(comment): PRComment => ({
id: comment.id,
author: comment.user?.login ?? 'ghost',
authorAvatarUrl: comment.user?.avatar_url ?? '',
body: comment.body ?? '',
createdAt: comment.created_at,
url: comment.html_url,
isBot: comment.user?.type === 'Bot'
})
)
return {
body: issue.body ?? '',
comments,
assignees: (issue.assignees ?? []).map((assignee) => assignee.login),
timelineItems
}
}
if (connectionId) {
// A connection-backed lookup must not use ambient local GH_REPO/GH_HOST.
return { body: '', comments: [], assignees: [], timelineItems: [] }
}
const { stdout } = await ghExecFileAsync(
['issue', 'view', String(issueNumber), '--json', 'body,comments,assignees'],
ghOptions
)
const data = JSON.parse(stdout) as {
body?: string
comments?: {
author: { login: string }
body: string
createdAt: string
url: string
}[]
assignees?: { login: string }[]
}
const comments = (data.comments ?? []).map((comment, index): PRComment => ({
id: index,
author: comment.author?.login ?? 'ghost',
authorAvatarUrl: '',
body: comment.body ?? '',
createdAt: comment.createdAt,
url: comment.url ?? ''
}))
return {
body: data.body ?? '',
comments,
assignees: (data.assignees ?? []).map((assignee) => assignee.login),
timelineItems: []
}
} catch {
return { body: '', comments: [], assignees: [], timelineItems: [] }
}
}