diff --git a/vscode/src/panel/handlers/handoffRequest.test.ts b/vscode/src/panel/handlers/handoffRequest.test.ts index 12f42ea..81108a6 100644 --- a/vscode/src/panel/handlers/handoffRequest.test.ts +++ b/vscode/src/panel/handlers/handoffRequest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest, handoffRequestNotices } from './handoffRequest' +import { canAnswerHandoffRequest, canAskHandoff, canCancelHandoffRequest, handoffRequestNotices, handoffRequestSystemNotice } from './handoffRequest' const review = { source: 'gitea' as const, @@ -37,6 +37,34 @@ describe('移交申请', () => { .toEqual(['#435 没交出来:会话太大']) }) + it('系统通知只打给负责人,同一条申请不重复', () => { + expect(handoffRequestSystemNotice({ + issueNumber: 435, + me: 'chw', + assignees: ['chw'], + from: 'cruldra', + })).toBe('cruldra 想要 #435') + expect(handoffRequestSystemNotice({ + issueNumber: 435, + me: 'chw', + assignees: ['chw'], + from: 'cruldra', + seenFrom: 'cruldra', + })).toBeUndefined() + expect(handoffRequestSystemNotice({ + issueNumber: 435, + me: 'cruldra', + assignees: ['chw'], + from: 'cruldra', + })).toBeUndefined() + expect(handoffRequestSystemNotice({ + issueNumber: 435, + me: 'other', + assignees: ['chw'], + from: 'cruldra', + })).toBeUndefined() + }) + it('只有负责人能同意或拒绝', () => { const pending = { ...review, handoffRequestFrom: 'cruldra' } expect(canAnswerHandoffRequest(pending, 'chw')).toBe(true) diff --git a/vscode/src/panel/handlers/handoffRequest.ts b/vscode/src/panel/handlers/handoffRequest.ts index f2f4e2e..cde49a9 100644 --- a/vscode/src/panel/handlers/handoffRequest.ts +++ b/vscode/src/panel/handlers/handoffRequest.ts @@ -50,6 +50,22 @@ export function handoffRequestNotices(before: HandoffRequestFacts & { number: nu return notices } +/** 负责人没开着看板也能看到的那一句。同一申请人不重复。 */ +export function handoffRequestSystemNotice(opts: { + issueNumber: number + me?: string + assignees: string[] + from?: string + seenFrom?: string +}): string | undefined { + const from = opts.from?.trim() + if (!from || !opts.me || from === opts.me || from === opts.seenFrom) + return + if (!opts.assignees.includes(opts.me)) + return + return `${from} 想要 #${opts.issueNumber}` +} + export function canAnswerHandoffRequest(issue: HandoffRequestFacts, me: string | undefined): boolean { return !!me && openReview(issue) diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 6e5c8e1..4ec8fd8 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -28,6 +28,7 @@ import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../ import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson' import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { logger } from '../logging/logger' +import { handoffRequestSystemNotice } from '../panel/handlers/handoffRequest' import * as issueChat from '../panel/handlers/issueChat' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' @@ -68,6 +69,16 @@ const BODY_MARKERS = [ /** 本次从 body 里扫出来的 marker 值,按 state JSON 字段名索引。 */ type MarkerPatch = { [K in typeof BODY_MARKERS[number]['field']]?: string } +function handoffRequestFromOf(body: string): string { + try { + const obj = JSON.parse(body) as { handoffRequestFrom?: unknown } + return typeof obj.handoffRequestFrom === 'string' ? obj.handoffRequestFrom.trim() : '' + } + catch { + return '' + } +} + function parseBodyMarkers(body: string): MarkerPatch { const out: MarkerPatch = {} for (const { type, field, ext } of BODY_MARKERS) { @@ -100,6 +111,8 @@ class WebhookCoordinator { private eventSubscription?: { dispose: () => void } /** 同 issue 自动审查启动中不重入(opened 与 synchronize 常连发)。 */ private reviewInFlight = new Set() + /** 已经为这条移交申请通知过负责人。申请取消后清掉。 */ + private handoffRequestSeen = new Map() /** Called once from extension.ts activate(). Idempotent. */ init(ctx: ExtensionContext): void { @@ -1436,6 +1449,55 @@ class WebhookCoordinator { * first review and `injectIntoImplTerminal` appends the merge-to-main * suffix. */ + /** 申请移交时给负责人发系统通知。看板关着也发。 */ + private async notifyHandoffRequest(event: IssueCommentWebhookEvent): Promise { + const from = handoffRequestFromOf(event.commentBody) + if (!from) { + this.handoffRequestSeen.delete(event.issueNumber) + return + } + const seen = this.handoffRequestSeen.get(event.issueNumber) ?? '' + if (from === seen || !this.ctx) + return + const ws = workspace.workspaceFolders?.[0]?.uri.fsPath + if (!ws) + return + const remote = await detectRepo(ws) + if (!remote) + return + const token = await getToken(this.ctx, remote.host) + if (!token) + return + const me = await loginForToken(remote.host, token) + const payload = event.raw as { issue?: { assignees?: Array<{ login?: string } | null> | null } | null } | null + const assignees = (payload?.issue?.assignees ?? []).flatMap(a => (a?.login ? [a.login] : [])) + if (!me || assignees.length === 0) + return + const notice = handoffRequestSystemNotice({ + issueNumber: event.issueNumber, + me, + assignees, + from, + seenFrom: seen, + }) + this.handoffRequestSeen.set(event.issueNumber, from) + if (!notice) + return + execFile('osascript', [ + '-e', + `display notification ${JSON.stringify('打开看板同意或拒绝')} with title ${JSON.stringify(notice)}`, + ], (err) => { + if (err) { + logger.add({ + level: 'warn', + source: 'webhook', + message: `移交申请通知发送失败 #${event.issueNumber}`, + details: err.message, + }) + } + }) + } + private async handleIssueCommentCreated(event: IssueCommentWebhookEvent): Promise { if (event.action !== 'created') { logger.add({ @@ -1519,6 +1581,7 @@ class WebhookCoordinator { source: 'webhook', message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`, }) + await this.notifyHandoffRequest(event) await this.refreshIssueCard({ issueNumber: event.issueNumber, raw: event.raw,