/** * 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": "" }`. 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 type { GiteaComment, GiteaIssue } from './api' import type { Issue, IssueColumn } from './types' import * as fs from 'node:fs' import { resolveWorktreePath } from '../git/worktree' 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 brainstormProfilePath?: 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 brainstormProfilePath?: 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 brainstormProfilePath = typeof obj.brainstormProfilePath === 'string' && obj.brainstormProfilePath.length > 0 ? obj.brainstormProfilePath : 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, brainstormProfilePath, 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() 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 { const buckets = new Map() 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 { const { host, token, owner, repo, workspaceRoot, issue, comments, prerequisite, liveMerged, liveMergedAt } = opts const id = `${owner}/${repo}#${issue.number}` const { column: fromComment, sessionId, profilePath, brainstormProfilePath, 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) { 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(resolveWorktreePath(worktreePath, workspaceRoot)) } catch { worktreeExists = false } } return { id, number: issue.number, title: issue.title, column, ...(sessionId ? { sessionId } : {}), ...(profilePath ? { profilePath } : {}), ...(brainstormProfilePath ? { brainstormProfilePath } : {}), ...(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 /** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */ scope?: 'mine' | 'all' }): Promise { 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> let allComments: Awaited if (opts.scope === 'all') { ;[merged, allComments] = await Promise.all([ listIssuesByFilter({ host, token, owner, repo }), commentsPromise, ]) } 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([ 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 } 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 直接 undefined,PR 状态由 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 { 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, }) }