diff --git a/src/main/github/work-item-details.test.ts b/src/main/github/work-item-details.test.ts index 2a7dc2fda85..5343c00813f 100644 --- a/src/main/github/work-item-details.test.ts +++ b/src/main/github/work-item-details.test.ts @@ -81,58 +81,211 @@ describe('getWorkItemDetails', () => { acquireMock.mockResolvedValue(undefined) }) - it('uses the collapsed GraphQL issue query as the hot path', async () => { + it('uses the collapsed GraphQL issue query with timeline activity enrichment', async () => { getWorkItemMock.mockResolvedValueOnce({ id: 'issue:923', type: 'issue', number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Issue body', - assignees: { nodes: [{ login: 'jinjing' }] }, - participants: { - nodes: [{ login: 'octocat', avatarUrl: 'https://x/y', name: 'Octo Cat' }] - }, - comments: { - nodes: [ - { - databaseId: 7, - body: 'first', - createdAt: '2026-04-01T00:00:00Z', - url: 'https://github.com/stablyai/orca/issues/923#issuecomment-7', - author: { login: 'octocat', avatarUrl: 'https://x/y' } - } - ] + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + const timelineEvents = [ + { + id: 101, + event: 'assigned', + actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, + assignee: { login: 'assigned-user' }, + created_at: '2026-04-01T01:00:00Z' + }, + { + id: 102, + event: 'cross-referenced', + actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, + created_at: '2026-04-01T02:00:00Z', + source: { + issue: { + number: 6180, + title: 'Synthetic reference PR', + html_url: 'https://github.com/acme/widgets/pull/6180', + repository: { owner: { login: 'acme' }, name: 'widgets' }, + pull_request: {} + } + } + }, + { + id: 103, + event: 'moved_columns_in_project', + actor: { login: 'github-project-automation', avatar_url: 'https://x/bot' }, + created_at: '2026-04-01T03:00:00Z', + previous_column_name: 'Doing', + project_column_name: 'Complete', + project: { name: 'Example Project' } + }, + { + id: 104, + event: 'closed', + actor: { login: 'timeline-actor', avatar_url: 'https://x/timeline-actor' }, + created_at: '2026-04-01T04:00:00Z', + state_reason: 'completed', + closer: { + number: 6180, + title: 'Synthetic reference PR', + html_url: 'https://github.com/acme/widgets/pull/6180', + repository: { owner: { login: 'acme' }, name: 'widgets' }, + pull_request: {} + } + } + ] + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { nodes: [{ login: 'assigned-user' }] }, + participants: { + nodes: [{ login: 'issue-author', avatarUrl: 'https://x/y', name: 'Issue Author' }] + }, + comments: { + nodes: [ + { + databaseId: 7, + body: 'first', + createdAt: '2026-04-01T00:00:00Z', + url: 'https://github.com/acme/widgets/issues/923#issuecomment-7', + author: { login: 'issue-author', avatarUrl: 'https://x/y' } + } + ] + } } } } - } + }) + }) + .mockResolvedValueOnce({ + stdout: timelineEvents.map((event) => JSON.stringify(event)).join('\n') }) - }) const details = await getWorkItemDetails('/repo-root', 923, 'issue') expect(getWorkItemMock).toHaveBeenCalledWith('/repo-root', 923, 'issue', undefined) - // Why: a single gh subprocess call replaces the previous REST + REST + GraphQL fan-out. - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock.mock.calls[0][0][0]).toBe('api') expect(ghExecFileAsyncMock.mock.calls[0][0][1]).toBe('graphql') + expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual([ + 'api', + '--cache', + '60s', + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1', + '--jq', + '.[] | @json' + ]) expect(details?.body).toBe('Issue body') - expect(details?.assignees).toEqual(['jinjing']) + expect(details?.assignees).toEqual(['assigned-user']) expect(details?.comments).toHaveLength(1) expect(details?.comments[0].id).toBe(7) - expect(details?.participants?.[0]?.login).toBe('octocat') + expect(details?.timelineItems).toMatchObject([ + { event: 'assigned', actor: 'timeline-actor', assignee: 'assigned-user' }, + { + event: 'cross-referenced', + source: { + type: 'pr', + number: 6180, + repository: 'acme/widgets' + } + }, + { + event: 'moved_columns_in_project', + actor: 'github-project-automation', + previousColumnName: 'Doing', + columnName: 'Complete', + projectName: 'Example Project' + }, + { + event: 'closed', + stateReason: 'completed', + closer: { type: 'pr', number: 6180 } + } + ]) + expect(details?.participants?.[0]?.login).toBe('issue-author') + }) + + it('caps issue timeline pagination by supported activity items', async () => { + getWorkItemMock.mockResolvedValueOnce({ + id: 'issue:923', + type: 'issue', + number: 923, + title: 'Use upstream issues', + state: 'open', + url: 'https://github.com/acme/widgets/issues/923', + labels: [], + updatedAt: '2026-04-01T00:00:00Z', + author: 'issue-author' + }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + const makeTimelineEvent = (page: number, index: number, event: string): string => + JSON.stringify({ + id: `${page}:${index}`, + event, + actor: { login: 'issue-author', avatar_url: 'https://x/y' }, + assignee: { login: `assignee-${page}-${index}` }, + created_at: '2026-04-01T00:00:00Z' + }) + const makeTimelinePage = (page: number, supportedCount: number): string => + Array.from({ length: 100 }, (_, index) => + makeTimelineEvent(page, index, index < supportedCount ? 'assigned' : 'subscribed') + ).join('\n') + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } + } + } + }) + }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(1, 0) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(2, 0) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(3, 10) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(4, 100) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(5, 100) }) + .mockResolvedValueOnce({ stdout: makeTimelinePage(6, 100) }) + + const details = await getWorkItemDetails('/repo-root', 923, 'issue') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(7) + expect(ghExecFileAsyncMock.mock.calls[1][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1' + ) + expect(ghExecFileAsyncMock.mock.calls[2][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=2' + ) + expect(ghExecFileAsyncMock.mock.calls[3][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=3' + ) + expect(ghExecFileAsyncMock.mock.calls[6][0]).toContain( + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=6' + ) + expect( + ghExecFileAsyncMock.mock.calls.some((call) => + call[0].includes('repos/acme/widgets/issues/923/timeline?per_page=100&page=7') + ) + ).toBe(false) + expect(details?.timelineItems).toHaveLength(300) + expect(details?.timelineItems.at(0)).toMatchObject({ assignee: 'assignee-3-0' }) + expect(details?.timelineItems.at(-1)).toMatchObject({ assignee: 'assignee-6-89' }) }) it('falls back to REST + GraphQL when the collapsed issue query fails', async () => { @@ -142,17 +295,18 @@ describe('getWorkItemDetails', () => { number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) // Collapsed GraphQL throws → fallback path picks up. ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL error')) .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body' }) }) .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { issue: { participants: { nodes: [] } } } } @@ -166,12 +320,24 @@ describe('getWorkItemDetails', () => { expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, - ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923'], + ['api', '--cache', '60s', 'repos/acme/widgets/issues/923'], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, - ['api', '--cache', '60s', 'repos/stablyai/orca/issues/923/comments?per_page=100'], + ['api', '--cache', '60s', 'repos/acme/widgets/issues/923/comments?per_page=100'], + { cwd: '/repo-root' } + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 4, + [ + 'api', + '--cache', + '60s', + 'repos/acme/widgets/issues/923/timeline?per_page=100&page=1', + '--jq', + '.[] | @json' + ], { cwd: '/repo-root' } ) expect(details?.body).toBe('Issue body') @@ -190,19 +356,20 @@ describe('getWorkItemDetails', () => { number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ body: 'Issue body', assignees: [] }) }) .mockResolvedValueOnce({ stdout: '[]' }) + .mockResolvedValueOnce({ stdout: '[]' }) const details = await getWorkItemDetails('/repo-root', 923, 'issue') - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) expect(noteRateLimitSpendMock).not.toHaveBeenCalled() expect(details?.body).toBe('Issue body') @@ -216,31 +383,33 @@ describe('getWorkItemDetails', () => { number: 923, title: 'Use upstream issues', state: 'open', - url: 'https://github.com/stablyai/orca/issues/923', + url: 'https://github.com/acme/widgets/issues/923', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'issue-author' }) - getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify({ - data: { - repository: { - issue: { - body: 'Remote issue body', - assignees: { nodes: [] }, - participants: { nodes: [] }, - comments: { nodes: [] } + getIssueOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + issue: { + body: 'Remote issue body', + assignees: { nodes: [] }, + participants: { nodes: [] }, + comments: { nodes: [] } + } } } - } + }) }) - }) + .mockResolvedValueOnce({ stdout: '[]' }) - const details = await getWorkItemDetails('/home/jinwoo/orca', 923, 'issue', 'openclaw-2') + const details = await getWorkItemDetails('/home/tester/widgets', 923, 'issue', 'ssh-test-1') - expect(getWorkItemMock).toHaveBeenCalledWith('/home/jinwoo/orca', 923, 'issue', 'openclaw-2') - expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2') + expect(getWorkItemMock).toHaveBeenCalledWith('/home/tester/widgets', 923, 'issue', 'ssh-test-1') + expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/home/tester/widgets', 'ssh-test-1') expect(ghExecFileAsyncMock.mock.calls[0][1]).toEqual({}) expect(details?.body).toBe('Remote issue body') }) @@ -253,17 +422,17 @@ describe('getWorkItemDetails', () => { number: 42, title: 'Review drawer WSL', state: 'open', - url: 'https://github.com/stablyai/orca/pull/42', + url: 'https://github.com/acme/widgets/pull/42', labels: [], updatedAt: '2026-04-01T00:00:00Z', - author: 'octocat' + author: 'pr-author' }) - getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getPRCommentsMock.mockResolvedValue([]) getPRChecksMock.mockResolvedValue([]) ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { const target = args.at(-1) - if (target === 'repos/stablyai/orca/pulls/42') { + if (target === 'repos/acme/widgets/pulls/42') { return { stdout: JSON.stringify({ body: 'PR body', @@ -272,7 +441,7 @@ describe('getWorkItemDetails', () => { }) } } - if (target === 'repos/stablyai/orca/pulls/42/files?per_page=100') { + if (target === 'repos/acme/widgets/pulls/42/files?per_page=100') { return { stdout: '[]' } } const query = args.find((arg) => arg.startsWith('query=')) ?? '' diff --git a/src/main/github/work-item-details.ts b/src/main/github/work-item-details.ts index e79f13577a3..6029613fb3d 100644 --- a/src/main/github/work-item-details.ts +++ b/src/main/github/work-item-details.ts @@ -6,6 +6,8 @@ import type { GitHubPRFile, GitHubPRFileContents, GitHubPRFileViewedState, + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget, GitHubWorkItem, GitHubWorkItemDetails, PRCheckDetail, @@ -30,6 +32,10 @@ import { isMaxBufferOverflowError } from '../git/max-buffer-overflow' // at 100 per page; we cap at a reasonable total so a massive PR cannot starve // the gh semaphore while we fetch file listings. const MAX_PR_FILES = 300 +// Why: issue timelines can be extremely noisy from automation and cross-links. +// Bound drawer detail work so one huge issue cannot monopolize gh/API time. +const MAX_ISSUE_TIMELINE_ITEMS = 300 +const GITHUB_REST_PAGE_SIZE = 100 // Why: hosted PR files must exceed the renderer's large-diff threshold before // we give up on the raw fetch; otherwise the UI sees an empty diff instead of // the safety fallback. @@ -126,6 +132,197 @@ type GraphQLIssueDetailsResponse = { errors?: { message?: string }[] } +type GitHubOwnerRepoSlug = { owner: string; repo: string } + +type RestTimelineUser = { + login?: string | null + avatar_url?: string | null +} + +type RestTimelineIssue = { + number?: number | null + title?: string | null + html_url?: string | null + repository?: { + name?: string | null + owner?: { login?: string | null } | null + } | null + pull_request?: unknown +} + +type RestTimelineEvent = { + id?: number | string | null + node_id?: string | null + event?: string | null + actor?: RestTimelineUser | null + user?: RestTimelineUser | null + assignee?: RestTimelineUser | null + created_at?: string | null + source?: { + issue?: RestTimelineIssue | null + } | null + closer?: RestTimelineIssue | null + state_reason?: string | null + project_card?: { + column_name?: string | null + previous_column_name?: string | null + project_url?: string | null + } | null + project?: { + name?: string | null + } | null + project_column_name?: string | null + previous_column_name?: string | null +} + +function isSupportedTimelineEvent( + eventName: string | null | undefined +): eventName is GitHubIssueTimelineItem['event'] { + return ( + eventName === 'assigned' || + eventName === 'unassigned' || + eventName === 'mentioned' || + eventName === 'cross-referenced' || + eventName === 'closed' || + eventName === 'reopened' || + eventName === 'moved_columns_in_project' + ) +} + +function mapTimelineTarget( + issue: RestTimelineIssue | null | undefined +): GitHubIssueTimelineTarget | undefined { + if (!issue || typeof issue.number !== 'number' || !issue.html_url) { + return undefined + } + const owner = issue.repository?.owner?.login + const repo = issue.repository?.name + return { + type: issue.pull_request ? 'pr' : 'issue', + number: issue.number, + title: issue.title ?? '', + url: issue.html_url, + repository: owner && repo ? `${owner}/${repo}` : undefined + } +} + +function getTimelineActor(event: RestTimelineEvent): { login: string; avatarUrl: string } { + const actor = event.actor ?? event.user + return { + login: actor?.login ?? 'ghost', + avatarUrl: actor?.avatar_url ?? '' + } +} + +function mapRestTimelineEvent(event: RestTimelineEvent): GitHubIssueTimelineItem | null { + const eventName = event.event + if (!isSupportedTimelineEvent(eventName)) { + return null + } + if (!event.created_at) { + return null + } + const actor = getTimelineActor(event) + const id = String(event.node_id ?? event.id ?? `${eventName}:${event.created_at}`) + const base = { + id, + event: eventName, + actor: actor.login, + actorAvatarUrl: actor.avatarUrl, + createdAt: event.created_at + } + if (eventName === 'assigned' || eventName === 'unassigned') { + return { + ...base, + assignee: event.assignee?.login ?? undefined + } + } + if (eventName === 'mentioned' || eventName === 'cross-referenced') { + return { + ...base, + source: mapTimelineTarget(event.source?.issue) + } + } + if (eventName === 'closed') { + return { + ...base, + stateReason: event.state_reason ?? null, + closer: mapTimelineTarget(event.closer ?? event.source?.issue) + } + } + if (eventName === 'moved_columns_in_project') { + return { + ...base, + previousColumnName: + event.previous_column_name ?? event.project_card?.previous_column_name ?? null, + columnName: event.project_column_name ?? event.project_card?.column_name ?? null, + projectName: event.project?.name ?? null + } + } + return base +} + +function parseRestTimelineEventLines(stdout: string): RestTimelineEvent[] { + const events: RestTimelineEvent[] = [] + for (const line of stdout.split('\n')) { + const trimmed = line.trim() + if (!trimmed) { + continue + } + try { + const parsed = JSON.parse(trimmed) as unknown + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + events.push(parsed) + } + } catch { + // Skip malformed jq lines; timeline activity is auxiliary to issue details. + } + } + return events +} + +async function getIssueTimelineItems( + ownerRepo: GitHubOwnerRepoSlug, + issueNumber: number, + ghOptions: ReturnType +): Promise { + try { + const items: GitHubIssueTimelineItem[] = [] + for (let page = 1; items.length < MAX_ISSUE_TIMELINE_ITEMS; page += 1) { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--cache', + '60s', + `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/timeline?per_page=${GITHUB_REST_PAGE_SIZE}&page=${page}`, + '--jq', + '.[] | @json' + ], + ghOptions + ) + // Why: --jq emits compact NDJSON while explicit pages let us stop once + // supported activity reaches the drawer cap. + const pageEvents = parseRestTimelineEventLines(stdout) + for (const event of pageEvents) { + const item = mapRestTimelineEvent(event) + if (!item) { + continue + } + items.push(item) + if (items.length === MAX_ISSUE_TIMELINE_ITEMS) { + break + } + } + if (pageEvents.length < GITHUB_REST_PAGE_SIZE) { + break + } + } + return items + } catch { + return [] + } +} + async function getIssueDetailsViaGraphQL( repoPath: string, issueNumber: number, @@ -136,6 +333,7 @@ async function getIssueDetailsViaGraphQL( comments: PRComment[] assignees: string[] participants: GitHubAssignableUser[] + timelineItems: GitHubIssueTimelineItem[] } | null> { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) const ownerRepo = await getIssueOwnerRepo( @@ -198,11 +396,13 @@ async function getIssueDetailsViaGraphQL( name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' })) + const timelineItems = await getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) return { body: issue.body ?? '', comments, assignees, - participants + participants, + timelineItems } } catch { return null @@ -471,7 +671,12 @@ async function getIssueBodyAndComments( issueNumber: number, connectionId?: string | null, localGitOptions: LocalGitExecOptions = {} -): Promise<{ body: string; comments: PRComment[]; assignees: string[] }> { +): Promise<{ + body: string + comments: PRComment[] + assignees: string[] + timelineItems: GitHubIssueTimelineItem[] +}> { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions)) const ownerRepo = await getIssueOwnerRepo( repoPath, @@ -480,7 +685,7 @@ async function getIssueBodyAndComments( ) try { if (ownerRepo) { - const [issueResult, commentsResult] = await Promise.all([ + const [issueResult, commentsResult, timelineItems] = await Promise.all([ ghExecFileAsync( [ 'api', @@ -498,7 +703,8 @@ async function getIssueBodyAndComments( `repos/${ownerRepo.owner}/${ownerRepo.repo}/issues/${issueNumber}/comments?per_page=100` ], ghOptions - ) + ), + getIssueTimelineItems(ownerRepo, issueNumber, ghOptions) ]) const issue = JSON.parse(issueResult.stdout) as { body?: string | null @@ -523,7 +729,7 @@ async function getIssueBodyAndComments( }) ) const assignees = (issue.assignees ?? []).map((a) => a.login) - return { body: issue.body ?? '', comments, assignees } + return { body: issue.body ?? '', comments, assignees, timelineItems } } // Fallback: non-GitHub remote const { stdout } = await ghExecFileAsync( @@ -551,9 +757,9 @@ async function getIssueBodyAndComments( }) ) const fallbackAssignees = (data.assignees ?? []).map((a) => a.login) - return { body: data.body ?? '', comments, assignees: fallbackAssignees } + return { body: data.body ?? '', comments, assignees: fallbackAssignees, timelineItems: [] } } catch { - return { body: '', comments: [], assignees: [] } + return { body: '', comments: [], assignees: [], timelineItems: [] } } } @@ -802,12 +1008,13 @@ export async function getWorkItemDetails( body: collapsed.body, comments: collapsed.comments, assignees: collapsed.assignees, - participants: collapsed.participants + participants: collapsed.participants, + timelineItems: collapsed.timelineItems } } // Why: fall back to body/comments and GraphQL participants in parallel; // the mention-participant merge is a cheap local operation afterward. - const [{ body, comments, assignees }, participants] = await Promise.all([ + const [{ body, comments, assignees, timelineItems }, participants] = await Promise.all([ getIssueBodyAndComments(repoPath, item.number, connectionId, localGitOptions), getWorkItemParticipants(repoPath, item, connectionId, localGitOptions) ]) @@ -824,7 +1031,8 @@ export async function getWorkItemDetails( body, comments, assignees, - participants: mentionParticipants + participants: mentionParticipants, + timelineItems } } diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index a417419855e..1ff788a6c95 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -20,6 +20,7 @@ import { Ban, Braces, Check, + CheckCircle2, ChevronDown, ChevronLeft, ChevronRight, @@ -33,9 +34,11 @@ import { GitPullRequest, GitPullRequestClosed, ListChecks, + Link2, LoaderCircle, MessageSquare, MessageSquarePlus, + MoveRight, PanelLeftOpen, Pencil, Plus, @@ -44,6 +47,8 @@ import { Send, Settings, UndoDot, + UserMinus, + UserPlus, Wrench, X } from 'lucide-react' @@ -140,7 +145,10 @@ import { type CommentCodeContextLineUpdate } from '@/components/comment-code-context-state' import { getPrCommentCodeContext } from '@/components/github/pr-comment-code-context' -import { resolveCommentReplyTarget } from '@/components/comment-reply-target-state' +import { + getCommentReplyTargetCandidates, + resolveCommentReplyTarget +} from '@/components/comment-reply-target-state' import { useAppStore } from '@/store' import { useAllWorktrees } from '@/store/selectors' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' @@ -181,6 +189,8 @@ import type { GitHubPRFileViewedState, GitHubWorkItem, GitHubWorkItemDetails, + GitHubIssueTimelineItem, + GitHubIssueTimelineTarget, GitHubAssignableUser, GitHubReaction, GitHubPRMergeMethod, @@ -2806,12 +2816,75 @@ function CommentCodeContext({ ) } +type IssueConversationEntry = + | { kind: 'comment'; id: string; createdAt: string; comment: PRComment; index: number } + | { + kind: 'activity' + id: string + createdAt: string + activity: GitHubIssueTimelineItem + index: number + } + +const EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS: GitHubIssueTimelineItem[] = [] + +function getTimelineSortValue(createdAt: string): number { + const value = new Date(createdAt).getTime() + return Number.isFinite(value) ? value : 0 +} + +function getIssueConversationEntries( + comments: PRComment[], + timelineItems: GitHubIssueTimelineItem[] +): IssueConversationEntry[] { + return [ + ...comments.map( + (comment, index): IssueConversationEntry => ({ + kind: 'comment', + id: `comment:${comment.id}`, + createdAt: comment.createdAt, + comment, + index + }) + ), + ...timelineItems.map( + (activity, index): IssueConversationEntry => ({ + kind: 'activity', + id: `activity:${activity.id}`, + createdAt: activity.createdAt, + activity, + index: comments.length + index + }) + ) + ].sort((a, b) => { + const diff = getTimelineSortValue(a.createdAt) - getTimelineSortValue(b.createdAt) + return diff === 0 ? a.index - b.index : diff + }) +} + +function getTimelineTargetLabel(target: GitHubIssueTimelineTarget): string { + const prefix = target.type === 'pr' ? 'PR' : 'issue' + const title = target.title ? ` ${target.title}` : '' + return `${prefix} #${target.number}${title}` +} + +function getTimelineStateReasonLabel(reason: string | null | undefined): string | null { + if (reason === 'completed') { + return translate('auto.components.GitHubItemDialog.timeline.completed', 'as completed') + } + if (reason === 'not_planned') { + return translate('auto.components.GitHubItemDialog.timeline.notPlanned', 'as not planned') + } + return null +} + function ConversationTab({ item, repoPath, sourceContext, body, comments, + timelineItems, files, headSha, baseSha, @@ -2833,6 +2906,7 @@ function ConversationTab({ sourceContext?: TaskSourceContext | null body: string comments: PRComment[] + timelineItems?: GitHubIssueTimelineItem[] files: GitHubPRFile[] headSha: string | undefined baseSha: string | undefined @@ -2860,7 +2934,13 @@ function ConversationTab({ [commentFilter, comments] ) const visibleCommentGroups = useMemo(() => groupPRComments(visibleComments), [visibleComments]) - const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, visibleComments) + const resolvedTimelineItems = timelineItems ?? EMPTY_GITHUB_ISSUE_TIMELINE_ITEMS + const issueConversationEntries = useMemo( + () => getIssueConversationEntries(comments, resolvedTimelineItems), + [comments, resolvedTimelineItems] + ) + const replyTargetComments = getCommentReplyTargetCandidates(item.type, comments, visibleComments) + const resolvedReplyingTo = resolveCommentReplyTarget(replyingTo, replyTargetComments) if (resolvedReplyingTo !== replyingTo) { // Why: comment filters/refetches can hide the active reply target; clear it @@ -3192,6 +3272,147 @@ function ConversationTab({ ) } + const renderTimelineTarget = (target: GitHubIssueTimelineTarget | undefined): React.ReactNode => { + if (!target) { + return null + } + return ( + + ) + } + + const renderTimelineActivityMessage = (activity: GitHubIssueTimelineItem): React.ReactNode => { + const assignee = + activity.assignee ?? translate('auto.components.GitHubItemDialog.timeline.someone', 'someone') + if (activity.event === 'assigned') { + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.assigned', 'assigned')}{' '} + {assignee} + + ) + } + if (activity.event === 'unassigned') { + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.unassigned', 'unassigned')}{' '} + {assignee} + + ) + } + if (activity.event === 'mentioned' || activity.event === 'cross-referenced') { + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.mentioned', 'mentioned this')} + {activity.source ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} + {renderTimelineTarget(activity.source)} + + ) : null} + + ) + } + if (activity.event === 'closed') { + const stateReason = getTimelineStateReasonLabel(activity.stateReason) + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.closed', 'closed this')} + {stateReason ? ` ${stateReason}` : ''} + {activity.closer ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} + {renderTimelineTarget(activity.closer)} + + ) : null} + + ) + } + if (activity.event === 'reopened') { + return translate('auto.components.GitHubItemDialog.timeline.reopened', 'reopened this') + } + const hasFrom = Boolean(activity.previousColumnName) + const hasTo = Boolean(activity.columnName) + return ( + <> + {translate('auto.components.GitHubItemDialog.timeline.moved', 'moved this')} + {hasFrom ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.from', 'from')}{' '} + {activity.previousColumnName} + + ) : null} + {hasTo ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.to', 'to')}{' '} + {activity.columnName} + + ) : null} + {activity.projectName ? ( + <> + {' '} + {translate('auto.components.GitHubItemDialog.timeline.in', 'in')}{' '} + {activity.projectName} + + ) : null} + + ) + } + + const renderTimelineActivity = (activity: GitHubIssueTimelineItem): React.JSX.Element => { + const Icon = + activity.event === 'assigned' + ? UserPlus + : activity.event === 'unassigned' + ? UserMinus + : activity.event === 'closed' + ? CheckCircle2 + : activity.event === 'reopened' + ? CircleDot + : activity.event === 'moved_columns_in_project' + ? MoveRight + : Link2 + return ( +
+ + + + {activity.actorAvatarUrl ? ( + + ) : null} +
+
+ {activity.actor} + {renderTimelineActivityMessage(activity)} + + {formatRelativeTime(activity.createdAt)} + +
+
+
+ ) + } + + const renderIssueConversationEntry = (entry: IssueConversationEntry): React.JSX.Element => + entry.kind === 'comment' + ? renderCommentCard(entry.comment) + : renderTimelineActivity(entry.activity) + return (
- + {item.type === 'issue' ? ( + + ) : ( + + )} - {translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} + {item.type === 'issue' + ? translate('auto.components.GitHubItemDialog.timeline.activity', 'Activity') + : translate('auto.components.GitHubItemDialog.1506916c09', 'Comments')} - {comments.length > 0 && ( + {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0) > 0 && ( - {comments.length} + {comments.length + (item.type === 'issue' ? resolvedTimelineItems.length : 0)} )}
@@ -3342,7 +3569,20 @@ function ConversationTab({
)} - {comments.length === 0 ? ( + {item.type === 'issue' ? ( + issueConversationEntries.length === 0 ? ( +
+ {translate( + 'auto.components.GitHubItemDialog.timeline.noActivity', + 'No activity yet.' + )} +
+ ) : ( +
+ {issueConversationEntries.map(renderIssueConversationEntry)} +
+ ) + ) : comments.length === 0 ? (
{translate('auto.components.GitHubItemDialog.5a94f3d0e9', 'No comments yet.')}
@@ -6151,15 +6391,6 @@ export default function GitHubItemDialog({ }) }, [repoPath, effectiveRepoId, workItem, issueSourcePreference]) - // Why: reset lifted edit state when the dialog switches items or when the - // same item receives an optimistic cache patch from the surrounding table. - useEffect(() => { - if (workItemState && workItemLabels) { - setLocalState(workItemState) - setLocalLabels(workItemLabels) - } - }, [workItemId, workItemState, workItemLabels]) - // Why: track comments added optimistically before the detail fetch resolves // so they can be merged into the fetch result instead of being overwritten. const optimisticCommentsRef = useRef([]) @@ -6257,6 +6488,19 @@ export default function GitHubItemDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [cachedEntry, workItem, optimisticTick]) + const resolvedWorkItemState = details?.item.state ?? workItemState + + // Why: the list row that opens the dialog can be stale; the detail payload + // carries the authoritative issue/PR state and should refresh local edit UI. + useEffect(() => { + if (resolvedWorkItemState) { + setLocalState(resolvedWorkItemState) + } + if (workItemLabels) { + setLocalLabels(workItemLabels) + } + }, [workItemId, resolvedWorkItemState, workItemLabels]) + const loading = !!cachedEntry?.pending && !cachedEntry?.details const error = cachedEntry?.error && !cachedEntry?.details ? cachedEntry.error : null const detailsLoaded = @@ -6393,6 +6637,7 @@ export default function GitHubItemDialog({ const body = details?.body ?? '' const comments = details?.comments ?? [] + const timelineItems = details?.timelineItems ?? [] const files = details?.files ?? [] const checks = details?.checks ?? [] const [pendingViewedPaths, setPendingViewedPaths] = useState>(() => new Set()) @@ -6910,6 +7155,7 @@ export default function GitHubItemDialog({ sourceContext={sourceContext} body={body} comments={comments} + timelineItems={timelineItems} files={files} headSha={details?.headSha} baseSha={details?.baseSha} @@ -7030,6 +7276,7 @@ export default function GitHubItemDialog({ sourceContext={sourceContext} body={body} comments={comments} + timelineItems={timelineItems} files={files} headSha={details?.headSha} baseSha={details?.baseSha} diff --git a/src/renderer/src/components/comment-reply-target-state.test.ts b/src/renderer/src/components/comment-reply-target-state.test.ts index bc4c050fa8e..f19cc94a620 100644 --- a/src/renderer/src/components/comment-reply-target-state.test.ts +++ b/src/renderer/src/components/comment-reply-target-state.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { resolveCommentReplyTarget } from './comment-reply-target-state' +import { + getCommentReplyTargetCandidates, + resolveCommentReplyTarget +} from './comment-reply-target-state' describe('comment reply target state', () => { it('preserves a visible reply target', () => { @@ -13,4 +16,17 @@ describe('comment reply target state', () => { it('keeps an empty reply target empty', () => { expect(resolveCommentReplyTarget(null, [{ id: 1 }])).toBeNull() }) + + it('uses filtered comments as reply targets for PRs', () => { + expect(getCommentReplyTargetCandidates('pr', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([ + { id: 2 } + ]) + }) + + it('uses all comments as reply targets for issues', () => { + expect(getCommentReplyTargetCandidates('issue', [{ id: 1 }, { id: 2 }], [{ id: 2 }])).toEqual([ + { id: 1 }, + { id: 2 } + ]) + }) }) diff --git a/src/renderer/src/components/comment-reply-target-state.ts b/src/renderer/src/components/comment-reply-target-state.ts index f25d9a69468..c6027d83321 100644 --- a/src/renderer/src/components/comment-reply-target-state.ts +++ b/src/renderer/src/components/comment-reply-target-state.ts @@ -2,6 +2,16 @@ export type CommentReplyTargetComment = { id: number } +export function getCommentReplyTargetCandidates( + itemType: 'issue' | 'pr', + comments: readonly CommentReplyTargetComment[], + visibleComments: readonly CommentReplyTargetComment[] +): readonly CommentReplyTargetComment[] { + // Why: the PR audience filter is hidden on issues, so stale PR filter state + // should not constrain issue reply targets after switching items. + return itemType === 'issue' ? comments : visibleComments +} + export function resolveCommentReplyTarget( replyingTo: number | null, visibleComments: readonly CommentReplyTargetComment[] diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 2f0f553b75c..5747673c5d8 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -822,7 +822,23 @@ "ba8e329d92": "Unmark viewed", "3f79ffc8b7": "Open the PR details to view current reviewers.", "5c1c973855": "Remove reviewer", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "timeline": { + "completed": "as completed", + "notPlanned": "as not planned", + "someone": "someone", + "assigned": "assigned", + "unassigned": "unassigned", + "mentioned": "mentioned this", + "in": "in", + "closed": "closed this", + "reopened": "reopened this", + "moved": "moved this", + "from": "from", + "to": "to", + "activity": "Activity", + "noActivity": "No activity yet." + } }, "GitLabItemDialog": { "65e784c1f1": "Reopen", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 5b2dc2c9818..59b6ebe93e0 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -822,7 +822,23 @@ "ba8e329d92": "Desmarcar visto", "3f79ffc8b7": "Abra los detalles de relaciones públicas para ver los revisores actuales.", "5c1c973855": "Eliminar revisor", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "timeline": { + "completed": "as completed", + "notPlanned": "as not planned", + "someone": "someone", + "assigned": "assigned", + "unassigned": "unassigned", + "mentioned": "mentioned this", + "in": "in", + "closed": "closed this", + "reopened": "reopened this", + "moved": "moved this", + "from": "from", + "to": "to", + "activity": "Activity", + "noActivity": "No activity yet." + } }, "GitLabItemDialog": { "65e784c1f1": "Reabrir", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 9ebbc5bce7b..19e6ed4fb9a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -822,7 +822,23 @@ "ba8e329d92": "閲覧済みのマークを外す", "3f79ffc8b7": "PR の詳細を開いて、現在のレビュアーを表示します。", "5c1c973855": "レビュアーを削除", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "timeline": { + "completed": "as completed", + "notPlanned": "as not planned", + "someone": "someone", + "assigned": "assigned", + "unassigned": "unassigned", + "mentioned": "mentioned this", + "in": "in", + "closed": "closed this", + "reopened": "reopened this", + "moved": "moved this", + "from": "from", + "to": "to", + "activity": "Activity", + "noActivity": "No activity yet." + } }, "GitLabItemDialog": { "65e784c1f1": "再度開く", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index b7311bbcacd..8c5e2c4ce93 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -822,7 +822,23 @@ "ba8e329d92": "본 것으로 표시 해제", "3f79ffc8b7": "현재 리뷰어를 보려면 PR 세부정보를 엽니다.", "5c1c973855": "리뷰어 삭제", - "commentTooLarge": "Comment is too large to submit safely." + "commentTooLarge": "Comment is too large to submit safely.", + "timeline": { + "completed": "as completed", + "notPlanned": "as not planned", + "someone": "someone", + "assigned": "assigned", + "unassigned": "unassigned", + "mentioned": "mentioned this", + "in": "in", + "closed": "closed this", + "reopened": "reopened this", + "moved": "moved this", + "from": "from", + "to": "to", + "activity": "Activity", + "noActivity": "No activity yet." + } }, "GitLabItemDialog": { "65e784c1f1": "다시 열기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index a1e06d3a9c7..a44ac9fc9fd 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -822,7 +822,23 @@ "ba8e329d92": "取消标记已查看", "3f79ffc8b7": "打开 PR 详情以查看当前评审人。", "5c1c973855": "移除评审人", - "commentTooLarge": "评论过长,无法安全提交。" + "commentTooLarge": "评论过长,无法安全提交。", + "timeline": { + "completed": "as completed", + "notPlanned": "as not planned", + "someone": "someone", + "assigned": "assigned", + "unassigned": "unassigned", + "mentioned": "mentioned this", + "in": "in", + "closed": "closed this", + "reopened": "reopened this", + "moved": "moved this", + "from": "from", + "to": "to", + "activity": "Activity", + "noActivity": "No activity yet." + } }, "GitLabItemDialog": { "65e784c1f1": "重新打开", diff --git a/src/shared/types.ts b/src/shared/types.ts index ef3254de59c..ff4992dee31 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1313,6 +1313,36 @@ export type PRComment = { isBot?: boolean } +export type GitHubIssueTimelineTarget = { + type: 'issue' | 'pr' + number: number + title: string + url: string + repository?: string +} + +export type GitHubIssueTimelineItem = { + id: string + event: + | 'assigned' + | 'unassigned' + | 'mentioned' + | 'cross-referenced' + | 'closed' + | 'reopened' + | 'moved_columns_in_project' + actor: string + actorAvatarUrl: string + createdAt: string + assignee?: string + source?: GitHubIssueTimelineTarget + closer?: GitHubIssueTimelineTarget + stateReason?: string | null + previousColumnName?: string | null + columnName?: string | null + projectName?: string | null +} + export type GitHubCommentResult = { ok: true; comment: PRComment } | { ok: false; error: string } export type IssueInfo = { @@ -1431,6 +1461,8 @@ export type GitHubWorkItemDetails = { item: Omit body: string comments: PRComment[] + /** Issue-only provider activity such as assignment, references, project moves, and state changes. */ + timelineItems?: GitHubIssueTimelineItem[] /** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */ headSha?: string baseSha?: string