🐛 fix(vscode): 修复评论全量接口被误分页导致审查反馈卡住

This commit is contained in:
chehongweo committed 2026-10-06 23:21:21 +08:00
1 parent 8d70ee1659
commit 3134201243
7 files changed
+71 -122

No files matched your search

+29
View File
@@ -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) }),
)
})
})
+8 -40
View File
@@ -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<GiteaComment[]> {
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<GiteaComment[]> {
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: {
+21 -24
View File
@@ -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')
})
+6 -16
View File
@@ -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<T, R>(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<GiteaComment[]> {
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,
+2 -2
View File
@@ -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)
}
/**
+1 -5
View File
@@ -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
+4 -35
View File
@@ -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 `<!-- spx:review=1 -->` 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<void> {
@@ -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}`,
})
}