This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
+528
View File
@@ -0,0 +1,528 @@
/**
* Loads issues from the workspace's Gitea repository using the fetch-based
* `api.ts` client and resolves each issue's Kanban column from a JSON
* 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.
*/
import * as fs from 'node:fs'
import * as path from 'node:path'
import type { GiteaComment, GiteaIssue } from './api'
import type { Issue, IssueColumn } from './types'
import {
getCurrentUser,
getDependencies,
getIssue,
getPullRequest,
listAllRepoComments,
listIssueComments,
listIssuesByFilter,
postIssueComment,
} from './api'
const COLUMN_IDS: readonly IssueColumn[] = ['todo', 'in-progress', 'review', 'done']
/**
* 过滤 specFile / planFile 字段里的垃圾值:
* 真实路径必须包含 `/` 且以 `.md` 结尾。`...`、`…` 这类是 cc
* 在 brainstorm 阶段写 issue body 时塞进去的占位说明文,webhook
* 历史版本正则太宽松会把它们写进 state JSON,这里读出时兜底清掉。
*/
export function isValidSpxFilePath(v: unknown): v is string {
return typeof v === 'string'
&& v.length > 0
&& v.includes('/')
&& v.endsWith('.md')
&& v !== '...'
&& v !== '…'
}
function isValidPrDiffFilePath(v: unknown): v is string {
return typeof v === 'string'
&& /^docs\/pr-diff\/[^\s]+\.md$/.test(v)
}
function isIssueColumn(value: unknown): value is IssueColumn {
return typeof value === 'string' && (COLUMN_IDS as readonly string[]).includes(value)
}
function defaultColumnForState(state: string): IssueColumn {
return state === 'open' ? 'todo' : 'done'
}
/**
* Scans comments from the tail for the most recent JSON state payload like
* `{ "column": "todo", ... }`, skipping ordinary text/JSON comments that lack
* a `column` field so they can't shadow the real state. Returns
* `{ column: null }` if no valid payload is present.
*/
function parseColumnFromComments(comments: GiteaComment[]): {
column: IssueColumn | null
sessionId?: string
profilePath?: string
testProfilePath?: string
specFile?: string
planFile?: string
prDiffFile?: string
pr?: string
prMerged?: boolean
prMergedAt?: string
branch?: string
worktreePath?: string
implementStatus?: 'running' | 'done' | 'failed'
implementSessionId?: string
reviewSessionId?: string
testSessionId?: string
color?: string
autoReview?: boolean
} {
if (comments.length === 0)
return { column: null }
// 从尾往前找第一条「parse 成功 && 是 object && 含 column」的 comment。
// 中途插入的普通文本/JSON 内容评论不带 column,被跳过——否则它们会
// 顶替真正的 state comment,导致整张卡片的列与元数据全部丢失。
let parsed: unknown
for (let i = comments.length - 1; i >= 0; i--) {
const body = (comments[i].body ?? '').trim()
if (!body)
continue
let candidate: unknown
try {
candidate = JSON.parse(body)
}
catch {
continue
}
if (candidate && typeof candidate === 'object' && 'column' in candidate) {
parsed = candidate
break
}
}
if (parsed && typeof parsed === 'object' && 'column' in parsed) {
const obj = parsed as {
column: unknown
sessionId?: unknown
profilePath?: unknown
testProfilePath?: unknown
specFile?: unknown
planFile?: unknown
prDiffFile?: unknown
pr?: unknown
prMerged?: unknown
prMergedAt?: unknown
branch?: unknown
worktreePath?: unknown
implementStatus?: unknown
implementSessionId?: unknown
reviewSessionId?: unknown
testSessionId?: unknown
color?: unknown
autoReview?: unknown
}
if (isIssueColumn(obj.column)) {
const sessionId = typeof obj.sessionId === 'string' && obj.sessionId.length > 0
? obj.sessionId
: undefined
const profilePath = typeof obj.profilePath === 'string' && obj.profilePath.length > 0
? obj.profilePath
: undefined
const testProfilePath = typeof obj.testProfilePath === 'string' && obj.testProfilePath.length > 0
? obj.testProfilePath
: undefined
const specFile = isValidSpxFilePath(obj.specFile) ? obj.specFile : undefined
const planFile = isValidSpxFilePath(obj.planFile) ? obj.planFile : undefined
const prDiffFile = isValidPrDiffFilePath(obj.prDiffFile) ? obj.prDiffFile : undefined
const pr = typeof obj.pr === 'string' && obj.pr.length > 0
? obj.pr
: undefined
const prMerged = typeof obj.prMerged === 'boolean' ? obj.prMerged : undefined
const prMergedAt = typeof obj.prMergedAt === 'string' && obj.prMergedAt.length > 0
? obj.prMergedAt
: undefined
const branch = typeof obj.branch === 'string' && obj.branch.length > 0
? obj.branch
: undefined
const worktreePath = typeof obj.worktreePath === 'string' && obj.worktreePath.length > 0
? obj.worktreePath
: undefined
const implementStatus = obj.implementStatus === 'running'
|| obj.implementStatus === 'done'
|| obj.implementStatus === 'failed'
? obj.implementStatus
: undefined
const implementSessionId = typeof obj.implementSessionId === 'string' && obj.implementSessionId.length > 0
? obj.implementSessionId
: undefined
const reviewSessionId = typeof obj.reviewSessionId === 'string' && obj.reviewSessionId.length > 0
? obj.reviewSessionId
: undefined
const testSessionId = typeof obj.testSessionId === 'string' && obj.testSessionId.length > 0
? obj.testSessionId
: undefined
const color = typeof obj.color === 'string' && obj.color.length > 0
? obj.color
: undefined
const autoReview = typeof obj.autoReview === 'boolean' ? obj.autoReview : undefined
return {
column: obj.column,
sessionId,
profilePath,
testProfilePath,
specFile,
planFile,
prDiffFile,
pr,
prMerged,
prMergedAt,
branch,
worktreePath,
implementStatus,
implementSessionId,
reviewSessionId,
testSessionId,
color,
autoReview,
}
}
}
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>()
for (const list of lists) {
for (const issue of list) {
if (!map.has(issue.number))
map.set(issue.number, issue)
}
}
return [...map.values()]
}
/**
* Groups repo-wide comments by issue number, ordered ascending by creation time.
* Comments whose `issue_url` cannot be parsed are dropped.
*/
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])
}
for (const bucket of buckets.values()) {
bucket.sort((a, b) => a.created_at.localeCompare(b.created_at))
}
return buckets
}
/**
* Builds an `Issue` from a Gitea issue plus its comment bucket.
*
* Encapsulates the per-issue parsing and side-effects (seeding a default
* state comment when none exists, statting the worktree path) so it can be
* reused by both the full loader and `loadSingleIssue`.
*/
/**
* Resolves the live merged state for a PR by querying gitea. Returns the
* boolean on success and `undefined` on any failure (network, 404, non-numeric
* index, etc.) so callers can fall back to whatever they had cached.
*/
/**
* Resolves the live PR status by querying gitea. Returns `{ merged, mergedAt }`
* on success and `{}` on any failure (no pr, non-numeric index, network, 404,
* parse error) so callers can fall back to whatever they had cached. `mergedAt`
* is the PR's `merged_at` (ISO 8601) and powers the 完成 列's merge-time sort.
*/
async function fetchPrStatus(opts: {
host: string
token: string
owner: string
repo: string
pr: string | undefined
}): Promise<{ merged?: boolean, mergedAt?: string }> {
const { host, token, owner, repo, pr } = opts
if (!pr)
return {}
const index = Number.parseInt(pr, 10)
if (!Number.isFinite(index) || index <= 0)
return {}
try {
const data = await getPullRequest({ host, token, owner, repo, index })
return { merged: data.merged === true, mergedAt: data.merged_at ?? undefined }
}
catch {
return {}
}
}
async function buildIssue(opts: {
host: string
token: string
owner: string
repo: string
workspaceRoot?: string
issue: GiteaIssue
comments: GiteaComment[]
prerequisite: number | undefined
/** 实时 PR merged 状态(由 loader 并发查询后传入);undefined 表示查询失败或无 PR,回退 state JSON。 */
liveMerged?: boolean
/** PR 的 merged_at(来自 PR API),仅用于完成列排序;查询失败/无 PR 为 undefined。 */
liveMergedAt?: string
}): Promise<Issue> {
const { host, token, owner, repo, workspaceRoot, issue, comments, prerequisite, liveMerged, liveMergedAt } = opts
const id = `${owner}/${repo}#${issue.number}`
const {
column: fromComment,
sessionId,
profilePath,
testProfilePath,
specFile,
planFile,
prDiffFile,
pr,
prMerged: persistedMerged,
prMergedAt: persistedMergedAt,
branch,
worktreePath,
implementStatus,
implementSessionId,
reviewSessionId,
testSessionId,
color,
autoReview,
} = parseColumnFromComments(comments)
// 优先用 PR API 实时结果;失败时退回 state JSON 持久化值。
const prMerged = liveMerged !== undefined ? liveMerged : persistedMerged
// 完成列排序用的 merged_at:实时查询优先,否则用持久化值(完成工单不再实时拉 PR)。
const mergedAt = liveMergedAt ?? persistedMergedAt
let column: IssueColumn
if (fromComment) {
column = fromComment
}
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) {
// eslint-disable-next-line no-console
console.warn(`[superpowers] failed to seed state comment on ${id}:`, postErr)
}
}
// Synchronously stat the worktree path so the UI can render it as a
// live link vs. plain text. A single existsSync per issue is cheap and
// avoids racing with the rest of the async pipeline.
let worktreeExists: boolean | undefined
if (worktreePath && workspaceRoot) {
try {
worktreeExists = fs.existsSync(path.join(workspaceRoot, worktreePath))
}
catch {
worktreeExists = false
}
}
return {
id,
number: issue.number,
title: issue.title,
column,
...(sessionId ? { sessionId } : {}),
...(profilePath ? { profilePath } : {}),
...(testProfilePath ? { testProfilePath } : {}),
...(specFile ? { specFile } : {}),
...(planFile ? { planFile } : {}),
...(prDiffFile ? { prDiffFile } : {}),
...(pr ? { pr } : {}),
...(prMerged !== undefined ? { prMerged } : {}),
...(mergedAt ? { prMergedAt: mergedAt } : {}),
...(branch ? { branch } : {}),
...(worktreePath ? { worktreePath } : {}),
...(worktreeExists !== undefined ? { worktreeExists } : {}),
...(implementStatus ? { implementStatus } : {}),
...(implementSessionId ? { implementSessionId } : {}),
...(reviewSessionId ? { reviewSessionId } : {}),
...(testSessionId ? { testSessionId } : {}),
...(prerequisite !== undefined ? { prerequisite } : {}),
...(color ? { color } : {}),
...(autoReview !== undefined ? { autoReview } : {}),
htmlUrl: issue.html_url,
}
}
export async function loadIssues(opts: {
host: string
token: string
owner: string
repo: string
/** Absolute workspace root used to resolve `worktreeExists` against disk. */
workspaceRoot?: string
}): Promise<Issue[]> {
const { host, token, owner, repo, workspaceRoot } = opts
// `/user` validates the token and gives us the login. The repo-wide
// comments firehose doesn't need the login, so we kick it off in parallel.
// The two issue-filter calls need `user.login` and run together after.
const userPromise = getCurrentUser({ host, token })
const commentsPromise = listAllRepoComments({ host, token, owner, repo })
const user = await userPromise
const [assigned, created, allComments] = 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,
])
const merged = mergeIssues(assigned, created)
const buckets = groupCommentsByIssue(allComments)
// 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。
// 完成(done)工单的依赖(前置阻塞)无意义、PR 状态永久不变(已合并),
// 跳过它们的 getDependencies + getPullRequest 往返是面板加载提速的核心:
// 完成列动辄上百工单,原先每个都发 2 次远程请求。
// column 计算与 buildIssue 的判断保持一致:state comment 优先,缺失时按 issue.state 默认。
const columns: IssueColumn[] = merged.map((issue) => {
const bucket = buckets.get(issue.number) ?? []
return parseColumnFromComments(bucket).column ?? defaultColumnForState(issue.state)
})
// Concurrently fetch each issue's dependencies + live PR merged state.
// Per product rule we keep at most one prerequisite — the first entry in
// the array (Gitea orders by creation time). Failures (412 dependencies
// disabled, network, etc.) are silently swallowed so one bad issue can't
// take down the whole loader. PR merged 查询同样以失败兜底 undefined。
// done 工单两者都跳过:prerequisite 直接 undefinedPR 状态由 state JSON 兜底。
const [prerequisites, liveMergedList] = await Promise.all([
Promise.all(merged.map(async (issue, i) => {
if (columns[i] === 'done')
return undefined
try {
const deps = await getDependencies({ host, token, owner, repo, index: issue.number })
return deps.length > 0 ? deps[0].number : undefined
}
catch {
return undefined
}
})),
Promise.all(merged.map(async (issue, i) => {
if (columns[i] === 'done')
return {}
const bucket = buckets.get(issue.number) ?? []
const { pr } = parseColumnFromComments(bucket)
return fetchPrStatus({ host, token, owner, repo, pr })
})),
])
const resolved: Issue[] = []
for (let i = 0; i < merged.length; i++) {
const issue = merged[i]
const prerequisite = prerequisites[i]
const { merged: liveMerged, mergedAt: liveMergedAt } = liveMergedList[i] ?? {}
const bucket = buckets.get(issue.number) ?? []
resolved.push(await buildIssue({
host,
token,
owner,
repo,
workspaceRoot,
issue,
comments: bucket,
prerequisite,
liveMerged,
liveMergedAt,
}))
}
return resolved
}
/**
* Loads a single issue by number, resolving its column / state metadata the
* same way `loadIssues` does. Returns `null` if Gitea reports the issue
* does not exist (404). Used by the incremental "issue created" path so the
* webview can append one card without rerendering the whole board.
*/
export async function loadSingleIssue(opts: {
host: string
token: string
owner: string
repo: string
workspaceRoot?: string
issueNumber: number
}): Promise<Issue | null> {
const { host, token, owner, repo, workspaceRoot, issueNumber } = opts
const issue = await getIssue({ host, token, owner, repo, index: issueNumber })
if (!issue)
return null
const [comments, prerequisite] = await Promise.all([
listIssueComments({ host, token, owner, repo, index: issueNumber }),
(async () => {
try {
const deps = await getDependencies({ host, token, owner, repo, index: issueNumber })
return deps.length > 0 ? deps[0].number : undefined
}
catch {
return undefined
}
})(),
])
// 解析 state JSON 拿到 pr 字段后实时查 merged 状态;失败 fallback undefined。
const { pr } = parseColumnFromComments(comments)
const { merged: liveMerged, mergedAt: liveMergedAt } = await fetchPrStatus({ host, token, owner, repo, pr })
return buildIssue({
host,
token,
owner,
repo,
workspaceRoot,
issue,
comments,
prerequisite,
liveMerged,
liveMergedAt,
})
}