✨ feat(vscode): 审查通过写入工单状态并在卡片上区分

This commit is contained in:
cruldra committed 2026-10-03 03:22:20 +08:00
1 parent ac1b7ed641
commit 177e75910e
21 files changed
+203 -10

No files matched your search

+1
View File
@@ -425,6 +425,7 @@ var knownStateFields = map[string]struct{}{
"implementStatus": {}, "implementStatus": {},
"color": {}, "color": {},
"autoReview": {}, "autoReview": {},
"reviewPassed": {},
"handoffAttachmentId": {}, "handoffAttachmentId": {},
"handoffFrom": {}, "handoffFrom": {},
"handoffRequestFrom": {}, "handoffRequestFrom": {},
+4
View File
@@ -118,6 +118,10 @@
"type": "boolean", "type": "boolean",
"description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。" "description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。"
}, },
"reviewPassed": {
"type": "boolean",
"description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。"
},
"handoffAttachmentId": { "handoffAttachmentId": {
"type": "string", "type": "string",
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。"
+11 -1
View File
@@ -87,13 +87,23 @@ func TestValidateAcceptsFullState(t *testing.T) {
"worktreePath":"/home/x/Sources/worktree/proj/slug", "worktreePath":"/home/x/Sources/worktree/proj/slug",
"implementStatus":"done", "implementStatus":"done",
"color":"terminal.ansiBlue", "color":"terminal.ansiBlue",
"autoReview":true "autoReview":true,
"reviewPassed":true
}` }`
if err := Validate([]byte(full)); err != nil { if err := Validate([]byte(full)); err != nil {
t.Fatalf("完整合法 state 应当通过校验,得到: %v", err) t.Fatalf("完整合法 state 应当通过校验,得到: %v", err)
} }
} }
func TestValidateAcceptsReviewPassed(t *testing.T) {
if err := Validate([]byte(`{"reviewPassed":true}`)); err != nil {
t.Fatalf("reviewPassed=true 应当通过校验,得到: %v", err)
}
if err := Validate([]byte(`{"reviewPassed":false}`)); err != nil {
t.Fatalf("reviewPassed=false 应当通过校验,得到: %v", err)
}
}
func TestValidateRejectsBadPrototypeFile(t *testing.T) { func TestValidateRejectsBadPrototypeFile(t *testing.T) {
err := Validate([]byte(`{"prototypeFile":"docs/prototype/login/figma-plugin/manifest.md"}`)) err := Validate([]byte(`{"prototypeFile":"docs/prototype/login/figma-plugin/manifest.md"}`))
if err == nil { if err == nil {
+14
View File
@@ -22,6 +22,20 @@ opencli spx pr review-comment --pr {{prNumber}} --body-file /tmp/review-{{prNumb
审查结束以后,删除所有审查过程中创建的临时 worktree(`git worktree remove --force <路径>`),你当前所在的 worktree 除外。 审查结束以后,删除所有审查过程中创建的临时 worktree(`git worktree remove --force <路径>`),你当前所在的 worktree 除外。
## 审查结论
没有必须改的问题才算通过。小建议不算不通过。
通过:
```
opencli spx issue state merge --issue {{issueNumber}} --state-json '{"reviewPassed":true}'
```
有必须改的问题:同一条命令,把 `reviewPassed` 写成 `false`。
这条合并发生在审查评论发出之后。不要建议合并 PR,不要执行合并。
## 严禁 ## 严禁
- 不要在审查意见里建议"合并 PR"或"merge" - 不要在审查意见里建议"合并 PR"或"merge"
+4
View File
@@ -118,6 +118,10 @@
"type": "boolean", "type": "boolean",
"description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。" "description": "本工单是否启用自动审查(覆盖全局 autoReview 设置)。"
}, },
"reviewPassed": {
"type": "boolean",
"description": "最近一次审查是否认为可以过。true 表示通过;false 或不写表示还在审,或审出了必须改的问题。PR 有新提交时合并成 false。不改变看板列。"
},
"handoffAttachmentId": { "handoffAttachmentId": {
"type": "string", "type": "string",
"description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。" "description": "移交会话包在 Gitea 的附件 id;存在即「待接管」,接管完成后清空;清空时写空串。"
+5
View File
@@ -10,6 +10,11 @@ describe('systemCommentText', () => {
expect(systemCommentText('{"column":"review","color":"terminal.ansiBlue"}')).toBe('更新了工单状态,放到审查') expect(systemCommentText('{"column":"review","color":"terminal.ansiBlue"}')).toBe('更新了工单状态,放到审查')
}) })
it('审查是否通过不单写一句', () => {
expect(systemCommentText('{"reviewPassed":true}')).toBe('更新了工单状态')
expect(systemCommentText('{"column":"review","reviewPassed":false}')).toBe('更新了工单状态,放到审查')
})
it('普通评论不是系统消息', () => { it('普通评论不是系统消息', () => {
expect(systemCommentText('这个方案可以')).toBeUndefined() expect(systemCommentText('这个方案可以')).toBeUndefined()
}) })
+1
View File
@@ -23,6 +23,7 @@ const STATE_KEYS = new Set([
'worktreePath', 'worktreePath',
'color', 'color',
'autoReview', 'autoReview',
'reviewPassed',
]) ])
/** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */ /** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */
+6
View File
@@ -99,6 +99,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
prototypeSessionId?: string prototypeSessionId?: string
color?: string color?: string
autoReview?: boolean autoReview?: boolean
reviewPassed?: boolean
handoffAttachmentId?: string handoffAttachmentId?: string
handoffFrom?: string handoffFrom?: string
handoffRequestFrom?: string handoffRequestFrom?: string
@@ -150,6 +151,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
prototypeSessionId?: unknown prototypeSessionId?: unknown
color?: unknown color?: unknown
autoReview?: unknown autoReview?: unknown
reviewPassed?: unknown
handoffAttachmentId?: unknown handoffAttachmentId?: unknown
handoffFrom?: unknown handoffFrom?: unknown
handoffRequestFrom?: unknown handoffRequestFrom?: unknown
@@ -209,6 +211,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
? obj.color ? obj.color
: undefined : undefined
const autoReview = typeof obj.autoReview === 'boolean' ? obj.autoReview : undefined const autoReview = typeof obj.autoReview === 'boolean' ? obj.autoReview : undefined
const reviewPassed = typeof obj.reviewPassed === 'boolean' ? obj.reviewPassed : undefined
const handoffAttachmentId = typeof obj.handoffAttachmentId === 'string' && obj.handoffAttachmentId.length > 0 const handoffAttachmentId = typeof obj.handoffAttachmentId === 'string' && obj.handoffAttachmentId.length > 0
? obj.handoffAttachmentId ? obj.handoffAttachmentId
: undefined : undefined
@@ -244,6 +247,7 @@ function parseColumnFromComments(comments: GiteaComment[]): {
prototypeSessionId, prototypeSessionId,
color, color,
autoReview, autoReview,
reviewPassed,
handoffAttachmentId, handoffAttachmentId,
handoffFrom, handoffFrom,
handoffRequestFrom, handoffRequestFrom,
@@ -390,6 +394,7 @@ async function buildIssue(opts: {
prototypeSessionId, prototypeSessionId,
color, color,
autoReview, autoReview,
reviewPassed,
handoffAttachmentId, handoffAttachmentId,
handoffFrom, handoffFrom,
handoffRequestFrom, handoffRequestFrom,
@@ -450,6 +455,7 @@ async function buildIssue(opts: {
...(prerequisite !== undefined ? { prerequisite } : {}), ...(prerequisite !== undefined ? { prerequisite } : {}),
...(color ? { color } : {}), ...(color ? { color } : {}),
...(autoReview !== undefined ? { autoReview } : {}), ...(autoReview !== undefined ? { autoReview } : {}),
...(reviewPassed !== undefined ? { reviewPassed } : {}),
...(handoffAttachmentId ? { handoffAttachmentId } : {}), ...(handoffAttachmentId ? { handoffAttachmentId } : {}),
...(handoffFrom ? { handoffFrom } : {}), ...(handoffFrom ? { handoffFrom } : {}),
...(handoffRequestFrom ? { handoffRequestFrom } : {}), ...(handoffRequestFrom ? { handoffRequestFrom } : {}),
+1 -1
View File
@@ -19,7 +19,7 @@ import { listIssueComments, postIssueComment } from './api'
* 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前 * 普通文本评论、审查评论、Gitea 自动关联评论都不含这些字段,因此从尾往前
* 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。 * 扫描时会被跳过,避免它们插队后下一次 merge 从空状态开始丢历史。
*/ */
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
/** /**
* body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。 * body 能 parse 成 object 且含至少一个已知 state 字段 → 认为是 state JSON。
+2
View File
@@ -131,6 +131,8 @@ export interface Issue {
* in the issue's state-JSON comment so it survives reloads. * in the issue's state-JSON comment so it survives reloads.
*/ */
autoReview?: boolean autoReview?: boolean
/** 最近一次审查是否通过。只影响审查列卡片上的小字,不改列。 */
reviewPassed?: boolean
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */ /** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
assignees?: string[] assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */ /** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
+1 -1
View File
@@ -54,7 +54,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string } | { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, reviewPassed?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } }
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
@@ -0,0 +1,2 @@
/** 改「审查结论」之前的默认审查提示词。设置里若原样存着这份,整段换成新文件。 */
export const PREVIOUS_DEFAULT_REVIEW_PROMPT = '审查这个仓库的 PR #{{prNumber}}。\n\n用 `tea pulls {{prNumber}}` 看 PR 元信息(标题/描述/分支)。看代码差异用 git(当前目录就是 PR 分支的 worktree):先 `git fetch origin main`,再 `git diff origin/main...HEAD`(概览可加 --stat)。注意 tea 没有 `diff` 子命令,不要尝试 `tea pulls diff`。\n\n## 审查方式\n\nECC 的 `/ecc:review-pr` 和 `/ecc:code-review` 的 PR 模式都走 `gh`,对 Gitea 不可用,不要调。直接把上面的 diff 喂给 ECC 的审查子代理,按改动涉及的语言挑:\n\n- `ecc:code-reviewer`(必跑)\n- `ecc:silent-failure-hunter`、`ecc:pr-test-analyzer`(必跑)\n- 语言专项:TypeScript 用 `ecc:typescript-reviewer`,Python 用 `ecc:python-reviewer`,React 用 `ecc:react-reviewer`,其余同理\n\n汇总时去重、按严重程度排序,只报置信度高的问题。\n\n## 提交审查意见\n\n把审查意见(markdown 格式)写到 `/tmp/review-{{prNumber}}.md`,调 spx:\n\n```\nopencli spx pr review-comment --pr {{prNumber}} --body-file /tmp/review-{{prNumber}}.md\n```\n\n审查结束以后,删除所有审查过程中创建的临时 worktree(`git worktree remove --force <路径>`),你当前所在的 worktree 除外。\n\n## 严禁\n\n- 不要在审查意见里建议"合并 PR"或"merge"\n- 不要执行 `tea pulls merge` 或任何合并命令\n- 不要 push 到 main / dev 分支\n\n合并权完全在用户手上,你的工作只是指出问题或确认通过。\n'
@@ -1,9 +1,11 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import * as path from 'node:path' import * as path from 'node:path'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { PREVIOUS_DEFAULT_REVIEW_PROMPT } from './previousReviewPrompt'
import { import {
composeBrainstormTemplate, composeBrainstormTemplate,
planPromptMigration, planPromptMigration,
planReviewPromptMigration,
PROMPT_TEMPLATE_VERSION, PROMPT_TEMPLATE_VERSION,
splitIfBlock, splitIfBlock,
upgradePlaceholders, upgradePlaceholders,
@@ -147,3 +149,43 @@ describe('planPromptMigration', () => {
expect(merged).toContain('工单已有原型插件 {{prototypeFile}}') expect(merged).toContain('工单已有原型插件 {{prototypeFile}}')
}) })
}) })
describe('planReviewPromptMigration', () => {
const nextDefault = readFileSync(
path.resolve(__dirname, '../../prompts/review.md'),
'utf-8',
)
it('和改之前的默认一样时,整段换成现在的文件', () => {
const result = planReviewPromptMigration(
PREVIOUS_DEFAULT_REVIEW_PROMPT,
PREVIOUS_DEFAULT_REVIEW_PROMPT,
nextDefault,
)
expect(result).not.toBeNull()
expect(result!.prompt).toBe(nextDefault)
expect(result!.prompt).toContain('{"reviewPassed":true}')
expect(result!.notes[0]?.message).toContain('旧版默认')
})
it('用户改过、还没有 reviewPassed 时,只把审查结论接到末尾', () => {
const custom = '先看 diff,再写意见。\n不要跑无关命令。\n'
const result = planReviewPromptMigration(custom, PREVIOUS_DEFAULT_REVIEW_PROMPT, nextDefault)
expect(result).not.toBeNull()
expect(result!.prompt.startsWith('先看 diff,再写意见。\n不要跑无关命令。\n\n## 审查结论\n')).toBe(true)
expect(result!.prompt).toContain('reviewPassed')
expect(result!.prompt).toContain('小建议不算不通过')
expect(result!.prompt).not.toContain('## 严禁')
expect(result!.prompt).not.toContain('tea pulls')
})
it('已经写了 reviewPassed 的不改', () => {
const custom = '我自己的审查。\nreviewPassed 我写过了。\n'
expect(planReviewPromptMigration(custom, PREVIOUS_DEFAULT_REVIEW_PROMPT, nextDefault)).toBeNull()
})
it('没存审查提示词时不改', () => {
expect(planReviewPromptMigration('', PREVIOUS_DEFAULT_REVIEW_PROMPT, nextDefault)).toBeNull()
expect(planReviewPromptMigration(undefined, PREVIOUS_DEFAULT_REVIEW_PROMPT, nextDefault)).toBeNull()
})
})
+49
View File
@@ -188,3 +188,52 @@ export function planPromptMigration(
next.promptTemplateVersion = PROMPT_TEMPLATE_VERSION next.promptTemplateVersion = PROMPT_TEMPLATE_VERSION
return { blob: next, notes } return { blob: next, notes }
} }
function normalizePrompt(text: string): string {
return text.replace(/\r\n/g, '\n').trim()
}
/** 新版审查提示词里「审查结论」那一节,到下一个同级标题为止。 */
export function reviewConclusionSection(nextDefault: string): string | null {
const normalized = nextDefault.replace(/\r\n/g, '\n')
const heading = '## 审查结论'
const start = normalized.indexOf(heading)
if (start < 0)
return null
const rest = normalized.slice(start)
const relative = rest.slice(heading.length).search(/\n## /)
const section = relative < 0 ? rest : rest.slice(0, heading.length + relative)
const trimmed = section.trim()
return trimmed.length > 0 ? trimmed : null
}
/**
* 设置里已经存了一份审查提示词时:和改之前的默认一样就整段换成新文件;
* 用户改过、且还没有 reviewPassed,只把「审查结论」接到末尾。
* 返回 null 表示不用写盘。
*/
export function planReviewPromptMigration(
stored: unknown,
previousDefault: string,
nextDefault: string,
): { prompt: string, notes: MigrationNote[] } | null {
if (typeof stored !== 'string' || stored.length === 0)
return null
if (normalizePrompt(stored) === normalizePrompt(previousDefault)) {
if (normalizePrompt(nextDefault) === normalizePrompt(previousDefault))
return null
return {
prompt: nextDefault,
notes: [{ level: 'info', message: '审查提示词与旧版默认一致,已换成带审查结论的新版' }],
}
}
if (stored.includes('reviewPassed'))
return null
const section = reviewConclusionSection(nextDefault)
if (!section)
return null
return {
prompt: `${stored.replace(/\s+$/, '')}\n\n${section}\n`,
notes: [{ level: 'info', message: '审查提示词末尾已追加「审查结论」,其余段落未改' }],
}
}
+18 -4
View File
@@ -18,7 +18,8 @@ import path from 'node:path'
import { seedCommitProfilePath } from '../cc/commitProfile' import { seedCommitProfilePath } from '../cc/commitProfile'
import { listClaudeProfiles } from '../cc/profiles' import { listClaudeProfiles } from '../cc/profiles'
import { normalizeReleasePrefix } from '../git/release' import { normalizeReleasePrefix } from '../git/release'
import { planPromptMigration } from './promptMigration' import { PREVIOUS_DEFAULT_REVIEW_PROMPT } from './previousReviewPrompt'
import { planPromptMigration, planReviewPromptMigration } from './promptMigration'
/** /**
* Fallback prompt strings used when the on-disk markdown can't be read. * Fallback prompt strings used when the on-disk markdown can't be read.
@@ -513,10 +514,19 @@ export async function saveSettings(ctx: ExtensionContext, next: Partial<Settings
* 单测里直接 import。 * 单测里直接 import。
*/ */
export function migratePromptTemplates(ctx: ExtensionContext): MigrationNote[] { export function migratePromptTemplates(ctx: ExtensionContext): MigrationNote[] {
const { blob, notes } = planPromptMigration( const stored = readStoredSettings(ctx) as Record<string, unknown>
readStoredSettings(ctx) as Record<string, unknown>, const planned = planPromptMigration(
stored,
readDefaultPrompt(ctx.extensionPath, 'brainstorm'), readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
) )
const review = planReviewPromptMigration(
planned.blob?.reviewPrompt ?? stored.reviewPrompt,
PREVIOUS_DEFAULT_REVIEW_PROMPT,
readDefaultPrompt(ctx.extensionPath, 'review'),
)
const blob = review
? { ...(planned.blob ?? stored), reviewPrompt: review.prompt }
: planned.blob
if (!blob) if (!blob)
return [] return []
const out: MigrationNote[] = [] const out: MigrationNote[] = []
@@ -535,7 +545,11 @@ export function migratePromptTemplates(ctx: ExtensionContext): MigrationNote[] {
} }
} }
writeStoredSettings(ctx, blob as Partial<Settings>) writeStoredSettings(ctx, blob as Partial<Settings>)
out.push(...notes, { level: 'info', message: '提示词模板已迁到 Handlebars' }) out.push(...planned.notes)
if (review)
out.push(...review.notes)
if (planned.blob)
out.push({ level: 'info', message: '提示词模板已迁到 Handlebars' })
return out return out
} }
+30 -1
View File
@@ -1158,8 +1158,9 @@ class WebhookCoordinator {
// Per-issue override takes precedence over the global setting. // Per-issue override takes precedence over the global setting.
let effectiveAutoReview = getSettings(this.ctx).autoReview let effectiveAutoReview = getSettings(this.ctx).autoReview
let autoReviewSource: 'issue' | 'global' = 'global' let autoReviewSource: 'issue' | 'global' = 'global'
let stateJson: Record<string, unknown> | undefined
try { try {
const stateJson = await readStateJsonComment({ stateJson = await readStateJsonComment({
host: ctx.host, host: ctx.host,
token: ctx.token, token: ctx.token,
owner: ctx.owner, owner: ctx.owner,
@@ -1180,6 +1181,34 @@ class WebhookCoordinator {
details: message, details: message,
}) })
} }
// 新提交让上一次「已通过」作废。已经是 false 或没写过就不再发一条状态评论。
// 读失败时不知道旧值,也清一次,避免卡片继续挂着已通过。
if (stateJson === undefined || stateJson.reviewPassed === true) {
try {
await mergeStateJsonComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
issueNumber: event.issueNumber,
extra: { reviewPassed: false },
})
this.activePanel?.postMessage({
type: 'issue/patch',
issueNumber: event.issueNumber,
patch: { reviewPassed: false },
})
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({
level: 'warn',
source: 'webhook',
message: `新提交未能清掉审查通过 #${event.issueNumber}`,
details: message,
})
}
}
if (!effectiveAutoReview) { if (!effectiveAutoReview) {
logger.add({ logger.add({
level: 'info', level: 'info',
+1
View File
@@ -85,6 +85,7 @@ function toIssue(baseUrl: string, it: YouTrackIssue): Issue {
? state.implementStatus ? state.implementStatus
: undefined, : undefined,
autoReview: typeof state.autoReview === 'boolean' ? state.autoReview : undefined, autoReview: typeof state.autoReview === 'boolean' ? state.autoReview : undefined,
reviewPassed: typeof state.reviewPassed === 'boolean' ? state.reviewPassed : undefined,
} }
} }
+1 -1
View File
@@ -20,7 +20,7 @@ import { addComment, listComments } from './api'
export const STATE_MARKER = '<!--spx-state-->' export const STATE_MARKER = '<!--spx-state-->'
/** Same field set as the Gitea state blob — kept independent to avoid coupling. */ /** Same field set as the Gitea state blob — kept independent to avoid coupling. */
const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const const KNOWN_STATE_FIELDS = ['column', 'sessionId', 'implementSessionId', 'reviewSessionId', 'testSessionId', 'prototypeSessionId', 'profilePath', 'brainstormProfilePath', 'testProfilePath', 'prototypeProfilePath', 'specFile', 'planFile', 'prototypeFile', 'prDiffFile', 'pr', 'prMerged', 'prMergedAt', 'branch', 'worktreePath', 'implementStatus', 'color', 'autoReview', 'reviewPassed', 'handoffAttachmentId', 'handoffFrom', 'handoffRequestFrom', 'handoffRequestError'] as const
/** /**
* Parse a comment body into a state object, or null if it isn't a state * Parse a comment body into a state object, or null if it isn't a state
@@ -118,6 +118,13 @@ export const IssueCard = forwardRef<HTMLDivElement, IssueCardProps>(
{' '} {' '}
<span>{issue.title}</span> <span>{issue.title}</span>
</div> </div>
{issue.column === 'review'
? (
<div className="mt-0.5 text-[10px] leading-none opacity-60">
{issue.reviewPassed === true ? '已通过' : '审查中'}
</div>
)
: null}
</Card> </Card>
) )
}, },
+1 -1
View File
@@ -93,7 +93,7 @@ export type ExtensionToWebview
| { type: 'issues/loaded' } | { type: 'issues/loaded' }
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> } | { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean, me?: string, avatars?: Record<string, string> }
| { type: 'issues/error', message: string } | { type: 'issues/error', message: string }
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } } | { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, reviewPassed?: boolean, specFile?: string, planFile?: string, prototypeFile?: string, prDiffFile?: string | null, sessionId?: string | null, implementSessionId?: string | null, reviewSessionId?: string | null, missingSessionIds?: string[], testSessionId?: string | null, prototypeSessionId?: string | null, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed' | null, column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string | null, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, prototypeTabOpen?: boolean, profilePath?: string | null, brainstormProfilePath?: string | null, testProfilePath?: string | null, prototypeProfilePath?: string | null, assignees?: string[], handoffAttachmentId?: string | null, handoffFrom?: string | null, handoffRequestFrom?: string | null, handoffRequestError?: string | null } }
/** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */ /** 某个会话 id 的开 tab 请求已处理完(开成、复用或被拒都算),webview 据此停掉行内 spinner。 */
| { type: 'session/tab-settled', sessionId: string } | { type: 'session/tab-settled', sessionId: string }
| { type: 'issue/pr-diff-summary-done', issueNumber: number } | { type: 'issue/pr-diff-summary-done', issueNumber: number }
+2
View File
@@ -95,6 +95,8 @@ export interface Issue {
* global setting; true/false = explicit override stored in state JSON. * global setting; true/false = explicit override stored in state JSON.
*/ */
autoReview?: boolean autoReview?: boolean
/** 最近一次审查是否通过。只影响审查列卡片上的小字,不改列。 */
reviewPassed?: boolean
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */ /** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
assignees?: string[] assignees?: string[]
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */ /** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */