Compare commits

..
2 Commits
6 changed files with 173 additions and 25 deletions

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
"publisher": "clurdra", "publisher": "clurdra",
"name": "superpowers-vscode-clurdra", "name": "superpowers-vscode-clurdra",
"displayName": "Superpowers-clurdra", "displayName": "Superpowers-clurdra",
"version": "0.2.207", "version": "0.2.208",
"packageManager": "pnpm@10.27.0", "packageManager": "pnpm@10.27.0",
"description": "Superpowers specs and plans Kanban explorer", "description": "Superpowers specs and plans Kanban explorer",
"author": "clurdra", "author": "clurdra",
+69
View File
@@ -53,3 +53,72 @@ export function systemCommentText(body: string): string | undefined {
return `更新了工单状态,放到${column}` return `更新了工单状态,放到${column}`
return '更新了工单状态' 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(/<!--[\s\S]*?-->/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')
// 自动链接 <http...> 与纯标签
.replace(/<https?:\/\/[^>]+>/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)}...`
}
@@ -4,14 +4,14 @@ import { buildSessionSubmitPayload } from './sessionSubmit'
describe('buildSessionSubmitPayload', () => { describe('buildSessionSubmitPayload', () => {
it('换行留在粘贴括号里,发送用的回车在括号外面', () => { it('换行留在粘贴括号里,发送用的回车在括号外面', () => {
const payload = buildSessionSubmitPayload('第一行\n第二行') const payload = buildSessionSubmitPayload('第一行\n第二行')
expect(payload.startsWith('\u001b[200~')).toBe(true) expect(payload.startsWith('\u001B[200~')).toBe(true)
expect(payload.endsWith('\u001b[201~\r')).toBe(true) expect(payload.endsWith('\u001B[201~\r')).toBe(true)
expect(payload).toContain('第一行\n第二行') expect(payload).toContain('第一行\n第二行')
}) })
it('正文里的转义字符会被去掉,避免提前结束粘贴', () => { it('正文里的转义字符会被去掉,避免提前结束粘贴', () => {
expect(buildSessionSubmitPayload('看到\u001b[201~就截断')).toBe( expect(buildSessionSubmitPayload('看到\u001B[201~就截断')).toBe(
'\u001b[200~看到[201~就截断\u001b[201~\r', '\u001B[200~看到[201~就截断\u001B[201~\r',
) )
}) })
}) })
+3 -3
View File
@@ -9,10 +9,10 @@
* 而且必须和正文在同一次写入里:分开写的话,长正文还没写完,回车可能插进正文中间。 * 而且必须和正文在同一次写入里:分开写的话,长正文还没写完,回车可能插进正文中间。
* 正文里的转义字符要去掉,否则会把粘贴提前截断。 * 正文里的转义字符要去掉,否则会把粘贴提前截断。
*/ */
const PASTE_START = '\u001b[200~' const PASTE_START = '\u001B[200~'
const PASTE_END = '\u001b[201~' const PASTE_END = '\u001B[201~'
export function buildSessionSubmitPayload(text: string): string { export function buildSessionSubmitPayload(text: string): string {
const body = text.replaceAll('\u001b', '') const body = text.replaceAll('\u001B', '')
return `${PASTE_START}${body}${PASTE_END}\r` return `${PASTE_START}${body}${PASTE_END}\r`
} }
+9 -17
View File
@@ -10,18 +10,19 @@
*/ */
import type { ExtensionContext } from 'vscode' import type { ExtensionContext } from 'vscode'
import type { KanbanWebviewPanel } from '../panel/KanbanPanel'
import type { KanbanScope } from '../panel/kanbanScope' import type { KanbanScope } from '../panel/kanbanScope'
import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server' import type { IssueCommentWebhookEvent, IssueWebhookEvent, PrWebhookEvent, WebhookEvent } from './server'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { promises as fsp } from 'node:fs' 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 { loginForToken } from '../auth/identity'
import { getToken } from '../auth/secrets' import { getToken } from '../auth/secrets'
import { renderReviewPrompt } from '../cc/prompts' import { renderReviewPrompt } from '../cc/prompts'
import { isMailboxIssue } from '../cc/sessionHandoff' import { isMailboxIssue } from '../cc/sessionHandoff'
import { detectRepo } from '../git/remote' import { detectRepo } from '../git/remote'
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api' 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 { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers' import { hasReviewMarker, parseClosingIssueNumber, stripReviewMarker } from '../gitea/markers'
import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson' import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
@@ -29,7 +30,6 @@ import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from
import { logger } from '../logging/logger' import { logger } from '../logging/logger'
import * as issueChat from '../panel/handlers/issueChat' import * as issueChat from '../panel/handlers/issueChat'
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals' import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
import { KanbanWebviewPanel } from '../panel/KanbanPanel'
import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope' import { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
import { annotateMissingSessionIds } from '../sessions/sessionFiles' import { annotateMissingSessionIds } from '../sessions/sessionFiles'
import { getSettings } from '../settings/store' import { getSettings } from '../settings/store'
@@ -1498,17 +1498,20 @@ class WebhookCoordinator {
return return
} }
// 判定发言人:如果发言人不是当前用户(不是 me),且不是插件状态机自动评论(systemCommentText(body) 为空,即真人评论): // 判定发言人:只有纯工单(event.prNumber === undefined)上的真人评论才提醒;
// PR 评论(代码审查、PR 讨论、Review 机器人等)绝对不发通知。
const isPureIssueComment = event.prNumber === undefined
const author = event.commentAuthor const author = event.commentAuthor
const me = await loginForToken(ctx.host, ctx.token) const me = await loginForToken(ctx.host, ctx.token)
const isSystemComment = !!systemCommentText(body) const isSystemComment = !!systemCommentText(body)
const isMine = !!(me && author === me) const isMine = !!(me && author === me)
if (author && !isMine && !isSystemComment) { if (isPureIssueComment && author && !isMine && !isSystemComment) {
const summary = cleanCommentSummary(body)
// 触发 macOS 系统原生右上角横幅通知 // 触发 macOS 系统原生右上角横幅通知
execFile('osascript', [ execFile('osascript', [
'-e', '-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) => { ], (err) => {
if (err) { if (err) {
logger.add({ 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)) { if (!hasReviewMarker(body)) {
+87
View File
@@ -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 = '<!-- spx:review=1 -->这是一条评论'
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('<!-- spx:review=1 -->评审意见')).toBe('评审意见')
expect(systemCommentText('<!-- spx:review=1 -->')).toBe('提交了审查')
})
it('普通文本不是系统评论', () => {
expect(systemCommentText('这是一条普通的讨论')).toBeUndefined()
})
})