Compare commits
10
Commits
4aae4df1f1
...
0332d1da72
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0332d1da72 | ||
|
|
fb122b76ce | ||
|
|
6603e76fe1 | ||
|
|
4ae508aee3 | ||
|
|
dbb7aa8a59 | ||
|
|
9e255f517b | ||
|
|
73f2c3ec66 | ||
|
|
0b85b9868b | ||
|
|
727dcd2480 | ||
|
|
17570749dd |
No files matched your search
+1
-1
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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 内容评论,从而不丢失历史状态。
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 会话进去解决、
|
||||
* 按原顺序摘完剩余提交并推远端。同名终端还活着就把提示词直接打进去,不再新开。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user