This commit is contained in:
cruldra committed 2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 files changed
+54215

No files matched your search

+650
View File
@@ -0,0 +1,650 @@
/**
* Fetch-based Gitea API client.
*
* Replaces the previous `tea` CLI shell-outs with direct REST calls over
* Node 20+'s global `fetch`. All requests are authenticated with a personal
* access token via the `Authorization: token <PAT>` header.
*
* Errors from non-2xx responses surface as `GiteaApiError` so callers can
* distinguish 401 (token invalid) from other failure modes.
*/
import { logger } from '../logging/logger'
const PAGE_SIZE = 50
export interface GiteaUser {
login: string
id: number
}
export interface GiteaIssue {
id: number
number: number
title: string
state: 'open' | 'closed'
comments: number
created_at: string
updated_at: string
body: string
html_url: string
user: { login: string } | null
assignees: Array<{ login: string }> | null
labels: Array<{ name: string, color: string }> | null
}
export interface GiteaComment {
id: number
body: string
issue_url: string
created_at: string
updated_at: string
}
export class GiteaApiError extends Error {
readonly status: number
constructor(status: number, message: string) {
super(message)
this.name = 'GiteaApiError'
this.status = status
}
}
function baseUrl(host: string): string {
return `https://${host}/api/v1`
}
function authHeaders(token: string): Record<string, string> {
return {
'Authorization': `token ${token}`,
'Accept': 'application/json',
}
}
async function ensureOk(res: Response): Promise<void> {
if (res.ok)
return
const body = await res.text().catch(() => '')
throw new GiteaApiError(res.status, body || res.statusText)
}
export async function getCurrentUser(opts: { host: string, token: string }): Promise<GiteaUser> {
const res = await fetch(`${baseUrl(opts.host)}/user`, {
headers: authHeaders(opts.token),
})
await ensureOk(res)
return res.json() as Promise<GiteaUser>
}
/**
* Fetches `/repos/{owner}/{repo}/issues` filtered by either `assigned_by` or
* `created_by`, paginating until a short page is returned.
*/
export async function listIssuesByFilter(opts: {
host: string
token: string
owner: string
repo: string
filter: 'assigned_by' | 'created_by'
user: string
}): Promise<GiteaIssue[]> {
const out: GiteaIssue[] = []
let page = 1
// Gitea returns a JSON array of issues; we accumulate until we see a short page.
// eslint-disable-next-line no-constant-condition
while (true) {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`)
url.searchParams.set('type', 'issues')
url.searchParams.set('state', 'all')
url.searchParams.set(opts.filter, opts.user)
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 GiteaIssue[]
if (!Array.isArray(batch) || batch.length === 0)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
/**
* Fetches the firehose `/repos/{owner}/{repo}/issues/comments` endpoint, which
* returns every comment in the repo across all issues. Paginated until a short
* page is returned.
*/
export async function listAllRepoComments(opts: {
host: string
token: string
owner: string
repo: string
}): Promise<GiteaComment[]> {
const out: GiteaComment[] = []
let page = 1
// eslint-disable-next-line no-constant-condition
while (true) {
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/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)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
/**
* 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).
*/
export async function listIssueComments(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaComment[]> {
const out: GiteaComment[] = []
let page = 1
// eslint-disable-next-line no-constant-condition
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)
break
out.push(...batch)
if (batch.length < PAGE_SIZE)
break
page += 1
}
return out
}
export async function postIssueComment(opts: {
host: string
token: string
owner: string
repo: string
index: number
body: string
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/comments`,
{
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ body: opts.body }),
},
)
await ensureOk(res)
}
export interface GiteaPullRequest {
number: number
merged: boolean
state: string
merged_at?: string | null
html_url: string
/**
* PR description / body. Used by the webhook coordinator to reverse-lookup
* the underlying issue number (`Closes #N`) when a comment fires on a PR.
*/
body: string
}
/**
* Fetches a single pull request by index via
* `/repos/{owner}/{repo}/pulls/{index}`. Used by the kanban "done" drop
* handler to verify a PR is merged before persisting `column='done'`.
*/
export async function getPullRequest(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaPullRequest> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
const data = await res.json() as {
number?: unknown
merged?: unknown
state?: unknown
merged_at?: unknown
html_url?: unknown
body?: unknown
}
return {
number: typeof data.number === 'number' ? data.number : Number(data.number),
merged: data.merged === true,
state: typeof data.state === 'string' ? data.state : '',
merged_at: typeof data.merged_at === 'string' ? data.merged_at : null,
html_url: typeof data.html_url === 'string' ? data.html_url : '',
body: typeof data.body === 'string' ? data.body : '',
}
}
export async function getIssue(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<GiteaIssue | null> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}`,
{ headers: authHeaders(opts.token) },
)
if (res.status === 404)
return null
await ensureOk(res)
return await res.json() as GiteaIssue
}
export async function getDependencies(opts: {
host: string
token: string
owner: string
repo: string
index: number
}): Promise<Array<{ number: number }>> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`,
{ headers: authHeaders(opts.token) },
)
await ensureOk(res)
const data = await res.json() as Array<{ number?: unknown }>
if (!Array.isArray(data))
return []
return data
.map((it) => {
const n = typeof it?.number === 'number' ? it.number : Number(it?.number)
return Number.isFinite(n) ? { number: n } : null
})
.filter((it): it is { number: number } => it !== null)
}
export async function addDependency(opts: {
host: string
token: string
owner: string
repo: string
index: number
dependencyIndex: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`,
{
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ index: opts.dependencyIndex }),
},
)
await ensureOk(res)
}
/**
* Merge a pull request via gitea's `POST /repos/{owner}/{repo}/pulls/{index}/merge`.
* Defaults to the plain "merge" strategy. Throws GiteaApiError on non-2xx.
*/
export async function mergePullRequest(opts: {
host: string
token: string
owner: string
repo: string
index: number
strategy?: 'merge' | 'rebase' | 'rebase-merge' | 'squash'
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/merge`
const body = {
Do: opts.strategy ?? 'merge',
delete_branch_after_merge: false,
force_merge: false,
}
const res = await fetch(url, {
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
await ensureOk(res)
}
export async function closeIssue(opts: {
host: string
token: string
owner: string
repo: string
issueNumber: number
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.issueNumber}`
const res = await fetch(url, {
method: 'PATCH',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ state: 'closed' }),
})
await ensureOk(res)
}
export async function deleteIssue(opts: {
host: string
token: string
owner: string
repo: string
issueNumber: number
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.issueNumber}`
const res = await fetch(url, {
method: 'DELETE',
headers: authHeaders(opts.token),
})
await ensureOk(res)
}
export async function deleteBranch(opts: {
host: string
token: string
owner: string
repo: string
branch: string
}): Promise<void> {
const url = `${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/branches/${encodeURIComponent(opts.branch)}`
const res = await fetch(url, {
method: 'DELETE',
headers: authHeaders(opts.token),
})
await ensureOk(res)
}
export async function removeDependency(opts: {
host: string
token: string
owner: string
repo: string
index: number
dependencyIndex: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues/${opts.index}/dependencies`,
{
method: 'DELETE',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({ index: opts.dependencyIndex }),
},
)
await ensureOk(res)
}
// no longer auto-invoked by the implement flow; kept for future manual ops
export async function createWebhook(opts: {
host: string
token: string
owner: string
repo: string
url: string
branchFilter: string
}): Promise<{ id: number }> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/hooks`,
{
method: 'POST',
headers: {
...authHeaders(opts.token),
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'gitea',
active: true,
events: ['pull_request'],
branch_filter: opts.branchFilter,
config: {
url: opts.url,
content_type: 'json',
},
}),
},
)
if (!res.ok) {
const body = await res.text().catch(() => '')
logger.add({
level: 'error',
source: 'gitea',
message: `createWebhook 失败 status=${res.status}`,
details: (body || res.statusText).slice(0, 500),
})
throw new GiteaApiError(res.status, body || res.statusText)
}
const data = (await res.json()) as { id?: unknown }
const id = typeof data.id === 'number' ? data.id : Number(data.id)
if (!Number.isFinite(id)) {
logger.add({
level: 'error',
source: 'gitea',
message: 'createWebhook: 响应缺少 id',
})
throw new GiteaApiError(500, 'createWebhook: response missing id')
}
logger.add({
level: 'info',
source: 'gitea',
message: `已创建 hookId=${id} url=${opts.url} branch_filter=${opts.branchFilter}`,
})
return { id }
}
// no longer auto-invoked by the implement flow; kept for future manual ops
export async function deleteWebhook(opts: {
host: string
token: string
owner: string
repo: string
hookId: number
}): Promise<void> {
const res = await fetch(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/hooks/${opts.hookId}`,
{
method: 'DELETE',
headers: authHeaders(opts.token),
},
)
if (res.status === 404) {
logger.add({
level: 'warn',
source: 'gitea',
message: `deleteWebhook hookId=${opts.hookId} 已不存在 (404)`,
})
return
}
if (!res.ok) {
const body = await res.text().catch(() => '')
logger.add({
level: 'error',
source: 'gitea',
message: `deleteWebhook hookId=${opts.hookId} 失败 status=${res.status}`,
details: (body || res.statusText).slice(0, 500),
})
throw new GiteaApiError(res.status, body || res.statusText)
}
logger.add({
level: 'info',
source: 'gitea',
message: `已删除 hookId=${opts.hookId}`,
})
}
/** PR 提交的精简视图:列表渲染只需 sha / 首行消息 / 作者 / 时间。 */
export interface PrCommitDto {
sha: string
message: string
authorName: string
date: string
}
/** 单个提交内的一个文件改动:path 给 diff 用,status 决定徽标颜色。 */
export interface GitCommitFileDto {
path: string
status: string
}
/** 一个提交的详情:parentSha 用作 diff 左侧 ref,files 是改动清单。 */
export interface GitCommitDetailDto {
sha: string
parentSha: string | undefined
files: GitCommitFileDto[]
}
/**
* 列出某 PR 的提交。关掉 files/stat/verification 让响应尽量小,列表只用到
* 顶层字段。message 取首行(完整消息留给 commit 详情页,这里只做概览)。
*/
export async function listPullRequestCommits(opts: {
host: string
token: string
owner: string
repo: string
index: number
limit?: number
}): Promise<PrCommitDto[]> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/pulls/${opts.index}/commits`,
)
url.searchParams.set('page', '1')
url.searchParams.set('limit', String(opts.limit ?? 50))
url.searchParams.set('files', 'false')
url.searchParams.set('stat', 'false')
url.searchParams.set('verification', 'false')
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const data = await res.json() as Array<{
sha?: unknown
created?: unknown
commit?: { message?: unknown, author?: { name?: unknown, date?: unknown } }
author?: { login?: unknown } | null
}>
if (!Array.isArray(data))
return []
const result = data.map((c) => {
const fullMessage = typeof c.commit?.message === 'string' ? c.commit.message : ''
const message = fullMessage.split('\n', 1)[0] ?? ''
const authorName = typeof c.commit?.author?.name === 'string' ? c.commit.author.name : ''
const commitDate = typeof c.commit?.author?.date === 'string' ? c.commit.author.date : ''
const created = typeof c.created === 'string' ? c.created : ''
return {
sha: typeof c.sha === 'string' ? c.sha : '',
message,
authorName,
date: commitDate || created,
}
})
// 按提交时间升序(最早在最前)。ISO 字符串字典序即时间序;date 为空者排前面。
result.sort((a, b) => a.date.localeCompare(b.date))
return result
}
/**
* 取单个提交详情,带文件清单。parentSha(首个父提交)作为 diff 左侧 ref;
* 根提交无 parent 时为 undefined,调用方据此把左侧当空内容处理(呈现全新增)。
*/
export async function getGitCommit(opts: {
host: string
token: string
owner: string
repo: string
sha: string
}): Promise<GitCommitDetailDto> {
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/git/commits/${opts.sha}`,
)
url.searchParams.set('files', 'true')
url.searchParams.set('stat', 'false')
url.searchParams.set('verification', 'false')
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
await ensureOk(res)
const data = await res.json() as {
sha?: unknown
parents?: Array<{ sha?: unknown }>
files?: Array<{ filename?: unknown, status?: unknown }>
}
const parentSha = typeof data.parents?.[0]?.sha === 'string' ? data.parents[0].sha : undefined
const files = Array.isArray(data.files)
? data.files.map(f => ({
path: typeof f.filename === 'string' ? f.filename : '',
status: typeof f.status === 'string' ? f.status : '',
}))
: []
return {
sha: typeof data.sha === 'string' ? data.sha : opts.sha,
parentSha,
files,
}
}
/**
* 取某 ref 下文件的原始内容,用作 diff 的一侧。
*
* 文件在该 ref 不存在(404)是预期情况:新增文件在父 ref 缺席、删除文件在子
* ref 缺席。这类情况返回空串,让 diff 自然呈现新增/删除,而非报错。其它非 2xx
* 同样兜底返回空串——diff 视图容不下错误对话框。
*/
export async function getRawFile(opts: {
host: string
token: string
owner: string
repo: string
filepath: string
ref: string
}): Promise<string> {
// encodeURI 保留路径分隔符 `/`,只转义路径段里的特殊字符。
const url = new URL(
`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/raw/${encodeURI(opts.filepath)}`,
)
url.searchParams.set('ref', opts.ref)
const res = await fetch(url.toString(), { headers: authHeaders(opts.token) })
if (!res.ok)
return ''
return res.text()
}
+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 直接 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,
})
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Shared helper for merging extra fields into an issue's "state JSON" comment.
*
* Convention: the most recent state-JSON comment on a gitea issue holds a
* JSON blob with orchestration state (column, branch, pr, implementStatus,
* etc.). We locate it by scanning comments from the tail and picking the first
* one that parses as an object carrying a known state field — so an ordinary
* text/JSON comment posted afterwards (cc chatter, review notes, Gitea's PR
* auto-link) can't shadow it. This helper reads that blob, merges `extra` on
* top, and posts a new comment with the resulting JSON body. With no prior
* state comment it starts fresh from `{}`.
*/
import type { GiteaComment } from './api'
import { listIssueComments, postIssueComment } from './api'
/**
* 已知 state 字段集合,用于把 state JSON comment 与普通 JSON 内容评论区分开。
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
*/
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'profilePath', 'testProfilePath', 'specFile', 'planFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview'] as const
/**
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
* 返回解析后的对象,否则返回 null。
*/
function parseStateComment(body: string | undefined | null): Record<string, unknown> | null {
const trimmed = (body ?? '').trim()
if (!trimmed)
return null
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
}
catch {
return null
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
return null
const obj = parsed as Record<string, unknown>
const hasKnownField = KNOWN_STATE_FIELDS.some(field => field in obj)
return hasKnownField ? obj : null
}
/**
* 从尾往前找最近一条 state JSON comment,返回解析结果;找不到返回 null。
* 跳过中途插入的普通文本/JSON 内容评论,从而不丢失历史状态。
*/
function findLatestState(comments: GiteaComment[]): Record<string, unknown> | null {
for (let i = comments.length - 1; i >= 0; i--) {
const parsed = parseStateComment(comments[i].body)
if (parsed)
return parsed
}
return null
}
export interface MergeStateJsonCommentOpts {
host: string
owner: string
repo: string
token: string
issueNumber: number
extra: Record<string, unknown>
}
/**
* Merge `extra` into the latest state-JSON comment of the issue and post a
* new comment with the merged JSON. Throws on network/API failure — callers
* are responsible for surfacing errors.
*/
export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise<void> {
const currentState = await readStateJsonComment({
host: opts.host,
owner: opts.owner,
repo: opts.repo,
token: opts.token,
issueNumber: opts.issueNumber,
})
await postMergedStateJsonComment(opts, currentState, opts.extra)
}
export interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts {
protectDoneColumn?: boolean
}
/**
* Merge `extra` while protecting terminal `done` cards from stale automatic
* column updates. When protection removes the only incoming field, no comment
* is posted.
*/
export async function mergeStateJsonCommentGuarded(
opts: MergeStateJsonCommentGuardedOpts,
): Promise<{ posted: boolean, protectedDoneColumn: boolean }> {
const currentState = await readStateJsonComment({
host: opts.host,
owner: opts.owner,
repo: opts.repo,
token: opts.token,
issueNumber: opts.issueNumber,
})
const extra = { ...opts.extra }
let protectedDoneColumn = false
if (
opts.protectDoneColumn
&& currentState.column === 'done'
&& typeof extra.column === 'string'
&& extra.column !== 'done'
) {
delete extra.column
protectedDoneColumn = true
}
if (Object.keys(extra).length === 0)
return { posted: false, protectedDoneColumn }
await postMergedStateJsonComment(opts, currentState, extra)
return { posted: true, protectedDoneColumn }
}
async function postMergedStateJsonComment(
opts: MergeStateJsonCommentOpts,
currentState: Record<string, unknown>,
extra: Record<string, unknown>,
): Promise<void> {
const merged: Record<string, unknown> = {
...currentState,
...extra,
}
await postIssueComment({
host: opts.host,
token: opts.token,
owner: opts.owner,
repo: opts.repo,
index: opts.issueNumber,
body: JSON.stringify(merged),
})
}
export interface ReadStateJsonCommentOpts {
host: string
owner: string
repo: string
token: string
issueNumber: number
}
/**
* Read the latest state-JSON comment of the issue and return it as a parsed
* object. Scans comments from the tail and returns the first one that parses
* as an object carrying a known state field, so ordinary comments inserted
* afterwards don't shadow the real state. Returns an empty object when no such
* comment exists. Throws on network/API failure.
*/
export async function readStateJsonComment(
opts: ReadStateJsonCommentOpts,
): Promise<Record<string, unknown>> {
const comments = await listIssueComments({
host: opts.host,
token: opts.token,
owner: opts.owner,
repo: opts.repo,
index: opts.issueNumber,
})
return findLatestState(comments) ?? {}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Gitea issue types used by the extension host.
*
* Mirrors the shape declared in `webview-ui/src/types.ts`. The two packages
* don't share types directly today; if these drift, that's a step-3 concern.
*/
export type IssueColumn = 'todo' | 'in-progress' | 'review' | 'done'
export interface Issue {
/** Stable identifier: `${owner}/${repo}#${index}` (gitea) or
* `youtrack:${idReadable}` (youtrack). Used to route mutations to the right
* source when both kinds of cards share the board. */
id: string
/** Which tracker this issue came from. Absent is treated as `'gitea'` for
* backward compatibility with state written before the second source. */
source?: 'gitea' | 'youtrack'
/** YouTrack readable id (`LXF-12`) — present only on youtrack issues; needed
* for REST calls that key by readable id. */
externalId?: string
/** Equals the Gitea issue number / tea's `index` field; for youtrack it's the
* numeric tail of `externalId` (collides across sources — never use alone as
* a key, pair with `source`). */
number: number
title: string
column: IssueColumn
/** YouTrack attachments (name + absolute URL). Links only; not downloaded. */
attachments?: Array<{ name: string, url: string }>
/** Optional Claude Code session id stored alongside the column marker in
* the issue's state-JSON comment. Used to resume the conversation. */
sessionId?: string
/** Absolute path to the Claude settings profile used at creation; passed
* as --settings on resume. */
profilePath?: string
/** 测试会话专用的 Claude 配置文件,独立于实施会话的 `profilePath`;
* 留空时测试会话回退到 `profilePath`,再回退默认。 */
testProfilePath?: string
/** Optional workspace-relative path to the spec file for this issue, as
* surfaced from the Claude session transcript. Lives under
* `docs/superpowers/specs/*.md`. */
specFile?: string
/** Optional workspace-relative path to the plan file for this issue, as
* surfaced from the Claude session transcript. Lives under
* `docs/superpowers/plans/*.md`. */
planFile?: string
/** Optional workspace-relative path to the generated PR diff summary. */
prDiffFile?: string
/** PR number associated with this issue (set after webhook fires). */
pr?: string
/** Whether the associated PR has been merged. Set when the user drags to
* the 完成 列 (扩展代为合并) or when a `pull_request.closed` webhook arrives
* with `merged === true`. Persisted in the state JSON. */
prMerged?: boolean
/** PR 合并时间(ISO 8601,来自 PR API 的 merged_at)。完成列按它降序排序,未合并为 undefined。合并时持久化进 state JSON;完成工单加载时不再实时拉 PR,直接读持久化值(缺失则按工单号回退排序)。 */
prMergedAt?: string
/** Branch name created for implementation, e.g. `feature/<hash>`. */
branch?: string
/** Workspace-relative path to the implementation worktree. */
worktreePath?: string
/** Whether the worktree path still exists on disk; computed by the loader. */
worktreeExists?: boolean
/** Lifecycle of the implementation flow. */
implementStatus?: 'running' | 'done' | 'failed'
/** Session id of the implementation conversation (separate from
* discussion-session `sessionId`). */
implementSessionId?: string
/** Backend-agnostic review session id (v1 stores a codex thread id). Set
* the first time auto-review runs; reused for subsequent `synchronize`
* webhook callbacks via `codex exec resume`. */
reviewSessionId?: string
/** Session id of the test conversation (a Claude Code session id). Set the
* first time the user manually starts a test session after the issue's PR
* is merged; reused for resume from the detail panel. */
testSessionId?: string
/** Browser URL to the Gitea issue page (escape hatch button in the UI). */
htmlUrl: string
/** 前置任务 issue number (Gitea dependencies 取第一个) */
prerequisite?: number
/** Terminal/tab color id (a `terminal.ansi*` ThemeColor key) chosen the
* first time a session is opened for this issue, persisted in the state
* JSON so all subsequent sessions reuse the same tone. */
color?: string
/** Per-issue override of the global `autoReview` setting. When set, the
* webhook coordinator uses this value instead of the global flag. Stored
* in the issue's state-JSON comment so it survives reloads. */
autoReview?: boolean
/** Whether the brainstorm terminal tab is currently alive in this VS Code window. */
brainstormTabOpen?: boolean
/** Whether the implementation terminal tab is currently alive in this VS Code window. */
implementTabOpen?: boolean
/** Whether the review terminal tab is currently alive in this VS Code window. */
reviewTabOpen?: boolean
/** Whether the test terminal tab is currently alive in this VS Code window. */
testTabOpen?: boolean
}