mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
Add GitHub issue timeline activity feed to details dialog (#6330)
* Add GitHub issue timeline activity feed to details dialog Fetch and display issue timeline events (assignments, mentions, closes, reopenings, and project board column moves) in a unified activity tab within the GitHub item details dialog. - Query the GitHub REST timeline endpoint up to a bounded 300 items. - Merge comments and timeline events into a single sorted conversation. - Render tailored icons, links, and localizable messages per event. - Ensure issue reply targets are not incorrectly filtered by stale PR state. - Correctly update local item state from details payload. * Cap issue timeline pagination by supported activities Count only mapped, supported timeline items instead of raw REST events when checking the maximum item threshold. This prevents pagination from stopping early when pages contain many unsupported event types.
This commit is contained in:
@@ -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=')) ?? ''
|
||||
|
||||
@@ -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<typeof ghRepoExecOptions>
|
||||
): Promise<GitHubIssueTimelineItem[]> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user