diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 4c5360a..2b7bb9b 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -29,6 +29,8 @@ export interface GiteaIssue { comments: number created_at: string updated_at: string + /** 工单关闭时间(ISO 8601);未关闭为 null。完成列缺 PR 合并时间时用它排序。 */ + closed_at?: string | null body: string html_url: string user: { login: string, avatar_url?: string } | null diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index 4e24852..45a1a89 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -435,6 +435,7 @@ async function buildIssue(opts: { ...(pr ? { pr } : {}), ...(prMerged !== undefined ? { prMerged } : {}), ...(mergedAt ? { prMergedAt: mergedAt } : {}), + ...(issue.closed_at ? { closedAt: issue.closed_at } : {}), ...(branch ? { branch } : {}), ...(worktreePath ? { worktreePath } : {}), ...(worktreeExists !== undefined ? { worktreeExists } : {}), diff --git a/vscode/src/gitea/types.ts b/vscode/src/gitea/types.ts index beb214b..45ebea6 100644 --- a/vscode/src/gitea/types.ts +++ b/vscode/src/gitea/types.ts @@ -76,6 +76,8 @@ export interface Issue { prMerged?: boolean /** PR 合并时间(ISO 8601,来自 PR API 的 merged_at)。完成列按它降序排序,未合并为 undefined。合并时持久化进 state JSON;完成工单加载时不再实时拉 PR,直接读持久化值(缺失则按工单号回退排序)。 */ prMergedAt?: string + /** 工单关闭时间(ISO 8601,来自 Gitea issue 的 closed_at)。完成列缺 prMergedAt 时按它排序,直接关掉、没走 PR 的工单才不会沉底。 */ + closedAt?: string /** Branch name created for implementation, e.g. `feature/`. */ branch?: string /** Workspace-relative path to the implementation worktree. */ diff --git a/vscode/webview-ui/src/lib/issueSort.test.ts b/vscode/webview-ui/src/lib/issueSort.test.ts new file mode 100644 index 0000000..df897f4 --- /dev/null +++ b/vscode/webview-ui/src/lib/issueSort.test.ts @@ -0,0 +1,56 @@ +import type { Issue, IssueColumn } from '../types' +import { describe, expect, it } from 'vitest' +import { compareIssuesInColumn } from './issueSort' + +function issue( + number: number, + column: IssueColumn, + extra: Partial = {}, +): Issue { + return { + id: `repo#${number}`, + number, + title: `issue ${number}`, + column, + htmlUrl: '', + ...extra, + } +} + +function sorted(col: IssueColumn, issues: Issue[]): number[] { + return [...issues].sort((a, b) => compareIssuesInColumn(col, a, b)).map(i => i.number) +} + +describe('compareIssuesInColumn', () => { + it('非完成列按工单号降序', () => { + const list = [issue(10, 'todo'), issue(42, 'todo'), issue(7, 'todo')] + expect(sorted('todo', list)).toEqual([42, 10, 7]) + }) + + it('完成列按 PR 合并时间降序', () => { + const list = [ + issue(1, 'done', { prMergedAt: '2026-09-01T00:00:00Z' }), + issue(2, 'done', { prMergedAt: '2026-09-20T00:00:00Z' }), + issue(3, 'done', { prMergedAt: '2026-09-10T00:00:00Z' }), + ] + expect(sorted('done', list)).toEqual([2, 3, 1]) + }) + + it('没有 PR 的完成工单用关闭时间参与同一条时间线,不沉底', () => { + const list = [ + issue(100, 'done', { prMergedAt: '2026-09-01T00:00:00Z' }), + issue(427, 'done', { closedAt: '2026-09-20T12:00:00Z' }), + issue(300, 'done', { prMergedAt: '2026-09-10T00:00:00Z' }), + ] + expect(sorted('done', list)).toEqual([427, 300, 100]) + }) + + it('两个时间都缺的排在最后,按工单号降序', () => { + const list = [ + issue(5, 'done'), + issue(9, 'done'), + issue(1, 'done', { closedAt: '2026-01-01T00:00:00Z' }), + ] + expect(sorted('done', list)).toEqual([1, 9, 5]) + }) +}) diff --git a/vscode/webview-ui/src/lib/issueSort.ts b/vscode/webview-ui/src/lib/issueSort.ts index 3b7a95d..33ba083 100644 --- a/vscode/webview-ui/src/lib/issueSort.ts +++ b/vscode/webview-ui/src/lib/issueSort.ts @@ -3,13 +3,19 @@ import type { Issue, IssueColumn } from '../types' /** * 看板每一列的卡片排序。 * - * `done` 列按 PR 合并时间(merged_at)降序,最近合并的在最前;缺合并时间的 - * 工单排在有时间的之后,按工单号降序兜底。其余列一律按工单号降序。 + * `done` 列按"完成时间"降序,最近完成的在最前。完成时间取 PR 合并时间 + * (merged_at),没有 PR 的工单退回工单关闭时间(closed_at)——不这样兜底, + * 直接关掉、没走 PR 流程的工单会永远沉在列底看不见。两者都没有的排最后, + * 按工单号降序。其余列一律按工单号降序。 */ +function doneAt(issue: Issue): string | undefined { + return issue.prMergedAt ?? issue.closedAt +} + export function compareIssuesInColumn(col: IssueColumn, a: Issue, b: Issue): number { if (col === 'done') { - const am = a.prMergedAt - const bm = b.prMergedAt + const am = doneAt(a) + const bm = doneAt(b) if (am && bm && am !== bm) return bm.localeCompare(am) if (am && !bm) diff --git a/vscode/webview-ui/src/types.ts b/vscode/webview-ui/src/types.ts index b90e226..0caecd0 100644 --- a/vscode/webview-ui/src/types.ts +++ b/vscode/webview-ui/src/types.ts @@ -53,6 +53,8 @@ export interface Issue { prMerged?: boolean /** PR 合并时间(ISO 8601,来自 merged_at)。完成列按它降序排序,未合并/查询失败为 undefined。不持久化进 state JSON。 */ prMergedAt?: string + /** 工单关闭时间(ISO 8601)。完成列缺 prMergedAt 时按它排序。 */ + closedAt?: string /** Branch name created for implementation, e.g. `feature/`. */ branch?: string /** Workspace-relative path to the implementation worktree. */