Compare commits
2
Commits
8d70ee1659
...
851be48c43
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
851be48c43 | ||
|
|
3134201243 |
No files matched your search
+1
-1
@@ -2,7 +2,7 @@
|
||||
"publisher": "clurdra",
|
||||
"name": "superpowers-vscode-clurdra",
|
||||
"displayName": "Superpowers-clurdra",
|
||||
"version": "0.2.238",
|
||||
"version": "0.2.239",
|
||||
"packageManager": "pnpm@10.27.0",
|
||||
"description": "Superpowers specs and plans Kanban explorer",
|
||||
"author": "clurdra",
|
||||
|
||||
@@ -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 所在。
|
||||
*/
|
||||
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: {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user