新增 resolveWorktreePath(stored, workspaceRoot) 三态合一解析:开头 ~ 展开为 home、 已是绝对原样、相对拼到 workspaceRoot。替换全部消费点,原先「判 isAbsolute 再 join」 不认 ~、issueLoader 算 worktreeExists 更是裸 join 连绝对路径都拼坏。 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
/**
|
||
* 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 { resolveWorktreePath } from '../git/worktree'
|
||
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(resolveWorktreePath(worktreePath, workspaceRoot))
|
||
}
|
||
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 直接 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<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,
|
||
})
|
||
}
|