diff --git a/vscode/src/gitea/api.comments.test.ts b/vscode/src/gitea/api.comments.test.ts new file mode 100644 index 0000000..d91eae8 --- /dev/null +++ b/vscode/src/gitea/api.comments.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('../logging/logger', () => ({ logger: { add: vi.fn() } })) + +afterEach(() => vi.unstubAllGlobals()) + +describe('单个工单评论接口', () => { + it.each([0, 50, 78])('返回 %i 条评论时只发一次请求,不传分页参数', async (count) => { + const { listIssueComments } = await import('./api.js') + const comments = Array.from({ length: count }, (_, i) => ({ id: i + 1, body: `评论 ${i}` })) + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(comments))) + vi.stubGlobal('fetch', fetch) + + const result = await listIssueComments({ + host: 'gitea.example', + token: 'test-token', + owner: 'owner', + repo: 'repo', + index: 408, + }) + + expect(result).toEqual(comments) + expect(fetch).toHaveBeenCalledOnce() + expect(fetch).toHaveBeenCalledWith( + 'https://gitea.example/api/v1/repos/owner/repo/issues/408/comments', + expect.objectContaining({ headers: expect.any(Object) }), + ) + }) +}) diff --git a/vscode/src/gitea/api.ts b/vscode/src/gitea/api.ts index 7123039..085928d 100644 --- a/vscode/src/gitea/api.ts +++ b/vscode/src/gitea/api.ts @@ -235,33 +235,7 @@ export async function listAllRepoComments(opts: { } /** - * 单页拉取某工单评论。Gitea 默认旧→新,末页才是最新 state JSON 所在。 - */ -export async function listIssueCommentsPage(opts: { - host: string - token: string - owner: string - repo: string - index: number - page: number -}): Promise { - const url = new URL( - `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`, - ) - url.searchParams.set('limit', String(PAGE_SIZE)) - url.searchParams.set('page', String(opts.page)) - - const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) - await ensureOk(res) - const batch = await res.json() as GiteaComment[] - return Array.isArray(batch) ? batch : [] -} - -/** - * Fetches comments for a single issue via - * `/repos/{owner}/{repo}/issues/{index}/comments`. Paginated until a short - * page is returned. Used when we need the freshest view of one issue's - * state-JSON comment (e.g. mutating the last comment in place). + * 单个工单的评论接口一次返回全部评论,只支持 since / before 时间筛选。 */ export async function listIssueComments(opts: { host: string @@ -270,19 +244,13 @@ export async function listIssueComments(opts: { repo: string index: number }): Promise { - const out: GiteaComment[] = [] - let page = 1 - - while (true) { - const batch = await listIssueCommentsPage({ ...opts, page }) - if (batch.length === 0) - break - out.push(...batch) - if (batch.length < PAGE_SIZE) - break - page += 1 - } - return out + const url = new URL( + `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`, + ) + const res = await fetch(url.toString(), { headers: authHeaders(opts.token) }) + await ensureOk(res) + const batch = await res.json() as GiteaComment[] + return Array.isArray(batch) ? batch : [] } export async function postIssueComment(opts: { diff --git a/vscode/src/gitea/issueLoader.test.ts b/vscode/src/gitea/issueLoader.test.ts index 985f784..7779fa6 100644 --- a/vscode/src/gitea/issueLoader.test.ts +++ b/vscode/src/gitea/issueLoader.test.ts @@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const getCurrentUser = vi.fn() const listIssuesByFilter = vi.fn() -const listIssueCommentsPage = vi.fn() const listAllRepoComments = vi.fn() const getDependencies = vi.fn() const getPullRequest = vi.fn() @@ -15,7 +14,6 @@ vi.mock('./api', () => ({ PAGE_SIZE: 50, getCurrentUser, listIssuesByFilter, - listIssueCommentsPage, listAllRepoComments, getDependencies, getPullRequest, @@ -52,14 +50,14 @@ function comment(body: string, index: number, id: number): GiteaComment { } } -describe('listCommentsUntilColumn', () => { +describe('listCommentsForIssue', () => { beforeEach(() => { - listIssueCommentsPage.mockReset() + listIssueComments.mockReset() }) it('commentCount 为 0 时不发请求', async () => { - const { listCommentsUntilColumn } = await import('./issueLoader.js') - const out = await listCommentsUntilColumn({ + const { listCommentsForIssue } = await import('./issueLoader.js') + const out = await listCommentsForIssue({ host: 'gitea.example', token: 't', owner: 'o', @@ -68,29 +66,28 @@ describe('listCommentsUntilColumn', () => { commentCount: 0, }) expect(out).toEqual([]) - expect(listIssueCommentsPage).not.toHaveBeenCalled() + expect(listIssueComments).not.toHaveBeenCalled() }) - it('先拉末页,没有 column JSON 再往前翻', async () => { - const { listCommentsUntilColumn } = await import('./issueLoader.js') - listIssueCommentsPage.mockImplementation(async (opts: { page: number }) => { - if (opts.page === 2) - return [comment('later discussion', 1, 51)] - if (opts.page === 1) - return [comment(JSON.stringify({ column: 'in-progress' }), 1, 1)] - return [] - }) + it('超过 50 条评论时一次读取,完整保留状态及其后的讨论', async () => { + const { listCommentsForIssue } = await import('./issueLoader.js') + const comments = [ + comment(JSON.stringify({ column: 'in-progress' }), 1, 1), + ...Array.from({ length: 77 }, (_, i) => comment('later discussion', 1, i + 2)), + ] + listIssueComments.mockResolvedValue(comments) - const out = await listCommentsUntilColumn({ + const out = await listCommentsForIssue({ host: 'gitea.example', token: 't', owner: 'o', repo: 'r', index: 1, - commentCount: 51, + commentCount: 78, }) - expect(listIssueCommentsPage.mock.calls.map(c => (c[0] as { page: number }).page)).toEqual([2, 1]) + expect(listIssueComments).toHaveBeenCalledOnce() + expect(out).toEqual(comments) expect(out.at(-1)?.body).toContain('later discussion') expect(out[0]?.body).toContain('in-progress') }) @@ -100,22 +97,22 @@ describe('loadIssues', () => { beforeEach(() => { getCurrentUser.mockReset() listIssuesByFilter.mockReset() - listIssueCommentsPage.mockReset() + listIssueComments.mockReset() listAllRepoComments.mockReset() getDependencies.mockReset() getPullRequest.mockReset() postIssueComment.mockReset() getCurrentUser.mockResolvedValue({ login: 'me', id: 1 }) listIssuesByFilter.mockResolvedValue([]) - listIssueCommentsPage.mockResolvedValue([]) + listIssueComments.mockResolvedValue([]) getDependencies.mockResolvedValue([]) postIssueComment.mockResolvedValue({}) }) - it('不走整仓评论消防水管,只按工单拉末页', async () => { + it('不读取整仓评论,只读取指定工单的评论', async () => { const { loadIssues } = await import('./issueLoader.js') listIssuesByFilter.mockResolvedValue([issue(7, { comments: 3 })]) - listIssueCommentsPage.mockResolvedValue([ + listIssueComments.mockResolvedValue([ comment(JSON.stringify({ column: 'todo' }), 7, 1), ]) @@ -129,7 +126,7 @@ describe('loadIssues', () => { expect(me).toBe('me') expect(listAllRepoComments).not.toHaveBeenCalled() - expect(listIssueCommentsPage).toHaveBeenCalledWith(expect.objectContaining({ index: 7, page: 1 })) + expect(listIssueComments).toHaveBeenCalledWith(expect.objectContaining({ index: 7 })) expect(issues).toHaveLength(1) expect(issues[0].column).toBe('todo') }) diff --git a/vscode/src/gitea/issueLoader.ts b/vscode/src/gitea/issueLoader.ts index ddb2dfe..776a886 100644 --- a/vscode/src/gitea/issueLoader.ts +++ b/vscode/src/gitea/issueLoader.ts @@ -5,7 +5,7 @@ * * Flow: * 1. 拉工单列表(mine:先 /user 再 assigned+created;all:工单与 /user 并行)。 - * 2. 每张工单只从评论末页往前翻,直到碰到带 column 的 state JSON。 + * 2. 每张工单一次读取全部评论,从中解析最新的 column state JSON。 * 不拉整仓库评论。缺 state 时只在内存里用默认列,不发评论。 * 3. 非 done 工单并行补依赖和 PR 状态。 */ @@ -21,9 +21,7 @@ import { getIssue, getPullRequest, listIssueComments, - listIssueCommentsPage, listIssuesByFilter, - PAGE_SIZE, } from './api' import { resolveAvatars } from './avatars' @@ -291,10 +289,10 @@ async function mapPool(items: T[], limit: number, fn: (item: T) => Promise } /** - * 从末页往前拼评论,直到 parse 出 column 或翻到第 1 页。 + * 按工单读取评论,用于解析看板状态。 * commentCount=0 不发请求。 */ -export async function listCommentsUntilColumn(opts: { +export async function listCommentsForIssue(opts: { host: string token: string owner: string @@ -304,15 +302,7 @@ export async function listCommentsUntilColumn(opts: { }): Promise { if (opts.commentCount <= 0) return [] - const lastPage = Math.max(1, Math.ceil(opts.commentCount / PAGE_SIZE)) - let comments: GiteaComment[] = [] - for (let page = lastPage; page >= 1; page--) { - const batch = await listIssueCommentsPage({ ...opts, page }) - comments = [...batch, ...comments] - if (parseColumnFromComments(comments).column) - break - } - return comments + return listIssueComments(opts) } /** @@ -500,9 +490,9 @@ export async function loadIssues(opts: { } merged = merged.filter(issue => !isMailboxIssue(issue)) - // ② 每张工单从评论末页往前翻,直到有 column + // ② 每张工单读取一次评论,再从中解析 column const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue => - listCommentsUntilColumn({ + listCommentsForIssue({ host, token, owner, diff --git a/vscode/src/panel/KanbanPanel.ts b/vscode/src/panel/KanbanPanel.ts index b31945e..c96f955 100644 --- a/vscode/src/panel/KanbanPanel.ts +++ b/vscode/src/panel/KanbanPanel.ts @@ -854,8 +854,8 @@ export class KanbanWebviewPanel { * present in `window.terminals` but absent from `this.implTerminals`. */ // internal: handler 模块访问 - injectIntoImplTerminal(issueNumber: number, text: string, isFirstReview: boolean): boolean { - return terminals.injectIntoImplTerminal(this, issueNumber, text, isFirstReview) + injectIntoImplTerminal(issueNumber: number, text: string): boolean { + return terminals.injectIntoImplTerminal(this, issueNumber, text) } /** diff --git a/vscode/src/panel/handlers/terminals.ts b/vscode/src/panel/handlers/terminals.ts index 468ecae..96a4402 100644 --- a/vscode/src/panel/handlers/terminals.ts +++ b/vscode/src/panel/handlers/terminals.ts @@ -40,11 +40,7 @@ export function resolveTerminalLocation(panel: KanbanWebviewPanel | null, preser * happens — e.g. after a window reload, the impl terminal might still be * present in `window.terminals` but absent from `this.implTerminals`. */ -export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: number, text: string, isFirstReview: boolean): boolean { - // `isFirstReview` 参数保留只为兼容签名(调用方仍按 first/再审 区分), - // 方法体本身不再消费它——合并改由用户拖工单到"完成"列时插件 API 触发, - // cc 不允许自行合并。 - void isFirstReview +export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: number, text: string): boolean { let terminal = panel.implTerminals.get(issueNumber) if (!terminal) { // Match by prefix — OSC title escapes can append a suffix to diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 4ec8fd8..5c5babe 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -21,7 +21,7 @@ import { getToken } from '../auth/secrets' import { renderReviewPrompt } from '../cc/prompts' import { isMailboxIssue } from '../cc/sessionHandoff' import { detectRepo } from '../git/remote' -import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api' +import { deleteWebhook, getIssue, getPullRequest } from '../gitea/api' import { cleanCommentSummary, systemCommentText } from '../gitea/issueComment' import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers' @@ -1444,10 +1444,6 @@ class WebhookCoordinator { * Comments without the `` marker are ignored. Edited / * deleted comments are also ignored (the dispatcher only routes 'created'). * - * `isFirstReview` is derived by counting the marker across the issue's - * existing comments — when this is the only one (count <= 1), it's the - * first review and `injectIntoImplTerminal` appends the merge-to-main - * suffix. */ /** 申请移交时给负责人发系统通知。看板关着也发。 */ private async notifyHandoffRequest(event: IssueCommentWebhookEvent): Promise { @@ -1636,39 +1632,12 @@ class WebhookCoordinator { if (await this.resolveOwnership(realIssueNumber, ctx) === 'observe') { // 审查反馈只注入责任人机器的实施终端;其他机器上本来也找不到 - // 该终端,提前拦截省掉后面的评论计数 API 往返。 + // 该终端,不执行本地注入。 return } - // Count marker comments to determine isFirstReview. This call also - // includes the just-posted comment, so a first review yields count=1. - let reviewCount = 1 - try { - // 审查评论 post 在 PR 上,不是工单 issue 上。如果当前 comment 来自 PR - // (event.prNumber !== undefined),就去 PR 评论流数;否则才走工单号。 - const commentsIndex = event.prNumber ?? realIssueNumber - const comments = await listIssueComments({ - host: ctx.host, - token: ctx.token, - owner: ctx.owner, - repo: ctx.repo, - index: commentsIndex, - }) - reviewCount = comments.filter(c => hasReviewMarker(c.body ?? '')).length - } - catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger.add({ - level: 'warn', - source: 'webhook', - message: `统计审查评论数失败 #${realIssueNumber}(按首次处理)`, - details: message, - }) - } - const isFirstReview = reviewCount <= 1 - const injected = this.activePanel - ? this.activePanel.injectIntoImplTerminal(realIssueNumber, text, isFirstReview) + ? this.activePanel.injectIntoImplTerminal(realIssueNumber, text) : false if (!injected) { logger.add({ @@ -1682,7 +1651,7 @@ class WebhookCoordinator { logger.add({ level: 'info', source: 'webhook', - message: `审查反馈已注入实施终端 #${realIssueNumber} isFirstReview=${isFirstReview} length=${text.length}`, + message: `审查反馈已注入实施或测试终端 #${realIssueNumber} length=${text.length}`, }) }