🐛 fix(vscode): 修复评论全量接口被误分页导致审查反馈卡住
This commit is contained in:
1 parent
8d70ee1659
commit
3134201243
7 files changed
+71
-122
No files matched your search
@@ -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
@@ -235,33 +235,7 @@ export async function listAllRepoComments(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 单页拉取某工单评论。Gitea 默认旧→新,末页才是最新 state JSON 所在。
|
* 单个工单的评论接口一次返回全部评论,只支持 since / before 时间筛选。
|
||||||
*/
|
|
||||||
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).
|
|
||||||
*/
|
*/
|
||||||
export async function listIssueComments(opts: {
|
export async function listIssueComments(opts: {
|
||||||
host: string
|
host: string
|
||||||
@@ -270,19 +244,13 @@ export async function listIssueComments(opts: {
|
|||||||
repo: string
|
repo: string
|
||||||
index: number
|
index: number
|
||||||
}): Promise<GiteaComment[]> {
|
}): Promise<GiteaComment[]> {
|
||||||
const out: GiteaComment[] = []
|
const url = new URL(
|
||||||
let page = 1
|
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
|
||||||
|
)
|
||||||
while (true) {
|
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
|
||||||
const batch = await listIssueCommentsPage({ ...opts, page })
|
await ensureOk(res)
|
||||||
if (batch.length === 0)
|
const batch = await res.json() as GiteaComment[]
|
||||||
break
|
return Array.isArray(batch) ? batch : []
|
||||||
out.push(...batch)
|
|
||||||
if (batch.length < PAGE_SIZE)
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postIssueComment(opts: {
|
export async function postIssueComment(opts: {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
|
|
||||||
const getCurrentUser = vi.fn()
|
const getCurrentUser = vi.fn()
|
||||||
const listIssuesByFilter = vi.fn()
|
const listIssuesByFilter = vi.fn()
|
||||||
const listIssueCommentsPage = vi.fn()
|
|
||||||
const listAllRepoComments = vi.fn()
|
const listAllRepoComments = vi.fn()
|
||||||
const getDependencies = vi.fn()
|
const getDependencies = vi.fn()
|
||||||
const getPullRequest = vi.fn()
|
const getPullRequest = vi.fn()
|
||||||
@@ -15,7 +14,6 @@ vi.mock('./api', () => ({
|
|||||||
PAGE_SIZE: 50,
|
PAGE_SIZE: 50,
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
listIssuesByFilter,
|
listIssuesByFilter,
|
||||||
listIssueCommentsPage,
|
|
||||||
listAllRepoComments,
|
listAllRepoComments,
|
||||||
getDependencies,
|
getDependencies,
|
||||||
getPullRequest,
|
getPullRequest,
|
||||||
@@ -52,14 +50,14 @@ function comment(body: string, index: number, id: number): GiteaComment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('listCommentsUntilColumn', () => {
|
describe('listCommentsForIssue', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
listIssueCommentsPage.mockReset()
|
listIssueComments.mockReset()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('commentCount 为 0 时不发请求', async () => {
|
it('commentCount 为 0 时不发请求', async () => {
|
||||||
const { listCommentsUntilColumn } = await import('./issueLoader.js')
|
const { listCommentsForIssue } = await import('./issueLoader.js')
|
||||||
const out = await listCommentsUntilColumn({
|
const out = await listCommentsForIssue({
|
||||||
host: 'gitea.example',
|
host: 'gitea.example',
|
||||||
token: 't',
|
token: 't',
|
||||||
owner: 'o',
|
owner: 'o',
|
||||||
@@ -68,29 +66,28 @@ describe('listCommentsUntilColumn', () => {
|
|||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
})
|
})
|
||||||
expect(out).toEqual([])
|
expect(out).toEqual([])
|
||||||
expect(listIssueCommentsPage).not.toHaveBeenCalled()
|
expect(listIssueComments).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('先拉末页,没有 column JSON 再往前翻', async () => {
|
it('超过 50 条评论时一次读取,完整保留状态及其后的讨论', async () => {
|
||||||
const { listCommentsUntilColumn } = await import('./issueLoader.js')
|
const { listCommentsForIssue } = await import('./issueLoader.js')
|
||||||
listIssueCommentsPage.mockImplementation(async (opts: { page: number }) => {
|
const comments = [
|
||||||
if (opts.page === 2)
|
comment(JSON.stringify({ column: 'in-progress' }), 1, 1),
|
||||||
return [comment('later discussion', 1, 51)]
|
...Array.from({ length: 77 }, (_, i) => comment('later discussion', 1, i + 2)),
|
||||||
if (opts.page === 1)
|
]
|
||||||
return [comment(JSON.stringify({ column: 'in-progress' }), 1, 1)]
|
listIssueComments.mockResolvedValue(comments)
|
||||||
return []
|
|
||||||
})
|
|
||||||
|
|
||||||
const out = await listCommentsUntilColumn({
|
const out = await listCommentsForIssue({
|
||||||
host: 'gitea.example',
|
host: 'gitea.example',
|
||||||
token: 't',
|
token: 't',
|
||||||
owner: 'o',
|
owner: 'o',
|
||||||
repo: 'r',
|
repo: 'r',
|
||||||
index: 1,
|
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.at(-1)?.body).toContain('later discussion')
|
||||||
expect(out[0]?.body).toContain('in-progress')
|
expect(out[0]?.body).toContain('in-progress')
|
||||||
})
|
})
|
||||||
@@ -100,22 +97,22 @@ describe('loadIssues', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getCurrentUser.mockReset()
|
getCurrentUser.mockReset()
|
||||||
listIssuesByFilter.mockReset()
|
listIssuesByFilter.mockReset()
|
||||||
listIssueCommentsPage.mockReset()
|
listIssueComments.mockReset()
|
||||||
listAllRepoComments.mockReset()
|
listAllRepoComments.mockReset()
|
||||||
getDependencies.mockReset()
|
getDependencies.mockReset()
|
||||||
getPullRequest.mockReset()
|
getPullRequest.mockReset()
|
||||||
postIssueComment.mockReset()
|
postIssueComment.mockReset()
|
||||||
getCurrentUser.mockResolvedValue({ login: 'me', id: 1 })
|
getCurrentUser.mockResolvedValue({ login: 'me', id: 1 })
|
||||||
listIssuesByFilter.mockResolvedValue([])
|
listIssuesByFilter.mockResolvedValue([])
|
||||||
listIssueCommentsPage.mockResolvedValue([])
|
listIssueComments.mockResolvedValue([])
|
||||||
getDependencies.mockResolvedValue([])
|
getDependencies.mockResolvedValue([])
|
||||||
postIssueComment.mockResolvedValue({})
|
postIssueComment.mockResolvedValue({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('不走整仓评论消防水管,只按工单拉末页', async () => {
|
it('不读取整仓评论,只读取指定工单的评论', async () => {
|
||||||
const { loadIssues } = await import('./issueLoader.js')
|
const { loadIssues } = await import('./issueLoader.js')
|
||||||
listIssuesByFilter.mockResolvedValue([issue(7, { comments: 3 })])
|
listIssuesByFilter.mockResolvedValue([issue(7, { comments: 3 })])
|
||||||
listIssueCommentsPage.mockResolvedValue([
|
listIssueComments.mockResolvedValue([
|
||||||
comment(JSON.stringify({ column: 'todo' }), 7, 1),
|
comment(JSON.stringify({ column: 'todo' }), 7, 1),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -129,7 +126,7 @@ describe('loadIssues', () => {
|
|||||||
|
|
||||||
expect(me).toBe('me')
|
expect(me).toBe('me')
|
||||||
expect(listAllRepoComments).not.toHaveBeenCalled()
|
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).toHaveLength(1)
|
||||||
expect(issues[0].column).toBe('todo')
|
expect(issues[0].column).toBe('todo')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*
|
*
|
||||||
* Flow:
|
* Flow:
|
||||||
* 1. 拉工单列表(mine:先 /user 再 assigned+created;all:工单与 /user 并行)。
|
* 1. 拉工单列表(mine:先 /user 再 assigned+created;all:工单与 /user 并行)。
|
||||||
* 2. 每张工单只从评论末页往前翻,直到碰到带 column 的 state JSON。
|
* 2. 每张工单一次读取全部评论,从中解析最新的 column state JSON。
|
||||||
* 不拉整仓库评论。缺 state 时只在内存里用默认列,不发评论。
|
* 不拉整仓库评论。缺 state 时只在内存里用默认列,不发评论。
|
||||||
* 3. 非 done 工单并行补依赖和 PR 状态。
|
* 3. 非 done 工单并行补依赖和 PR 状态。
|
||||||
*/
|
*/
|
||||||
@@ -21,9 +21,7 @@ import {
|
|||||||
getIssue,
|
getIssue,
|
||||||
getPullRequest,
|
getPullRequest,
|
||||||
listIssueComments,
|
listIssueComments,
|
||||||
listIssueCommentsPage,
|
|
||||||
listIssuesByFilter,
|
listIssuesByFilter,
|
||||||
PAGE_SIZE,
|
|
||||||
} from './api'
|
} from './api'
|
||||||
import { resolveAvatars } from './avatars'
|
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 不发请求。
|
* commentCount=0 不发请求。
|
||||||
*/
|
*/
|
||||||
export async function listCommentsUntilColumn(opts: {
|
export async function listCommentsForIssue(opts: {
|
||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
owner: string
|
owner: string
|
||||||
@@ -304,15 +302,7 @@ export async function listCommentsUntilColumn(opts: {
|
|||||||
}): Promise<GiteaComment[]> {
|
}): Promise<GiteaComment[]> {
|
||||||
if (opts.commentCount <= 0)
|
if (opts.commentCount <= 0)
|
||||||
return []
|
return []
|
||||||
const lastPage = Math.max(1, Math.ceil(opts.commentCount / PAGE_SIZE))
|
return listIssueComments(opts)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -500,9 +490,9 @@ export async function loadIssues(opts: {
|
|||||||
}
|
}
|
||||||
merged = merged.filter(issue => !isMailboxIssue(issue))
|
merged = merged.filter(issue => !isMailboxIssue(issue))
|
||||||
|
|
||||||
// ② 每张工单从评论末页往前翻,直到有 column
|
// ② 每张工单读取一次评论,再从中解析 column
|
||||||
const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue =>
|
const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue =>
|
||||||
listCommentsUntilColumn({
|
listCommentsForIssue({
|
||||||
host,
|
host,
|
||||||
token,
|
token,
|
||||||
owner,
|
owner,
|
||||||
|
|||||||
@@ -854,8 +854,8 @@ export class KanbanWebviewPanel {
|
|||||||
* present in `window.terminals` but absent from `this.implTerminals`.
|
* present in `window.terminals` but absent from `this.implTerminals`.
|
||||||
*/
|
*/
|
||||||
// internal: handler 模块访问
|
// internal: handler 模块访问
|
||||||
injectIntoImplTerminal(issueNumber: number, text: string, isFirstReview: boolean): boolean {
|
injectIntoImplTerminal(issueNumber: number, text: string): boolean {
|
||||||
return terminals.injectIntoImplTerminal(this, issueNumber, text, isFirstReview)
|
return terminals.injectIntoImplTerminal(this, issueNumber, text)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,11 +40,7 @@ export function resolveTerminalLocation(panel: KanbanWebviewPanel | null, preser
|
|||||||
* happens — e.g. after a window reload, the impl terminal might still be
|
* happens — e.g. after a window reload, the impl terminal might still be
|
||||||
* present in `window.terminals` but absent from `this.implTerminals`.
|
* present in `window.terminals` but absent from `this.implTerminals`.
|
||||||
*/
|
*/
|
||||||
export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: number, text: string, isFirstReview: boolean): boolean {
|
export function injectIntoImplTerminal(panel: KanbanWebviewPanel, issueNumber: number, text: string): boolean {
|
||||||
// `isFirstReview` 参数保留只为兼容签名(调用方仍按 first/再审 区分),
|
|
||||||
// 方法体本身不再消费它——合并改由用户拖工单到"完成"列时插件 API 触发,
|
|
||||||
// cc 不允许自行合并。
|
|
||||||
void isFirstReview
|
|
||||||
let terminal = panel.implTerminals.get(issueNumber)
|
let terminal = panel.implTerminals.get(issueNumber)
|
||||||
if (!terminal) {
|
if (!terminal) {
|
||||||
// Match by prefix — OSC title escapes can append a suffix to
|
// Match by prefix — OSC title escapes can append a suffix to
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import { getToken } from '../auth/secrets'
|
|||||||
import { renderReviewPrompt } from '../cc/prompts'
|
import { renderReviewPrompt } from '../cc/prompts'
|
||||||
import { isMailboxIssue } from '../cc/sessionHandoff'
|
import { isMailboxIssue } from '../cc/sessionHandoff'
|
||||||
import { detectRepo } from '../git/remote'
|
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 { cleanCommentSummary, systemCommentText } from '../gitea/issueComment'
|
||||||
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
|
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
|
||||||
import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers'
|
import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers'
|
||||||
@@ -1444,10 +1444,6 @@ class WebhookCoordinator {
|
|||||||
* Comments without the `<!-- spx:review=1 -->` marker are ignored. Edited /
|
* Comments without the `<!-- spx:review=1 -->` marker are ignored. Edited /
|
||||||
* deleted comments are also ignored (the dispatcher only routes 'created').
|
* 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> {
|
private async notifyHandoffRequest(event: IssueCommentWebhookEvent): Promise<void> {
|
||||||
@@ -1636,39 +1632,12 @@ class WebhookCoordinator {
|
|||||||
|
|
||||||
if (await this.resolveOwnership(realIssueNumber, ctx) === 'observe') {
|
if (await this.resolveOwnership(realIssueNumber, ctx) === 'observe') {
|
||||||
// 审查反馈只注入责任人机器的实施终端;其他机器上本来也找不到
|
// 审查反馈只注入责任人机器的实施终端;其他机器上本来也找不到
|
||||||
// 该终端,提前拦截省掉后面的评论计数 API 往返。
|
// 该终端,不执行本地注入。
|
||||||
return
|
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
|
const injected = this.activePanel
|
||||||
? this.activePanel.injectIntoImplTerminal(realIssueNumber, text, isFirstReview)
|
? this.activePanel.injectIntoImplTerminal(realIssueNumber, text)
|
||||||
: false
|
: false
|
||||||
if (!injected) {
|
if (!injected) {
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -1682,7 +1651,7 @@ class WebhookCoordinator {
|
|||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
source: 'webhook',
|
source: 'webhook',
|
||||||
message: `审查反馈已注入实施终端 #${realIssueNumber} isFirstReview=${isFirstReview} length=${text.length}`,
|
message: `审查反馈已注入实施或测试终端 #${realIssueNumber} length=${text.length}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user