Compare commits

...
10 Commits
Author SHA1 Message Date
cruldra 0332d1da72 🔧 chore(vscode): 版本号升到 0.2.148 2026-09-21 00:37:18 +08:00
cruldra fb122b76ce ✨ feat(vscode): 同事改工单所在列、开关工单时,本机看板实时跟着动
此前只有新建工单、改标题、改指派会实时上卡;列变化存在 state JSON 评论里,
而 issue_comment 处理只认带 spx:review 标识的审查评论,别人拖卡这边毫无反应,
issues closed/reopened 也没接。抽出 refreshIssueCard 作为统一刷卡入口:重拉
该工单、叠加本机运行态、按 mine 可见性门禁决定上卡还是撤卡,只刷 UI,不写
远端状态也不起会话,观察者机器执行同样安全。state 评论(解析出 column)与
工单开关都接到这个入口上。
2026-09-21 00:37:04 +08:00
cruldra 6603e76fe1 🔧 chore(vscode): 版本号升到 0.2.147 2026-09-20 18:00:45 +08:00
cruldra 4ae508aee3 🐛 fix(vscode): worktree 还在的工单也能启动测试会话,不再强制要求 PR
handleStartTestSession 早就允许在工单 worktree 里测——代码就在工作目录,
不需要 PR 做参考;只有 worktree 已清理、回退主工作区时才要靠 PR 了解
合并进来的改动。详情面板的 ▶ 按钮却只看 pr,导致「有 worktree、没 PR」
的工单点不了,实际后端跑得起来。按钮条件补上 worktreeExists,与后端对齐。
2026-09-20 18:00:27 +08:00
cruldra dbb7aa8a59 🔧 chore(vscode): 版本号升到 0.2.146 2026-09-20 17:25:09 +08:00
cruldra 9e255f517b ✨ feat(vscode): 看板顶部加筛选框,按标题 / 工单号 / PR 号过滤卡片
工单多了以后靠肉眼在四列里翻太慢。顶部输入框按空白分词逐词 AND 匹配
标题、工单号、PR 号,`#427` 与 `427` 等价,纯数字按包含匹配,记不全
编号也能搜到。筛选只收窄渲染,拖拽与依赖锁定仍按全量列表算;列头角标
在筛选时显示「可见 / 总数」。
2026-09-20 17:24:54 +08:00
cruldra 73f2c3ec66 🐛 fix(vscode): 完成列缺 PR 合并时间时按工单关闭时间排序
没走 PR 流程、直接关掉的工单没有 merged_at,原先一律排在所有已合并
工单之后,在上百张卡的完成列里等于永远看不见。loader 把 Gitea issue
的 closed_at 带进 Issue,完成列排序缺 prMergedAt 时回退到它,让两类
工单落在同一条时间线上。
2026-09-20 17:23:25 +08:00
cruldra 0b85b9868b 🧹 chore(vscode): 删掉 cutRelease 换签名后残留的旧注释 2026-09-17 17:58:12 +08:00
cruldra 727dcd2480 🔧 chore(vscode): 版本号升到 0.2.145 2026-09-17 17:57:47 +08:00
cruldra 17570749dd ✨ feat(vscode): 同一天再切发布直接把 main 新提交推进到当天分支,不再另起编号分支 2026-09-17 17:57:47 +08:00
20 changed files with 481 additions and 130 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra",
"name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra",
"version": "0.2.144",
"version": "0.2.148",
"packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra",
+39 -40
View File
@@ -12,8 +12,6 @@ const DEFAULT_PREFIX = 'release/'
export interface ReleaseStatus {
devBranch: string
/** 下一次切发布会得到的分支全名;同一天已切过则带 .2 / .3 序号 */
next: string
/** 当期发布分支全名(含前缀);远端没有任何发布分支时为 undefined */
current: string | undefined
/** 未发布提交数;current 为 undefined 时为 0 */
@@ -104,30 +102,16 @@ export function normalizeReleasePrefix(raw: string): string {
return t.endsWith('/') ? t : `${t}/`
}
/** 当天首次切不带序号;同一天再切从 .2 起,取已有最大序号加一,删过中间序号也不会倒退 */
export function nextReleaseBranch(prefix: string, remoteBranches: string[], now = new Date()): string {
export function todayReleaseBranch(prefix: string, now = new Date()): string {
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
const base = `${prefix}${y}-${m}-${d}`
const seqs = remoteBranches.flatMap((b) => {
if (b === base)
return [1]
if (!b.startsWith(`${base}.`))
return []
const n = Number(b.slice(base.length + 1))
return Number.isInteger(n) && n > 1 ? [n] : []
})
return seqs.length === 0 ? base : `${base}.${Math.max(...seqs) + 1}`
}
/** 先按日期再按同日序号;纯字典序会把 .10 排到 .2 前面 */
export function compareReleaseBranches(a: string, b: string): number {
return a.localeCompare(b, 'en', { numeric: true })
return `${prefix}${y}-${m}-${d}`
}
/** 发布分支名是 ISO 日期,字典序即时间序,不解析日期 */
export function pickCurrentRelease(prefix: string, remoteBranches: string[]): string | undefined {
return remoteBranches.filter(b => b.startsWith(prefix)).sort(compareReleaseBranches).at(-1)
return remoteBranches.filter(b => b.startsWith(prefix)).sort().at(-1)
}
/** Gitea 默认合并说明:Merge pull request '标题' (#260) from … */
@@ -184,17 +168,15 @@ async function remoteRefExists(cwd: string, branch: string): Promise<boolean> {
const unreleasedRange = (current: string, devBranch: string): string => `origin/${current}...origin/${devBranch}`
export async function getReleaseStatus(o: { cwd: string, devBranch: string, prefix: string }): Promise<ReleaseStatus> {
const base = { devBranch: o.devBranch, next: nextReleaseBranch(o.prefix, []), current: undefined, unreleased: 0 }
const base = { devBranch: o.devBranch, current: undefined, unreleased: 0 }
const fetched = await runGit(o.cwd, ['fetch', 'origin', '--prune'])
if (!fetched.ok)
return { ...base, unavailable: `拉取远端失败:${tail(fetched.stderr)}` }
if (!await remoteRefExists(o.cwd, o.devBranch))
return { ...base, unavailable: `远端没有 ${o.devBranch}` }
const names = await remoteBranchNames(o.cwd, o.prefix)
const next = nextReleaseBranch(o.prefix, names)
const current = pickCurrentRelease(o.prefix, names)
const current = pickCurrentRelease(o.prefix, await remoteBranchNames(o.cwd, o.prefix))
if (!current)
return { ...base, next }
return base
const shas = await mustGit(
o.cwd,
['rev-list', '--right-only', '--cherry-pick', '--no-merges', unreleasedRange(current, o.devBranch)],
@@ -202,7 +184,7 @@ export async function getReleaseStatus(o: { cwd: string, devBranch: string, pref
)
const picked = await cherryPickedSources(o.cwd, `origin/${o.devBranch}..origin/${current}`)
const unreleased = shas.split('\n').filter(sha => sha && !picked.has(sha)).length
return { devBranch: o.devBranch, next, current, unreleased }
return { devBranch: o.devBranch, current, unreleased }
}
/**
@@ -217,7 +199,7 @@ async function cherryPickedSources(cwd: string, range: string): Promise<Set<stri
/** 新→旧;不 fetch,调用方自己决定何时刷新远端 */
export async function listReleaseBranches(o: { cwd: string, prefix: string }): Promise<string[]> {
const names = await remoteBranchNames(o.cwd, o.prefix)
return names.filter(b => b.startsWith(o.prefix)).sort(compareReleaseBranches).reverse()
return names.filter(b => b.startsWith(o.prefix)).sort().reverse()
}
/** 用本地 merge 记录把未发布 sha 对上 PR。不打 Gitea。 */
@@ -477,21 +459,38 @@ async function attachMigrationVerdicts(cwd: string, current: string, commits: Un
})
}
/** 纯远端切分支;返回该分支相对上一个发布点的提交数(无上一个则为 0) */
export async function cutRelease(o: { cwd: string, devBranch: string, prefix: string, now?: Date }): Promise<{ branch: string, count: number }> {
export type CutOutcome
= | { kind: 'created', branch: string, count: number }
| { kind: 'advanced', branch: string, count: number }
| { kind: 'diverged', branch: string }
/**
* 切发布 = 让今天的发布分支等于 origin/devBranch 此刻的内容:
* ① 今天还没切过 → 从 origin/devBranch 新建
* ② 切过,分支上没别的提交 → 快进到 origin/devBranch,SHA 原样保留
* ③ 切过,分支上已有单独摘的提交 → 快进不了,交给调用方逐个摘
*/
export async function cutRelease(o: { cwd: string, devBranch: string, prefix: string, now?: Date }): Promise<CutOutcome> {
await mustGit(o.cwd, ['fetch', 'origin', '--prune'], '拉取远端失败')
const branch = todayReleaseBranch(o.prefix, o.now)
const names = await remoteBranchNames(o.cwd, o.prefix)
const branch = nextReleaseBranch(o.prefix, names, o.now)
const prev = pickCurrentRelease(o.prefix, names)
await mustGit(
o.cwd,
['push', 'origin', `--force-with-lease=refs/heads/${branch}:`, `origin/${o.devBranch}:refs/heads/${branch}`], // ← 空 lease = 远端此刻必须不存在,fetch 之后别人抢先切了也推不上去
`推送 ${branch} 失败`,
)
if (!prev)
return { branch, count: 0 }
const count = await mustGit(o.cwd, ['rev-list', '--count', `origin/${prev}..origin/${o.devBranch}`], '统计提交数失败')
return { branch, count: Number(count) }
if (!names.includes(branch)) { // ①
const prev = pickCurrentRelease(o.prefix, names)
await mustGit(
o.cwd,
['push', 'origin', `--force-with-lease=refs/heads/${branch}:`, `origin/${o.devBranch}:refs/heads/${branch}`], // ← 空 lease = 远端此刻必须不存在,fetch 之后别人抢先切了也推不上去
`推送 ${branch} 失败`,
)
const count = prev ? Number(await mustGit(o.cwd, ['rev-list', '--count', `origin/${prev}..origin/${o.devBranch}`], '统计提交数失败')) : 0
return { kind: 'created', branch, count }
}
const linear = await runGit(o.cwd, ['merge-base', '--is-ancestor', `origin/${branch}`, `origin/${o.devBranch}`])
if (!linear.ok) // ③
return { kind: 'diverged', branch }
const count = Number(await mustGit(o.cwd, ['rev-list', '--count', `origin/${branch}..origin/${o.devBranch}`], '统计提交数失败'))
if (count > 0) // ② 不带 force:fetch 之后远端被人推过,git 自己会拒掉非快进
await mustGit(o.cwd, ['push', 'origin', `origin/${o.devBranch}:refs/heads/${branch}`], `推送 ${branch} 失败`)
return { kind: 'advanced', branch, count }
}
/** 在临时 worktree 里按给定顺序(旧→新)cherry-pick,成功才推远端;无论成败都清理 worktree */
+2
View File
@@ -29,6 +29,8 @@ export interface GiteaIssue {
comments: number
created_at: string
updated_at: string
/** 工单关闭时间(ISO 8601);未关闭为 null。完成列缺 PR 合并时间时用它排序。 */
closed_at?: string | null
body: string
html_url: string
user: { login: string, avatar_url?: string } | null
+1
View File
@@ -435,6 +435,7 @@ async function buildIssue(opts: {
...(pr ? { pr } : {}),
...(prMerged !== undefined ? { prMerged } : {}),
...(mergedAt ? { prMergedAt: mergedAt } : {}),
...(issue.closed_at ? { closedAt: issue.closed_at } : {}),
...(branch ? { branch } : {}),
...(worktreePath ? { worktreePath } : {}),
...(worktreeExists !== undefined ? { worktreeExists } : {}),
+9
View File
@@ -43,6 +43,15 @@ function parseStateComment(body: string | undefined | null): Record<string, unkn
return hasKnownField ? obj : null
}
/**
* 评论是否是一条带 `column` 的 state JSON——即「这张卡换列了」的信号。
* webhook 收到这种评论时据此刷新对应卡片。
*/
export function hasStateColumn(body: string | undefined | null): boolean {
const parsed = parseStateComment(body)
return parsed !== null && 'column' in parsed
}
/**
* 从尾往前找最近一条 state JSON comment,返回解析结果;找不到返回 null。
* 跳过中途插入的普通文本/JSON 内容评论,从而不丢失历史状态。
+2
View File
@@ -76,6 +76,8 @@ export interface Issue {
prMerged?: boolean
/** PR 合并时间(ISO 8601,来自 PR API 的 merged_at)。完成列按它降序排序,未合并为 undefined。合并时持久化进 state JSON;完成工单加载时不再实时拉 PR,直接读持久化值(缺失则按工单号回退排序)。 */
prMergedAt?: string
/** 工单关闭时间(ISO 8601,来自 Gitea issue 的 closed_at)。完成列缺 prMergedAt 时按它排序,直接关掉、没走 PR 的工单才不会沉底。 */
closedAt?: string
/** Branch name created for implementation, e.g. `feature/<hash>`. */
branch?: string
/** Workspace-relative path to the implementation worktree. */
+58 -32
View File
@@ -20,7 +20,7 @@ import {
indexUnreleasedByMergePr,
listReleaseBranches,
listUnreleasedCommits,
nextReleaseBranch,
todayReleaseBranch,
} from '../../git/release'
import { DEFAULT_WORKTREE_TEMPLATE, resolveWorktreeDir } from '../../git/worktreePath'
import { triggerJenkinsBuild } from '../../jenkins/trigger'
@@ -35,7 +35,7 @@ export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise<vo
const base = {
type: 'release/status' as const,
devBranch: s.devBranch,
nextBranch: nextReleaseBranch(s.releaseBranchPrefix, []),
nextBranch: todayReleaseBranch(s.releaseBranchPrefix),
running: panel.releaseRunning,
jenkinsConfigured: s.jenkinsJob.length > 0,
}
@@ -45,7 +45,7 @@ export async function handleReleaseStatus(panel: KanbanWebviewPanel): Promise<vo
}
try {
const st = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
panel.postMessage({ ...base, nextBranch: st.next, current: st.current, unreleased: st.unreleased, unavailable: st.unavailable })
panel.postMessage({ ...base, current: st.current, unreleased: st.unreleased, unavailable: st.unavailable })
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
@@ -88,13 +88,26 @@ export function handleReleaseCut(panel: KanbanWebviewPanel): Promise<void> {
return runReleaseAction(panel, async () => {
const root = requireRoot()
const s = getSettings(panel.context)
const { branch, count } = await cutRelease({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
toast(panel, 'success', `已切发布分支 ${branch}(${count} 个提交)`)
const r = await cutRelease({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
if (r.kind === 'diverged') {
const picked = await pickUnreleased(panel, { root, devBranch: s.devBranch, current: r.branch })
if (picked !== 'done')
return
}
else if (r.kind === 'advanced' && r.count === 0) {
toast(panel, 'info', `${r.branch} 已经和 ${s.devBranch} 一致,没有新提交`)
return
}
else {
toast(panel, 'success', r.kind === 'created'
? `已切发布分支 ${r.branch}(${r.count} 个提交)`
: `已把 ${s.devBranch} 上 ${r.count} 个新提交推进到 ${r.branch}`)
}
if (!s.jenkinsJob)
return
const pick = await window.showInformationMessage(`现在部署 ${branch} 到${s.jenkinsProdHost}?`, '部署')
const pick = await window.showInformationMessage(`现在部署 ${r.branch} 到${s.jenkinsProdHost}?`, '部署')
if (pick === '部署')
await deploy(panel, branch)
await deploy(panel, r.branch)
})
}
@@ -140,35 +153,48 @@ export function handleReleaseCherryPickConfirm(panel: KanbanWebviewPanel, shas:
const status = await getReleaseStatus({ cwd: root, devBranch: s.devBranch, prefix: s.releaseBranchPrefix })
if (status.unavailable)
throw new Error(status.unavailable)
const current = status.current
if (!current)
if (!status.current)
throw new Error('尚无发布分支,先切新发布')
const commits = await listUnreleasedCommits({ cwd: root, devBranch: s.devBranch, current })
const wanted = new Set(shas)
const blocked = commits.find(c => wanted.has(c.sha) && c.migration?.blocked)
if (blocked)
throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`)
const order = new Map(commits.map((c, i) => [c.sha, i]))
const ordered = [...shas].sort((a, b) => (order.get(b) ?? 0) - (order.get(a) ?? 0))
// 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。
const worktreePath = resolveWorktreeDir(s.worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE, root, current.replace(/\//g, '-'))
const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${current}…`, { spinner: true, dismissOnTimer: 0 })
let conflict: CherryPickConflict | undefined
try {
conflict = await cherryPickToRelease({ cwd: root, branch: current, shas: ordered, worktreePath })
}
finally {
dismiss(panel, spinner)
}
if (!conflict) {
toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${current}`)
return
}
openReleaseConflictSession(panel, { branch: current, conflict, subjectOf: new Map(commits.map(c => [c.sha, c.subject])) })
toast(panel, 'info', `${conflict.sha.slice(0, 7)} ${conflict.subject} 冲突,已开 ${current} 冲突解决会话接手`, { dismissOnTimer: 10_000 })
await pickUnreleased(panel, { root, devBranch: s.devBranch, current: status.current, shas })
})
}
/**
* 把 devBranch 上未发布的提交摘到 current,shas 不传就全摘。
* 冲突时开会话接手并返回 'conflict',调用方据此决定后续(比如要不要问部署)。
*/
async function pickUnreleased(panel: KanbanWebviewPanel, o: { root: string, devBranch: string, current: string, shas?: string[] }): Promise<'done' | 'conflict' | 'nothing'> {
const s = getSettings(panel.context)
const commits = await listUnreleasedCommits({ cwd: o.root, devBranch: o.devBranch, current: o.current })
const wanted = o.shas && new Set(o.shas)
const chosen = wanted ? commits.filter(c => wanted.has(c.sha)) : commits
if (chosen.length === 0) {
toast(panel, 'info', '没有待摘的提交')
return 'nothing'
}
const blocked = chosen.find(c => c.migration?.blocked)
if (blocked)
throw new Error(`${blocked.sha.slice(0, 7)} ${blocked.subject}:${blocked.migration!.blocked}`)
const ordered = chosen.map(c => c.sha).reverse() // ← 列表是新→旧,摘要旧→新
// 发布 worktree 走和实施 worktree 同一套目录模板,分支名里的 / 换成 - 当目录名。
const worktreePath = resolveWorktreeDir(s.worktreeDirectory || DEFAULT_WORKTREE_TEMPLATE, o.root, o.current.replace(/\//g, '-'))
const spinner = toast(panel, 'info', `正在摘 ${ordered.length} 个提交到 ${o.current}…`, { spinner: true, dismissOnTimer: 0 })
let conflict: CherryPickConflict | undefined
try {
conflict = await cherryPickToRelease({ cwd: o.root, branch: o.current, shas: ordered, worktreePath })
}
finally {
dismiss(panel, spinner)
}
if (!conflict) {
toast(panel, 'success', `已摘 ${ordered.length} 个提交到 ${o.current}`)
return 'done'
}
openReleaseConflictSession(panel, { branch: o.current, conflict, subjectOf: new Map(commits.map(c => [c.sha, c.subject])) })
toast(panel, 'info', `${conflict.sha.slice(0, 7)} ${conflict.subject} 冲突,已开 ${o.current} 冲突解决会话接手`, { dismissOnTimer: 10_000 })
return 'conflict'
}
/**
* 摘修复冲突后的接力:worktree 里 cherry-pick 停在中间态,开一个 cc 会话进去解决、
* 按原顺序摘完剩余提交并推远端。同名终端还活着就把提示词直接打进去,不再新开。
+70 -17
View File
@@ -22,7 +22,7 @@ import { isMailboxIssue } from '../cc/sessionHandoff'
import { detectRepo } from '../git/remote'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
import { logger } from '../logging/logger'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
@@ -442,6 +442,9 @@ class WebhookCoordinator {
else if (event.action === 'edited') {
await this.handleIssueEdited(event)
}
else if (event.action === 'closed' || event.action === 'reopened') {
await this.handleIssueStateChanged(event)
}
else if (event.action === 'assigned' || event.action === 'unassigned') {
// Gitea 的 issue_assign 事件 X-Gitea-Event 也是 'issues',靠
// action 区分,不会走到独立的事件分支。
@@ -720,10 +723,19 @@ class WebhookCoordinator {
}
/**
* issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板
* 要实时上卡/撤卡,不能等下次手动刷新。
* 把一张工单的最新状态推给看板:重拉这张工单、叠加本机运行态,再按
* 'mine' 可见性门禁决定上卡还是撤卡。指派变化、列变化、开关工单都走
* 这里。它只刷 UI——不写远端状态、不起会话,所以在"观察者"机器上执行
* 同样安全,同事把卡拖到哪一列,这边就跟着动到哪一列。
*/
private async handleIssueAssigned(event: IssueWebhookEvent): Promise<void> {
private async refreshIssueCard(opts: {
issueNumber: number
/** 原始 webhook payload,'mine' 门禁从里面取 poster / assignees。 */
raw: unknown
/** 日志里标明这次刷新由什么事件触发,如 'issue assigned'。 */
reason: string
}): Promise<void> {
const { issueNumber, raw, reason } = opts
if (!this.ctx)
return
// 没面板就没什么可更新,下次打开看板的批量加载会兜底。
@@ -735,7 +747,7 @@ class WebhookCoordinator {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue assigned 事件忽略:未打开工作区',
message: `${reason} 事件忽略:未打开工作区`,
})
return
}
@@ -744,7 +756,7 @@ class WebhookCoordinator {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue assigned 事件忽略:工作区未关联 gitea',
message: `${reason} 事件忽略:工作区未关联 gitea`,
})
return
}
@@ -753,12 +765,12 @@ class WebhookCoordinator {
logger.add({
level: 'warn',
source: 'webhook',
message: 'issue assigned 事件忽略:未配置 token',
message: `${reason} 事件忽略:未配置 token`,
})
return
}
// 与 handleIssueOpened 同一套 'mine' 可见性门禁:指派给我 → 上卡,
// 与 handleIssueOpened 同一套 'mine' 可见性门禁:与我有关 → 上卡,
// 与我无关(含被取消指派)→ 撤卡。身份/payload 只在 'mine' 时解析,
// 省一次身份请求。
const scope = this.ctx.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
@@ -766,20 +778,20 @@ class WebhookCoordinator {
let assignees: string[] = []
let me: string | undefined
if (scope === 'mine') {
const raw = event.raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null
poster = raw?.issue?.user?.login
assignees = (raw?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : []))
const payload = raw as { issue?: { user?: { login?: string } | null, assignees?: Array<{ login?: string } | null> | null } | null } | null
poster = payload?.issue?.user?.login
assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : []))
me = await loginForToken(remote.host, token)
}
if (!decideIssueAppend({ scope, pendingCreatedLocally: false, poster, assignees, me })) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue #${event.issueNumber} ${event.action === 'unassigned' ? '取消指派' : '指派'}后与我无关,scope=mine 撤卡`,
message: `issue #${issueNumber} ${reason} 后与我无关,scope=mine 撤卡`,
details: `poster=${poster ?? '<未知>'} assignees=[${assignees.join(',')}] me=${me ?? '<未知>'}`,
})
// 卡本来不在板上时前端 remove 是无害 no-op。
this.activePanel.postMessage({ type: 'issue/remove', issueNumber: event.issueNumber })
this.activePanel.postMessage({ type: 'issue/remove', issueNumber })
return
}
try {
@@ -789,11 +801,11 @@ class WebhookCoordinator {
repo: remote.repo,
token,
workspaceRoot: ws,
issueNumber: event.issueNumber,
issueNumber,
})
const issue = loaded ? (await annotateReviewSessionFileExists(overlayLocalIssueState(this.ctx, [loaded], ws)))[0] : null
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡刷新
// assignee,不在板上的新增——指派给我的工单借此实时上卡。
// 前端对 issue/append 按 number 去重原位替换:已在板上的卡就地刷新
// (含所在列),不在板上的新增——指派给我的工单借此实时上卡。
if (issue) {
this.activePanel.noteIssueOwnership(issue)
this.activePanel.postMessage({ type: 'issue/append', issue })
@@ -804,12 +816,36 @@ class WebhookCoordinator {
logger.add({
level: 'warn',
source: 'webhook',
message: 'loadSingleIssue 失败(issue assigned)',
message: `loadSingleIssue 失败(${reason})`,
details: msg,
})
}
}
/**
* issue assigned/unassigned:指派关系变化后,受影响机器的 'mine' 看板
* 要实时上卡/撤卡,不能等下次手动刷新。
*/
private async handleIssueAssigned(event: IssueWebhookEvent): Promise<void> {
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: event.action === 'unassigned' ? '取消指派' : '指派',
})
}
/**
* issue closed/reopened:工单直接在 Gitea 上开关时,状态评论不一定跟着
* 变,但卡片的默认列会变(关闭 → 完成),所以照样重拉一次。
*/
private async handleIssueStateChanged(event: IssueWebhookEvent): Promise<void> {
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: event.action === 'closed' ? '工单关闭' : '工单重开',
})
}
/**
* Issue `edited` payload: cc 会往 issue body 里追加 `<!-- spx:spec=... -->`
* / `<!-- spx:plan=... -->` / `<!-- spx:prototype=... -->` 三种 marker。
@@ -1386,6 +1422,23 @@ class WebhookCoordinator {
message: `issue_comment body diag #${event.issueNumber} len=${body.length}`,
details: body.slice(0, 200),
})
// 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡
// 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张
// 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。
if (event.prNumber === undefined && hasStateColumn(body)) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`,
})
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: '列变化',
})
return
}
if (!/<!--\s*spx:review=1\s*-->/i.test(body)) {
logger.add({
level: 'info',
+30 -22
View File
@@ -11,11 +11,11 @@ import {
indexUnreleasedByMergePr,
listReleaseBranches,
listUnreleasedCommits,
nextReleaseBranch,
normalizeReleasePrefix,
parseAlembicRevision,
parseGiteaMergeSubject,
pickCurrentRelease,
todayReleaseBranch,
} from '../../src/git/release'
const gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' }
@@ -59,19 +59,11 @@ describe('纯函数', () => {
expect(normalizeReleasePrefix(' rel ')).toBe('rel/')
expect(normalizeReleasePrefix('release/')).toBe('release/')
})
it('nextReleaseBranch 当天首次不带序号,再切从 .2 起,取最大序号加一', () => {
expect(nextReleaseBranch('release/', ['main'], AUG31)).toBe('release/2026-08-31')
expect(nextReleaseBranch('release/', ['release/2026-08-30', 'release/2026-08-30.2'], AUG31)).toBe('release/2026-08-31')
expect(nextReleaseBranch('release/', ['release/2026-08-31'], AUG31)).toBe('release/2026-08-31.2')
expect(nextReleaseBranch('release/', ['release/2026-08-31', 'release/2026-08-31.2'], AUG31)).toBe('release/2026-08-31.3')
expect(nextReleaseBranch('release/', ['release/2026-08-31.3'], AUG31)).toBe('release/2026-08-31.4')
expect(nextReleaseBranch('release/', ['release/2026-08-31.x'], AUG31)).toBe('release/2026-08-31')
it('todayReleaseBranch', () => {
expect(todayReleaseBranch('release/', AUG31)).toBe('release/2026-08-31')
})
it('pickCurrentRelease 先按日期再按同日序号,.10 排在 .9 后', () => {
it('pickCurrentRelease 取前缀内字典序最大', () => {
expect(pickCurrentRelease('release/', ['main', 'release/2026-08-01', 'release/2026-08-31', 'hotfix/2027'])).toBe('release/2026-08-31')
expect(pickCurrentRelease('release/', ['release/2026-08-31.2', 'release/2026-08-31'])).toBe('release/2026-08-31.2')
expect(pickCurrentRelease('release/', ['release/2026-08-31.10', 'release/2026-08-31.9'])).toBe('release/2026-08-31.10')
expect(pickCurrentRelease('release/', ['release/2026-08-31.3', 'release/2026-09-01'])).toBe('release/2026-09-01')
expect(pickCurrentRelease('release/', ['main'])).toBeUndefined()
})
it('parseGiteaMergeSubject 认 Gitea 合并说明', () => {
@@ -108,16 +100,33 @@ describe('集成', () => {
expect(s).toMatchObject({ devBranch: 'main', current: undefined, unreleased: 0 })
expect(s.unavailable).toBeUndefined()
})
it('cutRelease 纯远端切分支,不建本地分支;同一天再切得到 .2', async () => {
it('cutRelease 纯远端切分支,不建本地分支;同一天再切 = 快进到 main,不另起分支', async () => {
fx = await setup()
const { repo, origin } = fx
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ branch: 'release/2026-08-31', count: 0 })
const { repo, origin, peer } = fx
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'created', branch: 'release/2026-08-31', count: 0 })
expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(git(origin, ['rev-parse', 'main']))
expect(() => git(repo, ['rev-parse', '--verify', 'refs/heads/release/2026-08-31'])).toThrow()
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ branch: 'release/2026-08-31.2', count: 0 })
expect(git(origin, ['rev-parse', 'release/2026-08-31.2'])).toBe(git(origin, ['rev-parse', 'main']))
const s = await getReleaseStatus({ cwd: repo, devBranch: 'main', prefix: 'release/' })
expect(s.current).toBe('release/2026-08-31.2')
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'advanced', branch: 'release/2026-08-31', count: 0 })
commit(peer, 'b.txt', 'b', 'feat b')
git(peer, ['push', 'origin', 'main'])
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'advanced', branch: 'release/2026-08-31', count: 1 })
expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(git(origin, ['rev-parse', 'main']))
expect(git(origin, ['for-each-ref', '--format=%(refname:short)', 'refs/heads/release/'])).toBe('release/2026-08-31')
})
it('同一天再切但发布分支上已有单独摘的提交 → diverged,远端不动', async () => {
fx = await setup()
const { repo, origin, peer } = fx
await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
const fix = commit(peer, 'b.txt', 'b', 'fix b')
commit(peer, 'c.txt', 'c', 'feat c')
git(peer, ['push', 'origin', 'main'])
git(peer, ['fetch', 'origin'])
git(peer, ['checkout', '-b', 'rel', 'origin/release/2026-08-31'])
git(peer, ['cherry-pick', '-x', fix])
git(peer, ['push', 'origin', 'HEAD:release/2026-08-31'])
const before = git(origin, ['rev-parse', 'release/2026-08-31'])
expect(await cutRelease({ cwd: repo, devBranch: 'main', prefix: 'release/', now: AUG31 })).toEqual({ kind: 'diverged', branch: 'release/2026-08-31' })
expect(git(origin, ['rev-parse', 'release/2026-08-31'])).toBe(before)
})
it('unreleased 计数忽略已 cherry-pick 的提交;cherryPickToRelease 推远端并清理 worktree', async () => {
fx = await setup()
@@ -178,13 +187,12 @@ describe('集成', () => {
await expect(cherryPickToRelease({ cwd: fx.repo, branch: 'release/2026-08-31', shas: [fix], worktreePath: fx.repo })).rejects.toThrow(/工作区本身/)
expect(git(fx.repo, ['status', '--porcelain'])).toBe('')
})
it('listReleaseBranches 新→旧,同日序号大的在前', async () => {
it('listReleaseBranches 新→旧', async () => {
fx = await setup()
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG01 })
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
await cutRelease({ cwd: fx.repo, devBranch: 'main', prefix: 'release/', now: AUG31 })
git(fx.repo, ['fetch', 'origin'])
expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31.2', 'release/2026-08-31', 'release/2026-08-01'])
expect(await listReleaseBranches({ cwd: fx.repo, prefix: 'release/' })).toEqual(['release/2026-08-31', 'release/2026-08-01'])
})
it('indexUnreleasedByMergePr 用本地合并记录把分支提交对上 PR', async () => {
fx = await setup()
+26
View File
@@ -394,3 +394,29 @@ describe('deleteLocalBranch', () => {
])
})
})
describe('hasStateColumn', () => {
it('带 column 的 state JSON → true', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('{"column":"done","pr":"318"}')).toBe(true)
expect(hasStateColumn(' {"column":"in-progress"} ')).toBe(true)
})
it('没有 column 的 state JSON → false', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('{"pr":"318","branch":"feature/x"}')).toBe(false)
})
it('普通文本、审查评论、空值 → false', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('<!-- spx:review=1 -->\n看着没问题')).toBe(false)
expect(hasStateColumn('拖到完成列了')).toBe(false)
expect(hasStateColumn('')).toBe(false)
expect(hasStateColumn(undefined)).toBe(false)
})
it('json 数组不算 state 评论', async () => {
const { hasStateColumn } = await import('../../src/gitea/stateJson')
expect(hasStateColumn('[{"column":"done"}]')).toBe(false)
})
})
+15 -2
View File
@@ -13,6 +13,7 @@ import { ToastStack } from './components/ToastStack'
import { useIssues } from './hooks/useIssues'
import { useManagedSessions } from './hooks/useManagedSessions'
import { useProfiles } from './hooks/useProfiles'
import { filterIssues } from './lib/issueFilter'
import { compareIssuesInColumn } from './lib/issueSort'
import { COLUMN_ORDER } from './types'
@@ -127,6 +128,9 @@ export function App() {
setShowNewIssueModal(false)
}
// 顶部筛选框内容。纯前端过滤,不影响拖拽与详情面板拿到的全量列表。
const [filterQuery, setFilterQuery] = useState('')
// Drop selectedId if it's no longer present after issues refresh.
const readyIssues = state.status === 'ready' ? state.issues : null
useEffect(() => {
@@ -153,13 +157,15 @@ export function App() {
// Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation.
// 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。
// 键盘导航只在筛选后可见的卡片之间走,和看板显示保持一致。
const ordered = useMemo<Issue[][]>(() => {
if (!readyIssues)
return []
const visible = filterIssues(readyIssues, filterQuery)
return COLUMN_ORDER.map(col =>
readyIssues.filter(i => i.column === col).sort((a, b) => compareIssuesInColumn(col, a, b)),
visible.filter(i => i.column === col).sort((a, b) => compareIssuesInColumn(col, a, b)),
)
}, [readyIssues])
}, [readyIssues, filterQuery])
// Resolved selected issue (or null) for the detail panel.
const selectedIssue = useMemo<Issue | null>(() => {
@@ -312,6 +318,8 @@ export function App() {
{state.status === 'loading' && (
<>
<PanelHeader
filterQuery={filterQuery}
onFilterChange={setFilterQuery}
onRefresh={refresh}
refreshing={refreshing}
scope={scope}
@@ -339,6 +347,8 @@ export function App() {
{state.status === 'ready' && (
<>
<PanelHeader
filterQuery={filterQuery}
onFilterChange={setFilterQuery}
onRefresh={refresh}
refreshing={refreshing}
scope={scope}
@@ -361,6 +371,7 @@ export function App() {
<div className="min-h-0 flex-1 overflow-hidden">
<KanbanBoard
issues={state.issues}
filterQuery={filterQuery}
onIssuesChange={setIssues}
onCreateIssue={() => setShowNewIssueModal(true)}
selectedId={selectedId}
@@ -445,6 +456,8 @@ export function App() {
{state.status === 'error' && (
<>
<PanelHeader
filterQuery={filterQuery}
onFilterChange={setFilterQuery}
onRefresh={refresh}
refreshing={refreshing}
scope={scope}
@@ -379,10 +379,12 @@ export function IssueDetailPanel({
// 测试会话 id 行有三种状态(照搬头脑风暴行的三态):
// 1. testSessionId 已存在 + testTabOpen=true → 显示 id + X 关闭 tab
// 2. testSessionId 已存在 + testTabOpen=false → 显示 id,双击 resume
// 3. testSessionId 为空 + issue.pr 存在 → 显示 Play 启动新测试 cc tab
// (prompt 依赖已合并的 PR 号,没有 PR 不显示启动按钮)
// 3. testSessionId 为空 + 有 worktree 或有 PR → 显示 Play 启动新测试 cc tab
// 条件与 handleStartTestSession 一致:worktree 还在就进 worktree 测,
// 代码本就在工作目录,不需要 PR;worktree 已清理才回退主工作区,
// 那条路径要靠 PR 让 cc 了解合并进来的改动。
const hasSession = !!(issue?.testSessionId && issue.testSessionId.length > 0)
const canStart = !hasSession && !!issue?.pr
const canStart = !hasSession && (!!issue?.worktreeExists || !!issue?.pr)
const secondaryActions: RowAction[] = []
if (hasSession && issue?.testTabOpen) {
secondaryActions.push({
@@ -410,8 +412,8 @@ export function IssueDetailPanel({
type: 'action' as const,
pending: hasSession ? isSessionTabPending(issue!.testSessionId!) : false,
description: hasSession
? '双击在新终端运行 claude --resume <id> 在工作区根(main)恢复测试对话'
: '工单 PR 合并后点击右侧 ▶ 在工作区根(main)启动一个测试 cc tab(让 cc 了解代码后告诉你怎么测试)',
? '双击在新终端运行 claude --resume <id> 恢复测试对话(worktree 在则用之,否则工作区根)'
: '点击右侧 ▶ 启动测试 cc tab(让 cc 了解代码后告诉你怎么测试):worktree 还在就在 worktree 里测,已清理则需要工单有已合并的 PR',
actionIcon: <Terminal className="size-3.5" />,
onAction: (v: unknown) => {
if (typeof v !== 'string' || v.length === 0 || !issue)
@@ -19,6 +19,7 @@ import {
import { useEffect, useMemo, useRef, useState } from 'react'
import { isIssueMine, issueOwnerLogin } from '@/lib/issueOwnership'
import { isIssueLocked } from '../lib/dependencies'
import { filterIssues } from '../lib/issueFilter'
import { compareIssuesInColumn } from '../lib/issueSort'
import { isColumnId, resolveKanbanDrag } from '../lib/kanbanDrag'
import { COLUMN_ORDER } from '../types'
@@ -27,6 +28,11 @@ import { KanbanColumn } from './KanbanColumn'
interface KanbanBoardProps {
issues: Issue[]
/**
* 顶部筛选框内容。只收窄渲染出来的卡片;拖拽、依赖锁定仍按 `issues`
* 全量算,免得筛选期间的一次拖动把被隐藏的工单从列表里抹掉。
*/
filterQuery?: string
onIssuesChange: (next: Issue[]) => void
/** Renders the + button on the todo column when provided. */
onCreateIssue?: () => void
@@ -103,6 +109,7 @@ function buildTodoTree(todoIssues: Issue[]): Array<{ issue: Issue, depth: number
export function KanbanBoard({
issues,
filterQuery = '',
onIssuesChange,
onCreateIssue,
selectedId,
@@ -127,7 +134,7 @@ export function KanbanBoard({
'review': [],
'done': [],
}
for (const issue of issues)
for (const issue of filterIssues(issues, filterQuery))
map[issue.column].push(issue)
// 各列排序:完成列按 PR 合并时间降序(最近合并在最前),其余列按 issue.number
// 降序让新工单置顶。todo 列的降序顺序同时是 `buildTodoTree` 的输入顺序——它按
@@ -135,6 +142,14 @@ export function KanbanBoard({
for (const col of Object.keys(map) as IssueColumn[])
map[col].sort((a, b) => compareIssuesInColumn(col, a, b))
return map
}, [issues, filterQuery])
// 未筛选时的每列总数,供列头角标显示「可见 / 总数」。
const totalByColumn = useMemo(() => {
const map: Record<IssueColumn, number> = { 'todo': 0, 'in-progress': 0, 'review': 0, 'done': 0 }
for (const issue of issues)
map[issue.column] += 1
return map
}, [issues])
const activeIssue = activeId ? issues.find(i => i.id === activeId) ?? null : null
@@ -240,6 +255,7 @@ export function KanbanBoard({
key={column}
column={column}
issues={columnIssues}
totalCount={totalByColumn[column]}
onCreate={column === 'todo' ? onCreateIssue : undefined}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
@@ -9,12 +9,15 @@ import { Badge } from './ui/badge'
interface KanbanColumnProps {
column: IssueColumn
issues: Issue[]
/** 该列未经筛选的卡片总数。与 `issues.length` 不等时角标显示「可见 / 总数」。 */
totalCount?: number
children: ReactNode
/** When provided, renders a + button in the header. Used for the todo column. */
onCreate?: () => void
}
export function KanbanColumn({ column, issues, children, onCreate }: KanbanColumnProps) {
export function KanbanColumn({ column, issues, totalCount, children, onCreate }: KanbanColumnProps) {
const filtered = totalCount !== undefined && totalCount !== issues.length
const { setNodeRef, isOver } = useDroppable({ id: column })
return (
@@ -31,7 +34,7 @@ export function KanbanColumn({ column, issues, children, onCreate }: KanbanColum
{COLUMN_LABELS[column]}
</span>
<div className="flex items-center gap-1.5">
<Badge variant="outline">{issues.length}</Badge>
<Badge variant="outline">{filtered ? `${issues.length} / ${totalCount}` : issues.length}</Badge>
{onCreate && (
<button
type="button"
@@ -49,7 +52,7 @@ export function KanbanColumn({ column, issues, children, onCreate }: KanbanColum
{issues.length === 0
? (
<div className="flex flex-1 items-center justify-center rounded-md border border-dashed border-[var(--vscode-foreground)] border-opacity-20 p-4 text-xs text-muted-foreground opacity-60">
拖到这里
{filtered ? '没有匹配的工单' : '拖到这里'}
</div>
)
: (
@@ -1,8 +1,11 @@
import type { ReleaseAction, ReleaseStatus } from '../hooks/useIssues'
import { GitCommit, ListPlus, Loader2, Lock, RefreshCw, Rocket, Settings, Unlock, User, Users } from 'lucide-react'
import { GitCommit, ListPlus, Loader2, Lock, RefreshCw, Rocket, Search, Settings, Unlock, User, Users, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
interface Props {
/** 顶部筛选框当前内容;按标题 / 工单号 / PR 号过滤看板卡片。 */
filterQuery: string
onFilterChange: (query: string) => void
onRefresh: () => void
/** host 正在重拉看板;为 true 时刷新图标转起来。 */
refreshing: boolean
@@ -66,6 +69,8 @@ function releaseTitle(release: ReleaseStatus | undefined): string {
}
export function PanelHeader({
filterQuery,
onFilterChange,
onRefresh,
refreshing,
scope,
@@ -113,7 +118,35 @@ export function PanelHeader({
}
return (
<div className="flex h-8 shrink-0 items-center justify-end gap-1 border-b border-[var(--vscode-panel-border)] px-2">
<div className="flex h-8 shrink-0 items-center gap-1 border-b border-[var(--vscode-panel-border)] px-2">
<div className="relative mr-auto flex items-center">
<Search size={12} className="pointer-events-none absolute left-2 opacity-60" />
<input
type="text"
value={filterQuery}
onChange={e => onFilterChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation()
onFilterChange('')
}
}}
placeholder="筛选:标题 / 工单号 / PR 号"
aria-label="筛选工单"
className="h-6 w-56 rounded border border-[var(--vscode-input-border,transparent)] bg-[var(--vscode-input-background)] pl-6 pr-6 text-xs text-[var(--vscode-input-foreground)] outline-none placeholder:text-[var(--vscode-input-placeholderForeground)] focus:border-[var(--vscode-focusBorder)]"
/>
{filterQuery !== '' && (
<button
type="button"
onClick={() => onFilterChange('')}
title="清空筛选"
aria-label="清空筛选"
className="absolute right-1 flex h-4 w-4 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
>
<X size={11} />
</button>
)}
</div>
<div ref={menuRef} className="relative">
<button
type="button"
@@ -140,7 +173,7 @@ export function PanelHeader({
className="absolute right-0 top-7 z-20 min-w-44 rounded border border-[var(--vscode-menu-border)] bg-[var(--vscode-menu-background)] py-1 text-xs text-[var(--vscode-menu-foreground)] shadow"
>
<button type="button" role="menuitem" className={MENU_ITEM_CLASS} onClick={() => pickRelease('cut')}>
{`切新发布(${release.nextBranch})`}
{release.current === release.nextBranch ? `更新今日发布(${release.nextBranch})` : `切新发布(${release.nextBranch})`}
</button>
<button
type="button"
@@ -0,0 +1,60 @@
import type { Issue } from '../types'
import { describe, expect, it } from 'vitest'
import { filterIssues } from './issueFilter'
function issue(number: number, title: string, extra: Partial<Issue> = {}): Issue {
return {
id: `repo#${number}`,
number,
title,
column: 'todo',
htmlUrl: '',
...extra,
}
}
const board = [
issue(426, '编程大赛:报名与提交作品'),
issue(427, '编程大赛-作品墙分类', { pr: '318' }),
issue(419, '管理后台统计各模型用量'),
issue(42, 'auto 模型:自动为用户会话选模型'),
]
function numbers(query: string): number[] {
return filterIssues(board, query).map(i => i.number)
}
describe('filterIssues', () => {
it('空查询放行全部,且原样返回同一个数组', () => {
expect(filterIssues(board, ' ')).toBe(board)
})
it('按标题子串匹配', () => {
expect(numbers('作品')).toEqual([426, 427])
})
it('标题匹配不区分大小写', () => {
expect(numbers('AUTO')).toEqual([42])
})
it('按工单号匹配,带不带 # 都行', () => {
expect(numbers('#427')).toEqual([427])
expect(numbers('427')).toEqual([427])
})
it('数字按包含匹配,短号能带出长号', () => {
expect(numbers('42')).toEqual([426, 427, 42])
})
it('按 PR 号匹配', () => {
expect(numbers('318')).toEqual([427])
})
it('多个词之间是 AND', () => {
expect(numbers('编程 分类')).toEqual([427])
})
it('没有命中时返回空', () => {
expect(numbers('不存在的东西')).toEqual([])
})
})
+34
View File
@@ -0,0 +1,34 @@
import type { Issue } from '../types'
/**
* 看板顶部筛选框的匹配规则。
*
* 查询按空白切成多个词,逐词 AND:工单要留在板上,每个词都得命中它的
* 标题、工单号或 PR 号之一。词首的 `#` 先去掉,`#427` 与 `427` 等价。
* 纯数字的词按「包含」匹配编号,`42` 既能带出 #42 也能带出 #427,
* 免得用户记不全编号就搜不到。
*/
export function parseFilterTerms(query: string): string[] {
return query.trim().toLowerCase().split(/\s+/).filter(Boolean)
}
function matchesTerm(issue: Issue, term: string): boolean {
if (issue.title.toLowerCase().includes(term))
return true
const digits = term.replace(/^#/, '')
if (digits === '' || !/^\d+$/.test(digits))
return false
return String(issue.number).includes(digits) || (issue.pr ?? '').includes(digits)
}
export function matchesIssueFilter(issue: Issue, terms: string[]): boolean {
return terms.every(term => matchesTerm(issue, term))
}
/** 空查询原样返回,调用方据此免掉一次无意义的数组拷贝。 */
export function filterIssues(issues: Issue[], query: string): Issue[] {
const terms = parseFilterTerms(query)
if (terms.length === 0)
return issues
return issues.filter(issue => matchesIssueFilter(issue, terms))
}
@@ -0,0 +1,56 @@
import type { Issue, IssueColumn } from '../types'
import { describe, expect, it } from 'vitest'
import { compareIssuesInColumn } from './issueSort'
function issue(
number: number,
column: IssueColumn,
extra: Partial<Issue> = {},
): Issue {
return {
id: `repo#${number}`,
number,
title: `issue ${number}`,
column,
htmlUrl: '',
...extra,
}
}
function sorted(col: IssueColumn, issues: Issue[]): number[] {
return [...issues].sort((a, b) => compareIssuesInColumn(col, a, b)).map(i => i.number)
}
describe('compareIssuesInColumn', () => {
it('非完成列按工单号降序', () => {
const list = [issue(10, 'todo'), issue(42, 'todo'), issue(7, 'todo')]
expect(sorted('todo', list)).toEqual([42, 10, 7])
})
it('完成列按 PR 合并时间降序', () => {
const list = [
issue(1, 'done', { prMergedAt: '2026-09-01T00:00:00Z' }),
issue(2, 'done', { prMergedAt: '2026-09-20T00:00:00Z' }),
issue(3, 'done', { prMergedAt: '2026-09-10T00:00:00Z' }),
]
expect(sorted('done', list)).toEqual([2, 3, 1])
})
it('没有 PR 的完成工单用关闭时间参与同一条时间线,不沉底', () => {
const list = [
issue(100, 'done', { prMergedAt: '2026-09-01T00:00:00Z' }),
issue(427, 'done', { closedAt: '2026-09-20T12:00:00Z' }),
issue(300, 'done', { prMergedAt: '2026-09-10T00:00:00Z' }),
]
expect(sorted('done', list)).toEqual([427, 300, 100])
})
it('两个时间都缺的排在最后,按工单号降序', () => {
const list = [
issue(5, 'done'),
issue(9, 'done'),
issue(1, 'done', { closedAt: '2026-01-01T00:00:00Z' }),
]
expect(sorted('done', list)).toEqual([1, 9, 5])
})
})
+10 -4
View File
@@ -3,13 +3,19 @@ import type { Issue, IssueColumn } from '../types'
/**
* 看板每一列的卡片排序。
*
* `done` 列按 PR 合并时间(merged_at)降序,最近合并的在最前;缺合并时间的
* 工单排在有时间的之后,按工单号降序兜底。其余列一律按工单号降序。
* `done` 列按"完成时间"降序,最近完成的在最前。完成时间取 PR 合并时间
* (merged_at),没有 PR 的工单退回工单关闭时间(closed_at)——不这样兜底,
* 直接关掉、没走 PR 流程的工单会永远沉在列底看不见。两者都没有的排最后,
* 按工单号降序。其余列一律按工单号降序。
*/
function doneAt(issue: Issue): string | undefined {
return issue.prMergedAt ?? issue.closedAt
}
export function compareIssuesInColumn(col: IssueColumn, a: Issue, b: Issue): number {
if (col === 'done') {
const am = a.prMergedAt
const bm = b.prMergedAt
const am = doneAt(a)
const bm = doneAt(b)
if (am && bm && am !== bm)
return bm.localeCompare(am)
if (am && !bm)
+2
View File
@@ -53,6 +53,8 @@ export interface Issue {
prMerged?: boolean
/** PR 合并时间(ISO 8601,来自 merged_at)。完成列按它降序排序,未合并/查询失败为 undefined。不持久化进 state JSON。 */
prMergedAt?: string
/** 工单关闭时间(ISO 8601)。完成列缺 prMergedAt 时按它排序。 */
closedAt?: string
/** Branch name created for implementation, e.g. `feature/<hash>`. */
branch?: string
/** Workspace-relative path to the implementation worktree. */