diff --git a/vscode/src/gitea/issueComment.ts b/vscode/src/gitea/issueComment.ts index ab84833..9be9312 100644 --- a/vscode/src/gitea/issueComment.ts +++ b/vscode/src/gitea/issueComment.ts @@ -53,3 +53,72 @@ export function systemCommentText(body: string): string | undefined { return `更新了工单状态,放到${column}` return '更新了工单状态' } + +/** + * 清洗 Markdown 评论内容并截断为单行纯文本摘要: + * 1. 移除 HTML 注释与代码块 + * 2. 移除引用行(> 开头)、表格分割线行、水平分割线 + * 3. 移除/转换行内 Markdown(链接、图片、行内代码、粗斜体、删除线、表格符号、标题与列表标记等) + * 4. 坍缩为空格单行,最多保留 maxLength 个字符,超出追加 '...' + */ +export function cleanCommentSummary(body: string, maxLength = 60): string { + if (!body) + return '' + + // 移除 HTML 注释和代码块 + let text = body + .replace(//g, '') + .replace(/```[\s\S]*?```/g, '') + + const rawLines = text.split(/\r?\n/) + // 逐行剔除引用行、表格分割线、水平分割线 + let lines = rawLines.filter((line) => { + const trimmed = line.trim() + if (!trimmed) + return false + if (trimmed.startsWith('>')) + return false + if (/^[-: |]+$/.test(trimmed) && trimmed.includes('-')) + return false + if (/^[-*_]{3,}$/.test(trimmed)) + return false + return true + }) + + // 若全是引用行,回退保留去掉 > 后的行 + if (lines.length === 0) { + lines = rawLines + .map(line => line.trim().replace(/^>+\s*/, '')) + .filter(Boolean) + } + + text = lines.join(' ') + // 移除图片 ![alt](url) + .replace(/!\[.*?\]\(.*?\)/g, '') + // 链接 [text](url) -> text + .replace(/\[(.*?)\]\(.*?\)/g, '$1') + // 自动链接 与纯标签 + .replace(/]+>/g, '') + .replace(/<[^>]+>/g, '') + // 行内代码 `code` -> code + .replace(/`([^`]+)`/g, '$1') + // 粗体、斜体、删除线 + .replace(/(\*\*|__)(.*?)\1/g, '$2') + .replace(/(\*|_)(.*?)\1/g, '$2') + .replace(/~~(.*?)~~/g, '$1') + // 任务列表 [- [x] / - [ ]] + .replace(/\[[ x]\]\s+/gi, '') + // 标题、无序列表、有序列表前缀 + .replace(/(^|\s)(?:#{1,6}|[*+-])\s+/g, '$1') + .replace(/(^|\s)\d+\.\s+/g, '$1') + // 表格竖线符号 + .replace(/\|/g, ' ') + + // 坍缩连续空白字符为单空格 + const singleLine = text.replace(/\s+/g, ' ').trim() + + if (singleLine.length <= maxLength) + return singleLine + + return `${singleLine.slice(0, maxLength)}...` +} diff --git a/vscode/src/panel/handlers/sessionSubmit.test.ts b/vscode/src/panel/handlers/sessionSubmit.test.ts index 1bde9a8..6e75062 100644 --- a/vscode/src/panel/handlers/sessionSubmit.test.ts +++ b/vscode/src/panel/handlers/sessionSubmit.test.ts @@ -4,14 +4,14 @@ import { buildSessionSubmitPayload } from './sessionSubmit' describe('buildSessionSubmitPayload', () => { it('换行留在粘贴括号里,发送用的回车在括号外面', () => { const payload = buildSessionSubmitPayload('第一行\n第二行') - expect(payload.startsWith('\u001b[200~')).toBe(true) - expect(payload.endsWith('\u001b[201~\r')).toBe(true) + expect(payload.startsWith('\u001B[200~')).toBe(true) + expect(payload.endsWith('\u001B[201~\r')).toBe(true) expect(payload).toContain('第一行\n第二行') }) it('正文里的转义字符会被去掉,避免提前结束粘贴', () => { - expect(buildSessionSubmitPayload('看到\u001b[201~就截断')).toBe( - '\u001b[200~看到[201~就截断\u001b[201~\r', + expect(buildSessionSubmitPayload('看到\u001B[201~就截断')).toBe( + '\u001B[200~看到[201~就截断\u001B[201~\r', ) }) }) diff --git a/vscode/src/panel/handlers/sessionSubmit.ts b/vscode/src/panel/handlers/sessionSubmit.ts index 94bb3e3..0ad18e5 100644 --- a/vscode/src/panel/handlers/sessionSubmit.ts +++ b/vscode/src/panel/handlers/sessionSubmit.ts @@ -9,10 +9,10 @@ * 而且必须和正文在同一次写入里:分开写的话,长正文还没写完,回车可能插进正文中间。 * 正文里的转义字符要去掉,否则会把粘贴提前截断。 */ -const PASTE_START = '\u001b[200~' -const PASTE_END = '\u001b[201~' +const PASTE_START = '\u001B[200~' +const PASTE_END = '\u001B[201~' export function buildSessionSubmitPayload(text: string): string { - const body = text.replaceAll('\u001b', '') + const body = text.replaceAll('\u001B', '') return `${PASTE_START}${body}${PASTE_END}\r` } diff --git a/vscode/src/webhook/coordinator.ts b/vscode/src/webhook/coordinator.ts index 6b92165..83297c7 100644 --- a/vscode/src/webhook/coordinator.ts +++ b/vscode/src/webhook/coordinator.ts @@ -10,18 +10,19 @@ */ import type { ExtensionContext } from 'vscode' +import type { KanbanWebviewPanel } from '../panel/KanbanPanel' import type { KanbanScope } from '../panel/kanbanScope' import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server' import { execFile } from 'node:child_process' import { promises as fsp } from 'node:fs' -import { env, Uri, ViewColumn, window, workspace } from 'vscode' +import { env, Uri, window, workspace } from 'vscode' import { loginForToken } from '../auth/identity' import { getToken } from '../auth/secrets' import { renderReviewPrompt } from '../cc/prompts' import { isMailboxIssue } from '../cc/sessionHandoff' import { detectRepo } from '../git/remote' import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api' -import { systemCommentText } from '../gitea/issueComment' +import { cleanCommentSummary, 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' @@ -29,7 +30,6 @@ import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from import { logger } from '../logging/logger' import * as issueChat from '../panel/handlers/issueChat' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' -import { KanbanWebviewPanel } from '../panel/KanbanPanel' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { getSettings } from '../settings/store' @@ -1498,17 +1498,20 @@ class WebhookCoordinator { return } - // 判定发言人:如果发言人不是当前用户(不是 me),且不是插件状态机自动评论(systemCommentText(body) 为空,即真人评论): + // 判定发言人:只有纯工单(event.prNumber === undefined)上的真人评论才提醒; + // PR 评论(代码审查、PR 讨论、Review 机器人等)绝对不发通知。 + const isPureIssueComment = event.prNumber === undefined const author = event.commentAuthor const me = await loginForToken(ctx.host, ctx.token) const isSystemComment = !!systemCommentText(body) const isMine = !!(me && author === me) - if (author && !isMine && !isSystemComment) { + if (isPureIssueComment && author && !isMine && !isSystemComment) { + const summary = cleanCommentSummary(body) // 触发 macOS 系统原生右上角横幅通知 execFile('osascript', [ '-e', - `display notification ${JSON.stringify(body)} with title ${JSON.stringify(`工单 #${realIssueNumber} 新讨论`)} subtitle ${JSON.stringify(author)}`, + `display notification ${JSON.stringify(summary)} with title ${JSON.stringify(`工单 #${realIssueNumber} 新讨论`)} subtitle ${JSON.stringify(author)}`, ], (err) => { if (err) { logger.add({ @@ -1519,17 +1522,6 @@ class WebhookCoordinator { }) } }) - - // 同时在 VS Code 内部弹出信息框 - void window.showInformationMessage(`[#${realIssueNumber}] ${author}: ${body}`, '查看').then((action) => { - if (action === '查看') { - const panel = this.activePanel ?? (this.ctx ? KanbanWebviewPanel.createOrShow(this.ctx) : undefined) - if (panel) { - panel.reveal(ViewColumn.Active) - panel.selectIssueAndTab(realIssueNumber, 'chat') - } - } - }) } if (!hasReviewMarker(body)) { diff --git a/vscode/test/gitea/issueComment.test.ts b/vscode/test/gitea/issueComment.test.ts new file mode 100644 index 0000000..cbfc5f2 --- /dev/null +++ b/vscode/test/gitea/issueComment.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest' +import { cleanCommentSummary, systemCommentText } from '../../src/gitea/issueComment' + +describe('cleanCommentSummary', () => { + it('保留普通单行纯文本', () => { + expect(cleanCommentSummary('好的我来看下')).toBe('好的我来看下') + }) + + it('空文本返回空字符串', () => { + expect(cleanCommentSummary('')).toBe('') + }) + + it('去除 Markdown 引用(> 开头的行)', () => { + const raw = `> 上一条评论的内容 +> 引用多行 + +好的收到,马上修改` + expect(cleanCommentSummary(raw)).toBe('好的收到,马上修改') + }) + + it('当全部内容都是引用时,回退提取去掉 > 后的文字', () => { + const raw = `> 收到回复` + expect(cleanCommentSummary(raw)).toBe('收到回复') + }) + + it('去除围栏代码块,保留正文', () => { + const raw = `问题定位如下: +\`\`\`ts +const x = 1 +console.log(x) +\`\`\` +请检查修复。` + expect(cleanCommentSummary(raw)).toBe('问题定位如下: 请检查修复。') + }) + + it('清洗行内代码为纯文本', () => { + expect(cleanCommentSummary('请检查 `coordinator.ts` 中的参数配置')).toBe('请检查 coordinator.ts 中的参数配置') + }) + + it('清洗 Markdown 链接与图片', () => { + const raw = '![效果图](https://img.example.com/pic.png) 请参考 [规范文档](https://example.com/doc) 进行修改' + expect(cleanCommentSummary(raw)).toBe('请参考 规范文档 进行修改') + }) + + it('去除 Markdown 表格线与表格符号', () => { + const raw = `审查结论如下: +| 模块 | 结果 | +| --- | --- | +| 登录 | 通过 | +| 支付 | 待测 |` + expect(cleanCommentSummary(raw)).toBe('审查结论如下: 模块 结果 登录 通过 支付 待测') + }) + + it('去除粗体、斜体、删除线、标题符号', () => { + const raw = '### **重要通知**:*今天* ~~不~~ 发布' + expect(cleanCommentSummary(raw)).toBe('重要通知:今天 不 发布') + }) + + it('去除任务列表标记与列表符号', () => { + const raw = `- [x] 第一步\n- [ ] 第二步` + expect(cleanCommentSummary(raw)).toBe('第一步 第二步') + }) + + it('去除 HTML 注释', () => { + const raw = '这是一条评论' + expect(cleanCommentSummary(raw)).toBe('这是一条评论') + }) + + it('单行截断:不超过 60 字符不截断,超过 60 字符截断并追加 ...', () => { + const exact60 = 'a'.repeat(60) + expect(cleanCommentSummary(exact60)).toBe(exact60) + + const over60 = 'b'.repeat(70) + expect(cleanCommentSummary(over60)).toBe(`${'b'.repeat(60)}...`) + }) +}) + +describe('systemCommentText', () => { + it('识别 review 评论', () => { + expect(systemCommentText('评审意见')).toBe('评审意见') + expect(systemCommentText('')).toBe('提交了审查') + }) + + it('普通文本不是系统评论', () => { + expect(systemCommentText('这是一条普通的讨论')).toBeUndefined() + }) +})