diff --git a/vscode/src/gitea/issueComment.ts b/vscode/src/gitea/issueComment.ts index 9504bb3..ab84833 100644 --- a/vscode/src/gitea/issueComment.ts +++ b/vscode/src/gitea/issueComment.ts @@ -1,3 +1,5 @@ +import { hasReviewMarker, stripReviewMarker } from './markers' + const COLUMN_LABEL: Record = { 'todo': '待办', 'in-progress': '进行中', @@ -26,8 +28,8 @@ const STATE_KEYS = new Set([ /** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */ export function systemCommentText(body: string): string | undefined { const trimmed = body.trim() - if (//i.test(trimmed)) { - const text = trimmed.replace(//gi, '').trim() + if (hasReviewMarker(trimmed)) { + const text = stripReviewMarker(trimmed) return text.length > 0 ? text : '提交了审查' } if (!trimmed.startsWith('{')) diff --git a/vscode/src/gitea/markers.test.ts b/vscode/src/gitea/markers.test.ts new file mode 100644 index 0000000..d4ab6ca --- /dev/null +++ b/vscode/src/gitea/markers.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest' +import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from './markers' + +const REAL_REVIEW = ` +## 审查意见 + +- **coordinator.ts:1452** 正则多写了反斜杠 +- 建议补单测 + +\`\`\`ts +const x = 1 +\`\`\`` + +describe('审查标记', () => { + it('真实审查评论:识别为审查,去标记后正文完整', () => { + expect(hasReviewMarker(REAL_REVIEW)).toBe(true) + expect(stripReviewMarker(REAL_REVIEW)).toBe(REAL_REVIEW.slice('\n'.length)) + }) + + it('普通评论不是审查', () => { + expect(hasReviewMarker('这个方案可以')).toBe(false) + expect(hasReviewMarker('提到了 spx:review=1 但不是注释')).toBe(false) + }) + + it('大小写和注释内空白都放宽', () => { + expect(hasReviewMarker('\n正文')).toBe(true) + expect(hasReviewMarker('')).toBe(true) + }) + + it('反复调用结果稳定', () => { + for (let i = 0; i < 3; i++) { + expect(hasReviewMarker(REAL_REVIEW)).toBe(true) + expect(stripReviewMarker(REAL_REVIEW)).toMatch(/^## 审查意见/) + } + }) + + it('多个标记全部去掉,只剩标记时为空串', () => { + expect(stripReviewMarker('\n a b')).toBe('a b') + expect(stripReviewMarker('\n')).toBe('') + }) +}) + +describe('parseClosingIssueNumber', () => { + it('多行 PR body 最后一行 Closes #462', () => { + const body = '## 改动\n\n- 修了正则\n- 加了测试\n\nCloses #462' + expect(parseClosingIssueNumber(body)).toBe(462) + }) + + it('其它关键字与大小写', () => { + expect(parseClosingIssueNumber('fixes #12')).toBe(12) + expect(parseClosingIssueNumber('FiXeS #7')).toBe(7) + expect(parseClosingIssueNumber('RESOLVE #3')).toBe(3) + expect(parseClosingIssueNumber('close\t#5')).toBe(5) + }) + + it('没有关键字返回 undefined', () => { + expect(parseClosingIssueNumber('关联 #462')).toBeUndefined() + expect(parseClosingIssueNumber('')).toBeUndefined() + }) + + it('不误匹配:# 前无空白、关键字粘在别的词后面', () => { + expect(parseClosingIssueNumber('closes#1')).toBeUndefined() + expect(parseClosingIssueNumber('prefixcloses #1')).toBeUndefined() + }) +}) diff --git a/vscode/src/gitea/markers.ts b/vscode/src/gitea/markers.ts new file mode 100644 index 0000000..0d379ac --- /dev/null +++ b/vscode/src/gitea/markers.ts @@ -0,0 +1,27 @@ +/** + * 评论 / PR 正文里约定标记的唯一解析入口。 + * + * - 审查标记:审查会话发到 PR 上的评论第一行写 ``, + * webhook 靠它识别"这是审查意见,要转给实施会话"。 + * - 关闭关键字:PR body 里的 `Closes #N` 等,用来从 PR 反查工单号。 + */ + +const REVIEW_MARKER = //i +// 带 g 的正则只给 replace 用:replace 每次从头扫,不受 lastIndex 残留影响。 +const REVIEW_MARKER_WITH_TRAILING_SPACE = new RegExp(`${REVIEW_MARKER.source}\\s*`, 'gi') +const CLOSING_KEYWORD = /\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)/i + +export function hasReviewMarker(body: string): boolean { + return REVIEW_MARKER.test(body) +} + +/** 去掉所有审查标记及其后空白,返回 trim 后的正文。 */ +export function stripReviewMarker(body: string): string { + return body.replace(REVIEW_MARKER_WITH_TRAILING_SPACE, '').trim() +} + +/** 从 PR body 取第一个关闭关键字指向的工单号。 */ +export function parseClosingIssueNumber(body: string): number | undefined { + const m = body.match(CLOSING_KEYWORD) + return m ? Number.parseInt(m[1], 10) : undefined +} diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 1237074..6b92165 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -23,6 +23,7 @@ import { detectRepo } from '../git/remote' import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api' import { systemCommentText } from '../gitea/issueComment' import { loadIssues, loadSingleIssue } from '../gitea/issueLoader' +import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers' import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson' import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState' import { logger } from '../logging/logger' @@ -357,9 +358,8 @@ class WebhookCoordinator { return event.issueNumber const body = event.body || '' - const m = body.match(/\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)/i) - if (m) { - const n = Number.parseInt(m[1], 10) + const n = parseClosingIssueNumber(body) + if (n !== undefined) { logger.add({ level: 'info', source: 'webhook', @@ -1449,9 +1449,9 @@ class WebhookCoordinator { repo: ctx.repo, index: event.prNumber, }) - const m = (pr.body ?? '').match(/\\b(?:closes|fixes|resolves|close|fix|resolve)\\s+#(\\d+)/i) - if (m) { - realIssueNumber = Number.parseInt(m[1], 10) + const closing = parseClosingIssueNumber(pr.body ?? '') + if (closing !== undefined) { + realIssueNumber = closing logger.add({ level: 'info', source: 'webhook', @@ -1532,7 +1532,7 @@ class WebhookCoordinator { }) } - if (!//i.test(body)) { + if (!hasReviewMarker(body)) { logger.add({ level: 'info', source: 'webhook', @@ -1540,7 +1540,7 @@ class WebhookCoordinator { }) return } - const text = body.replace(/\\s*/i, '').trim() + const text = stripReviewMarker(body) if (!text) { logger.add({ level: 'warn', @@ -1570,7 +1570,7 @@ class WebhookCoordinator { repo: ctx.repo, index: commentsIndex, }) - reviewCount = comments.filter(c => //i.test(c.body ?? '')).length + reviewCount = comments.filter(c => hasReviewMarker(c.body ?? '')).length } catch (err) { const message = err instanceof Error ? err.message : String(err)