11
This commit is contained in:
commit
e6f1776d4f
264 files changed
+54215
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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) ?? {}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user