⚡ perf(vscode): 看板打开先出快照、评论只拉末页

This commit is contained in:
cruldra committed 2026-09-01 07:09:55 +08:00
1 parent 904ab7491d
commit a68caf5f6e
9 files changed
+447 -115

No files matched your search

+26 -11
View File
@@ -13,7 +13,7 @@ import { Buffer } from 'node:buffer'
import { logger } from '../logging/logger'
const PAGE_SIZE = 50
export const PAGE_SIZE = 50
export interface GiteaUser {
login: string
@@ -226,6 +226,29 @@ export async function listAllRepoComments(opts: {
return out
}
/**
* 单页拉取某工单评论。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
@@ -243,16 +266,8 @@ export async function listIssueComments(opts: {
let page = 1
while (true) {
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(page))
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const batch = await res.json() as GiteaComment[]
if (!Array.isArray(batch) || batch.length === 0)
const batch = await listIssueCommentsPage({ ...opts, page })
if (batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
+154
View File
@@ -0,0 +1,154 @@
import type { GiteaComment, GiteaIssue } from './api'
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()
const postIssueComment = vi.fn()
const getIssue = vi.fn()
const listIssueComments = vi.fn()
vi.mock('./api', () => ({
PAGE_SIZE: 50,
getCurrentUser,
listIssuesByFilter,
listIssueCommentsPage,
listAllRepoComments,
getDependencies,
getPullRequest,
postIssueComment,
getIssue,
listIssueComments,
}))
function issue(n: number, extra: Partial<GiteaIssue> = {}): GiteaIssue {
return {
id: n,
number: n,
title: `Issue ${n}`,
state: 'open',
comments: 0,
created_at: '2020-01-01T00:00:00Z',
updated_at: '2020-01-01T00:00:00Z',
body: '',
html_url: `https://gitea.example/o/r/issues/${n}`,
user: { login: 'me' },
assignees: [{ login: 'me' }],
labels: [],
...extra,
}
}
function comment(body: string, index: number, id: number): GiteaComment {
return {
id,
body,
issue_url: `https://gitea.example/api/v1/repos/o/r/issues/${index}`,
created_at: `2020-01-01T00:00:${String(id).padStart(2, '0')}Z`,
updated_at: `2020-01-01T00:00:${String(id).padStart(2, '0')}Z`,
}
}
describe('listCommentsUntilColumn', () => {
beforeEach(() => {
listIssueCommentsPage.mockReset()
})
it('commentCount 为 0 时不发请求', async () => {
const { listCommentsUntilColumn } = await import('./issueLoader.js')
const out = await listCommentsUntilColumn({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
index: 1,
commentCount: 0,
})
expect(out).toEqual([])
expect(listIssueCommentsPage).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 []
})
const out = await listCommentsUntilColumn({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
index: 1,
commentCount: 51,
})
expect(listIssueCommentsPage.mock.calls.map(c => (c[0] as { page: number }).page)).toEqual([2, 1])
expect(out.at(-1)?.body).toContain('later discussion')
expect(out[0]?.body).toContain('in-progress')
})
})
describe('loadIssues', () => {
beforeEach(() => {
getCurrentUser.mockReset()
listIssuesByFilter.mockReset()
listIssueCommentsPage.mockReset()
listAllRepoComments.mockReset()
getDependencies.mockReset()
getPullRequest.mockReset()
postIssueComment.mockReset()
getCurrentUser.mockResolvedValue({ login: 'me', id: 1 })
listIssuesByFilter.mockResolvedValue([])
listIssueCommentsPage.mockResolvedValue([])
getDependencies.mockResolvedValue([])
postIssueComment.mockResolvedValue({})
})
it('不走整仓评论消防水管,只按工单拉末页', async () => {
const { loadIssues } = await import('./issueLoader.js')
listIssuesByFilter.mockResolvedValue([issue(7, { comments: 3 })])
listIssueCommentsPage.mockResolvedValue([
comment(JSON.stringify({ column: 'todo' }), 7, 1),
])
const { issues, me } = await loadIssues({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
scope: 'all',
})
expect(me).toBe('me')
expect(listAllRepoComments).not.toHaveBeenCalled()
expect(listIssueCommentsPage).toHaveBeenCalledWith(expect.objectContaining({ index: 7, page: 1 }))
expect(issues).toHaveLength(1)
expect(issues[0].column).toBe('todo')
})
it('缺 state 评论时 POST 不挡住返回', async () => {
const { loadIssues } = await import('./issueLoader.js')
listIssuesByFilter.mockResolvedValue([issue(3, { comments: 0 })])
postIssueComment.mockReturnValue(new Promise(() => {}))
const started = Date.now()
const { issues } = await loadIssues({
host: 'gitea.example',
token: 't',
owner: 'o',
repo: 'r',
scope: 'all',
})
expect(Date.now() - started).toBeLessThan(500)
expect(issues[0].column).toBe('todo')
expect(postIssueComment).toHaveBeenCalled()
})
})
+80 -67
View File
@@ -4,17 +4,10 @@
* state-comment convention.
*
* Flow:
* 1. Kick off `/user` and the repo-wide comments firehose concurrently.
* Once `/user` resolves we know `login`, so fire both issue-filter
* queries (`assigned_by`, `created_by`) in parallel with the still-
* pending comments fetch.
* 2. Merge the two issue lists by `number`.
* 3. Group comments by issue number (parsed from each comment's `issue_url`).
* 4. For each merged issue, inspect the literal last comment for
* `{ "column": "<id>" }`. If it parses cleanly, use that column.
* Otherwise compute a default from `issue.state` and persist it by
* posting a JSON comment. POST failures are logged and ignored — we
* still display the issue in the computed column.
* 1. 拉工单列表(mine:先 /user 再 assigned+created;all:工单与 /user 并行)。
* 2. 每张工单只从评论末页往前翻,直到碰到带 column 的 state JSON。
* 不拉整仓库评论。缺 state 时内存用默认列,POST 不挡返回。
* 3. 非 done 工单并行补依赖和 PR 状态。
*/
import type { GiteaComment, GiteaIssue } from './api'
@@ -27,9 +20,10 @@ import {
getDependencies,
getIssue,
getPullRequest,
listAllRepoComments,
listIssueComments,
listIssueCommentsPage,
listIssuesByFilter,
PAGE_SIZE,
postIssueComment,
} from './api'
@@ -219,16 +213,6 @@ function parseColumnFromComments(comments: GiteaComment[]): {
return { column: null }
}
/** Extracts the issue index from a comment's `issue_url`, e.g. `.../issues/42`. */
function indexFromIssueUrl(issueUrl: string): number | undefined {
if (!issueUrl)
return undefined
const segments = issueUrl.split('/')
const last = segments[segments.length - 1]
const n = Number(last)
return Number.isFinite(n) ? n : undefined
}
/** Merges issue lists by `number`, preferring the first occurrence. */
function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] {
const map = new Map<number, GiteaIssue>()
@@ -241,26 +225,49 @@ function mergeIssues(...lists: GiteaIssue[][]): GiteaIssue[] {
return [...map.values()]
}
const COMMENT_FETCH_CONCURRENCY = 8
async function mapPool<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
const out: R[] = Array.from({ length: items.length })
let next = 0
async function worker(): Promise<void> {
while (true) {
const i = next++
if (i >= items.length)
return
out[i] = await fn(items[i])
}
}
const n = Math.min(Math.max(limit, 1), items.length)
if (n === 0)
return out
await Promise.all(Array.from({ length: n }, () => worker()))
return out
}
/**
* Groups repo-wide comments by issue number, ordered ascending by creation time.
* Comments whose `issue_url` cannot be parsed are dropped.
* 从末页往前拼评论,直到 parse 出 column 或翻到第 1 页。
* commentCount=0 不发请求。
*/
function groupCommentsByIssue(comments: GiteaComment[]): Map<number, GiteaComment[]> {
const buckets = new Map<number, GiteaComment[]>()
for (const c of comments) {
const idx = indexFromIssueUrl(c.issue_url)
if (idx === undefined)
continue
const bucket = buckets.get(idx)
if (bucket)
bucket.push(c)
else
buckets.set(idx, [c])
export async function listCommentsUntilColumn(opts: {
host: string
token: string
owner: string
repo: string
index: number
commentCount: number
}): 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
}
for (const bucket of buckets.values()) {
bucket.sort((a, b) => a.created_at.localeCompare(b.created_at))
}
return buckets
return comments
}
/**
@@ -352,21 +359,17 @@ async function buildIssue(opts: {
}
else {
column = defaultColumnForState(issue.state)
// Persist the default so future pulls are cheap; failures are
// non-fatal — we still display the issue in the computed column.
try {
await postIssueComment({
host,
token,
owner,
repo,
index: issue.number,
body: JSON.stringify({ column }),
})
}
catch (postErr) {
// 缺省列先用于本屏;POST 不挡返回,失败只记 warn。
void postIssueComment({
host,
token,
owner,
repo,
index: issue.number,
body: JSON.stringify({ column }),
}).catch((postErr) => {
console.warn(`[superpowers] failed to seed state comment on ${id}:`, postErr)
}
})
}
// Synchronously stat the worktree path so the UI can render it as a
@@ -423,34 +426,44 @@ export async function loadIssues(opts: {
workspaceRoot?: string
/** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */
scope?: 'mine' | 'all'
}): Promise<Issue[]> {
}): Promise<{ issues: Issue[], me: string }> {
const { host, token, owner, repo, workspaceRoot } = opts
// The repo-wide comments firehose doesn't depend on scope; kick it off first.
const commentsPromise = listAllRepoComments({ host, token, owner, repo })
// ① 工单列表 + 当前用户
let merged: Awaited<ReturnType<typeof listIssuesByFilter>>
let allComments: Awaited<typeof commentsPromise>
let me: string
if (opts.scope === 'all') {
;[merged, allComments] = await Promise.all([
const [all, user] = await Promise.all([
listIssuesByFilter({ host, token, owner, repo }),
commentsPromise,
getCurrentUser({ host, token }),
])
merged = all
me = user.login
}
else {
// `/user` validates the token and gives us the login. The two
// issue-filter calls need `user.login` and run together after.
const user = await getCurrentUser({ host, token })
const [assigned, created, comments] = await Promise.all([
me = user.login
const [assigned, created] = await Promise.all([
listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }),
listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }),
commentsPromise,
])
merged = mergeIssues(assigned, created)
allComments = comments
}
merged = merged.filter(issue => !isMailboxIssue(issue))
const buckets = groupCommentsByIssue(allComments)
// ② 每张工单从评论末页往前翻,直到有 column
const commentLists = await mapPool(merged, COMMENT_FETCH_CONCURRENCY, issue =>
listCommentsUntilColumn({
host,
token,
owner,
repo,
index: issue.number,
commentCount: issue.comments,
}))
const buckets = new Map<number, GiteaComment[]>()
for (let i = 0; i < merged.length; i++)
buckets.set(merged[i].number, commentLists[i])
// 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。
// 完成(done)工单的依赖(前置阻塞)无意义、PR 状态永久不变(已合并),
@@ -509,7 +522,7 @@ export async function loadIssues(opts: {
}))
}
return resolved
return { issues: resolved, me }
}
/**