✨ feat(vscode): 支持讨论评论引用与 macOS 系统级新评论桌面提醒

This commit is contained in:
cruldra committed 2026-09-29 04:23:57 +08:00
1 parent 540c212bad
commit a2c3c1f919
13 files changed
+638 -302

No files matched your search

+80 -49
View File
@@ -10,23 +10,25 @@
*/
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, window, workspace } from 'vscode'
import { env, Uri, ViewColumn, 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 { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
import { hasStateColumn, mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
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'
@@ -1423,49 +1425,7 @@ class WebhookCoordinator {
message: `issue_comment body diag #${event.issueNumber} len=${body.length}`,
details: body.slice(0, 200),
})
if (this.activePanel && typeof event.issueNumber === 'number') {
void issueChat.handleIssueComments(this.activePanel, event.issueNumber)
}
// 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡
// 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张
// 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。
if (event.prNumber === undefined && hasStateColumn(body)) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`,
})
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: '列变化',
})
return
}
if (!/<!--\s*spx:review=1\s*-->/i.test(body)) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 无 spx:review 标识,忽略 #${event.issueNumber}`,
})
return
}
const text = body.replace(/<!--\s*spx:review=1\s*-->\s*/i, '').trim()
if (!text) {
logger.add({
level: 'warn',
source: 'webhook',
message: `审查评论正文为空 #${event.issueNumber}`,
})
return
}
// resolveRepoContext is keyed by pendingHooks/workspace, not by issue
// number — it just needs *some* number to log against. We pass the
// gitea-reported number here (which is the PR number when this comment
// is on a PR) and reverse-resolve to the real issue number below.
const ctx = await this.resolveRepoContext(event.issueNumber)
if (!ctx) {
logger.add({
@@ -1489,7 +1449,7 @@ class WebhookCoordinator {
repo: ctx.repo,
index: event.prNumber,
})
const m = (pr.body ?? '').match(/\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)/i)
const m = (pr.body ?? '').match(/\\b(?:closes|fixes|resolves|close|fix|resolve)\\s+#(\\d+)/i)
if (m) {
realIssueNumber = Number.parseInt(m[1], 10)
logger.add({
@@ -1497,9 +1457,6 @@ class WebhookCoordinator {
source: 'webhook',
message: `issue_comment 评论在 PR #${event.prNumber},反查到工单 #${realIssueNumber}`,
})
if (this.activePanel) {
void issueChat.handleIssueComments(this.activePanel, realIssueNumber)
}
}
else {
logger.add({
@@ -1519,6 +1476,80 @@ class WebhookCoordinator {
}
}
// 若看板面板正打开且当前选中的正是该工单,自动刷新讨论列表
if (this.activePanel) {
void issueChat.handleIssueComments(this.activePanel, realIssueNumber)
}
// 看板的列就存在工单的 state JSON 评论里,每次改列都新发一条。谁把卡
// 拖到哪一列,其他人的面板靠这条评论跟着动——解析出 column 就重拉这张
// 卡,不限于「完成」。PR 上的评论不可能是 state 评论,直接排除。
if (event.prNumber === undefined && hasStateColumn(body)) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 是 state 评论,刷新 #${event.issueNumber} 的卡片`,
})
await this.refreshIssueCard({
issueNumber: event.issueNumber,
raw: event.raw,
reason: '列变化',
})
return
}
// 判定发言人:如果发言人不是当前用户(不是 me),且不是插件状态机自动评论(systemCommentText(body) 为空,即真人评论):
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) {
// 触发 macOS 系统原生右上角横幅通知
execFile('osascript', [
'-e',
`display notification ${JSON.stringify(body)} with title ${JSON.stringify(`工单 #${realIssueNumber} 新讨论`)} subtitle ${JSON.stringify(author)}`,
], (err) => {
if (err) {
logger.add({
level: 'warn',
source: 'webhook',
message: `macOS 原生通知发送失败 #${realIssueNumber}`,
details: err.message,
})
}
})
// 同时在 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 (!/<!--\\s+px:review=1\\s*-->/i.test(body)) {
logger.add({
level: 'info',
source: 'webhook',
message: `issue_comment 无 spx:review 标识,忽略 #${event.issueNumber}`,
})
return
}
const text = body.replace(/<!--\\s+px:review=1\\s*-->\\s*/i, '').trim()
if (!text) {
logger.add({
level: 'warn',
source: 'webhook',
message: `审查评论正文为空 #${event.issueNumber}`,
})
return
}
if (await this.resolveOwnership(realIssueNumber, ctx) === 'observe') {
// 审查反馈只注入责任人机器的实施终端;其他机器上本来也找不到
// 该终端,提前拦截省掉后面的评论计数 API 往返。
@@ -1539,7 +1570,7 @@ class WebhookCoordinator {
repo: ctx.repo,
index: commentsIndex,
})
reviewCount = comments.filter(c => /<!--\s*spx:review=1\s*-->/i.test(c.body ?? '')).length
reviewCount = comments.filter(c => /<!--\\s+px:review=1\\s*-->/i.test(c.body ?? '')).length
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
+67
View File
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import { parseEvent } from './eventParser'
describe('parseEvent', () => {
it('parses issue_comment event and extracts commentAuthor from comment.user.login', () => {
const raw = {
action: 'created',
issue: { number: 42 },
comment: {
body: 'hello world',
html_url: 'https://gitea.example.com/repo/issues/42#issuecomment-1',
user: { login: 'alice' },
},
}
const event = parseEvent('issue_comment', undefined, raw)
expect(event).toEqual({
kind: 'issue_comment',
action: 'created',
issueNumber: 42,
prNumber: undefined,
commentAuthor: 'alice',
commentBody: 'hello world',
commentHtmlUrl: 'https://gitea.example.com/repo/issues/42#issuecomment-1',
raw,
})
})
it('falls back to sender.login when comment.user is missing', () => {
const raw = {
action: 'created',
issue: { number: 42 },
comment: {
body: 'from sender',
html_url: 'https://gitea.example.com/repo/issues/42#issuecomment-2',
},
sender: { login: 'bob' },
}
const event = parseEvent('issue_comment', undefined, raw)
expect(event).toMatchObject({
kind: 'issue_comment',
action: 'created',
commentAuthor: 'bob',
commentBody: 'from sender',
})
})
it('parses pull_request comment correctly', () => {
const raw = {
action: 'created',
issue: { number: 10, pull_request: {} },
comment: {
body: 'review comment',
html_url: 'https://gitea.example.com/repo/pulls/10#issuecomment-3',
user: { login: 'reviewer' },
},
}
const event = parseEvent('issue_comment', undefined, raw)
expect(event).toMatchObject({
kind: 'issue_comment',
action: 'created',
issueNumber: 10,
prNumber: 10,
commentAuthor: 'reviewer',
commentBody: 'review comment',
})
})
})
+177
View File
@@ -0,0 +1,177 @@
export interface PrWebhookEvent {
kind: 'pr'
issueNumber: number | undefined
action: string
pr: string
branch: string
htmlUrl: string
title: string
body: string
raw: unknown
}
export interface IssueWebhookEvent {
kind: 'issue'
action: string
issueNumber: number
title: string
body: string
htmlUrl: string
raw: unknown
}
export interface IssueCommentWebhookEvent {
kind: 'issue_comment'
action: string
issueNumber: number
prNumber: number | undefined
commentBody: string
commentAuthor: string
commentHtmlUrl: string
raw: unknown
}
export interface PushWebhookEvent {
kind: 'push'
branch: string
raw: unknown
}
export type WebhookEvent = PrWebhookEvent | IssueWebhookEvent | IssueCommentWebhookEvent | PushWebhookEvent
export function parseEvent(
eventHeader: string,
issueNumber: number | undefined,
raw: unknown,
): WebhookEvent | null {
if (!raw || typeof raw !== 'object')
return null
const obj = raw as {
action?: unknown
pull_request?: unknown
issue?: unknown
comment?: unknown
ref?: unknown
sender?: unknown
}
if (eventHeader === 'push') {
// gitea push 事件无 `action`,顶层带 `ref: "refs/heads/<branch>"`。
if (typeof obj.ref !== 'string')
return null
const branch = obj.ref.replace(/^refs\/heads\//, '')
if (!branch)
return null
return { kind: 'push', branch, raw }
}
if (typeof obj.action !== 'string')
return null
if (eventHeader === 'issue_comment') {
const issue = obj.issue
const comment = obj.comment
if (!issue || typeof issue !== 'object')
return null
if (!comment || typeof comment !== 'object')
return null
const issueObj = issue as {
number?: unknown
pull_request?: unknown
}
const commentObj = comment as {
body?: unknown
html_url?: unknown
user?: unknown
}
const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number)
if (!Number.isFinite(num))
return null
// gitea sets issue.pull_request (non-null object) when the issue is a PR.
const prNumber = issueObj.pull_request && typeof issueObj.pull_request === 'object'
? num
: undefined
const commentBody = typeof commentObj.body === 'string' ? commentObj.body : ''
const commentHtmlUrl = typeof commentObj.html_url === 'string' ? commentObj.html_url : ''
const commentUser = commentObj.user as { login?: unknown, username?: unknown } | undefined
const sender = obj.sender as { login?: unknown, username?: unknown } | undefined
const commentAuthor = (typeof commentUser?.login === 'string' && commentUser.login)
|| (typeof commentUser?.username === 'string' && commentUser.username)
|| (typeof sender?.login === 'string' && sender.login)
|| (typeof sender?.username === 'string' && sender.username)
|| ''
return {
kind: 'issue_comment',
action: obj.action,
issueNumber: num,
prNumber,
commentAuthor,
commentBody,
commentHtmlUrl,
raw,
}
}
if (eventHeader === 'issues') {
const issue = obj.issue
if (!issue || typeof issue !== 'object')
return null
const issueObj = issue as {
number?: unknown
html_url?: unknown
title?: unknown
body?: unknown
}
const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number)
if (!Number.isFinite(num))
return null
const htmlUrl = typeof issueObj.html_url === 'string' ? issueObj.html_url : ''
const title = typeof issueObj.title === 'string' ? issueObj.title : ''
const body = typeof issueObj.body === 'string' ? issueObj.body : ''
return {
kind: 'issue',
action: obj.action,
issueNumber: num,
title,
body,
htmlUrl,
raw,
}
}
// Default path: pull_request payload (preserves legacy behaviour for any
// event header value, since gitea historically may not set the header).
const pr = obj.pull_request
if (!pr || typeof pr !== 'object')
return null
const prObj = pr as {
number?: unknown
html_url?: unknown
head?: unknown
title?: unknown
body?: unknown
}
const num = typeof prObj.number === 'number' ? prObj.number : Number(prObj.number)
if (!Number.isFinite(num))
return null
const htmlUrl = typeof prObj.html_url === 'string' ? prObj.html_url : ''
const head = prObj.head as { ref?: unknown } | undefined
const branch = head && typeof head.ref === 'string' ? head.ref : ''
const title = typeof prObj.title === 'string' ? prObj.title : ''
const body = typeof prObj.body === 'string' ? prObj.body : ''
if (!branch || !htmlUrl)
return null
return {
kind: 'pr',
issueNumber,
action: obj.action,
pr: String(num),
branch,
htmlUrl,
title,
body,
raw,
}
}
+11 -213
View File
@@ -24,91 +24,23 @@
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import type { Event } from 'vscode'
import type { WebhookEvent } from './eventParser'
import { Buffer } from 'node:buffer'
import { createHmac, timingSafeEqual } from 'node:crypto'
import { createServer } from 'node:http'
import { EventEmitter } from 'vscode'
import { logger } from '../logging/logger'
import { parseEvent } from './eventParser'
export type WebhookEvent = PrWebhookEvent | IssueWebhookEvent | IssueCommentWebhookEvent | PushWebhookEvent
export interface PrWebhookEvent {
/** Discriminator: this event came from a `pull_request` payload. */
kind: 'pr'
/**
* Issue number from the legacy `/webhook/:n` path. `undefined` when the
* canonical `/webhook` route was used — the coordinator then resolves the
* issue via PR body / branch heuristics.
*/
issueNumber: number | undefined
/**
* The `action` field from the `pull_request` payload (e.g. `opened`,
* `reopened`, `synchronize`, `closed`, `edited`). The server fires this
* event on every `pull_request` action whose payload parses correctly;
* downstream code dispatches on `action` to decide what to do.
*/
action: string
/** PR number as string. */
pr: string
/** Head branch ref. */
branch: string
/** Browser URL to the PR. */
htmlUrl: string
/** PR title (best-effort; empty string if missing). */
title: string
/** PR body / description — used by the coordinator for `Closes #N` parsing. */
body: string
/** Raw JSON payload, in case downstream needs more fields. */
raw: unknown
}
export interface IssueWebhookEvent {
/** Discriminator: this event came from an `issues` payload. */
kind: 'issue'
/**
* The `action` field from the `issues` payload (`opened` / `closed` /
* `edited` / `reopened` / `deleted`...). The server fires for every
* action; the coordinator decides which ones it actually handles.
*/
action: string
/** Issue number. */
issueNumber: number
/** Issue title (best-effort). */
title: string
/** Issue body — used to extract the `<!-- spx:nonce=... -->` marker. */
body: string
/** Browser URL to the issue. */
htmlUrl: string
/** Raw JSON payload. */
raw: unknown
}
export interface IssueCommentWebhookEvent {
/** Discriminator: this event came from an `issue_comment` payload. */
kind: 'issue_comment'
/** Action: 'created' / 'edited' / 'deleted'. */
action: string
/**
* Issue number — gitea uses the same number for issues and PRs. If the
* issue is actually a PR, {@link prNumber} is also set.
*/
issueNumber: number
/** PR number when the comment is on a PR; otherwise undefined. */
prNumber: number | undefined
/** Comment body — caller scans for the `<!-- spx:review=1 -->` marker. */
commentBody: string
/** Browser URL to the comment. */
commentHtmlUrl: string
/** Raw JSON payload. */
raw: unknown
}
export interface PushWebhookEvent {
kind: 'push'
/** 被推送的分支名(从 ref `refs/heads/<branch>` 提取)。 */
branch: string
raw: unknown
}
export type {
IssueCommentWebhookEvent,
IssueWebhookEvent,
PrWebhookEvent,
PushWebhookEvent,
WebhookEvent,
} from './eventParser'
export { parseEvent } from './eventParser'
export class WebhookServer {
private server?: Server
@@ -345,137 +277,3 @@ export class WebhookServer {
}
}
}
/**
* Validate and normalise a gitea pull_request webhook payload. Returns null
* for missing fields or non-actionable actions; the HTTP handler still
* responds 200 in both cases. `issueNumber` is forwarded as-is — it's
* `undefined` when the request came in on the canonical `/webhook` route
* (the coordinator resolves it from `body` / `branch` in that case).
*/
function parseEvent(
eventHeader: string,
issueNumber: number | undefined,
raw: unknown,
): WebhookEvent | null {
if (!raw || typeof raw !== 'object')
return null
const obj = raw as {
action?: unknown
pull_request?: unknown
issue?: unknown
comment?: unknown
ref?: unknown
}
if (eventHeader === 'push') {
// gitea push 事件无 `action`,顶层带 `ref: "refs/heads/<branch>"`。
if (typeof obj.ref !== 'string')
return null
const branch = obj.ref.replace(/^refs\/heads\//, '')
if (!branch)
return null
return { kind: 'push', branch, raw }
}
if (typeof obj.action !== 'string')
return null
if (eventHeader === 'issue_comment') {
const issue = obj.issue
const comment = obj.comment
if (!issue || typeof issue !== 'object')
return null
if (!comment || typeof comment !== 'object')
return null
const issueObj = issue as {
number?: unknown
pull_request?: unknown
}
const commentObj = comment as {
body?: unknown
html_url?: unknown
}
const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number)
if (!Number.isFinite(num))
return null
// gitea sets issue.pull_request (non-null object) when the issue is a PR.
const prNumber = issueObj.pull_request && typeof issueObj.pull_request === 'object'
? num
: undefined
const commentBody = typeof commentObj.body === 'string' ? commentObj.body : ''
const commentHtmlUrl = typeof commentObj.html_url === 'string' ? commentObj.html_url : ''
return {
kind: 'issue_comment',
action: obj.action,
issueNumber: num,
prNumber,
commentBody,
commentHtmlUrl,
raw,
}
}
if (eventHeader === 'issues') {
const issue = obj.issue
if (!issue || typeof issue !== 'object')
return null
const issueObj = issue as {
number?: unknown
html_url?: unknown
title?: unknown
body?: unknown
}
const num = typeof issueObj.number === 'number' ? issueObj.number : Number(issueObj.number)
if (!Number.isFinite(num))
return null
const htmlUrl = typeof issueObj.html_url === 'string' ? issueObj.html_url : ''
const title = typeof issueObj.title === 'string' ? issueObj.title : ''
const body = typeof issueObj.body === 'string' ? issueObj.body : ''
return {
kind: 'issue',
action: obj.action,
issueNumber: num,
title,
body,
htmlUrl,
raw,
}
}
// Default path: pull_request payload (preserves legacy behaviour for any
// event header value, since gitea historically may not set the header).
const pr = obj.pull_request
if (!pr || typeof pr !== 'object')
return null
const prObj = pr as {
number?: unknown
html_url?: unknown
head?: unknown
title?: unknown
body?: unknown
}
const num = typeof prObj.number === 'number' ? prObj.number : Number(prObj.number)
if (!Number.isFinite(num))
return null
const htmlUrl = typeof prObj.html_url === 'string' ? prObj.html_url : ''
const head = prObj.head as { ref?: unknown } | undefined
const branch = head && typeof head.ref === 'string' ? head.ref : ''
const title = typeof prObj.title === 'string' ? prObj.title : ''
const body = typeof prObj.body === 'string' ? prObj.body : ''
if (!branch || !htmlUrl)
return null
return {
kind: 'pr',
issueNumber,
action: obj.action,
pr: String(num),
branch,
htmlUrl,
title,
body,
raw,
}
}