✨ feat(vscode): 看板底栏新增工单讨论 Tab 并支持系统气泡与 Gitea 评论同步

This commit is contained in:
cruldra committed 2026-09-29 04:23:57 +08:00
1 parent 8ecb40b330
commit df17821408
14 files changed
+1555 -2

No files matched your search

+6
View File
@@ -44,6 +44,12 @@ export interface GiteaComment {
issue_url: string
created_at: string
updated_at: string
user?: {
id: number
login: string
full_name?: string
avatar_url?: string
}
}
export class GiteaApiError extends Error {
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { systemCommentText } from './issueComment'
describe('systemCommentText', () => {
it('列评论变成一句系统话', () => {
expect(systemCommentText('{"column":"todo"}')).toBe('把工单放到待办')
})
it('带别的状态字段时仍只说列', () => {
expect(systemCommentText('{"column":"review","color":"terminal.ansiBlue"}')).toBe('更新了工单状态,放到审查')
})
it('普通评论不是系统消息', () => {
expect(systemCommentText('这个方案可以')).toBeUndefined()
})
it('审查评论去掉标记,剩下的正文当系统消息', () => {
expect(systemCommentText('<!-- spx:review=1 -->\n看过了')).toBe('看过了')
expect(systemCommentText('<!-- spx:review=1 -->')).toBe('提交了审查')
})
})
+53
View File
@@ -0,0 +1,53 @@
const COLUMN_LABEL: Record<string, string> = {
'todo': '待办',
'in-progress': '进行中',
'review': '审查',
'done': '完成',
}
const STATE_KEYS = new Set([
'column',
'sessionId',
'implementSessionId',
'reviewSessionId',
'testSessionId',
'prototypeSessionId',
'handoffAttachmentId',
'handoffFrom',
'handoffRequestFrom',
'handoffRequestError',
'pr',
'branch',
'worktreePath',
'color',
'autoReview',
])
/** 插件自己写的评论。返回系统气泡文案;普通讨论返回 undefined。 */
export function systemCommentText(body: string): string | undefined {
const trimmed = body.trim()
if (/<!--\s*spx:review=1\s*-->/i.test(trimmed)) {
const text = trimmed.replace(/<!--\s*spx:review=1\s*-->/gi, '').trim()
return text.length > 0 ? text : '提交了审查'
}
if (!trimmed.startsWith('{'))
return undefined
let parsed: unknown
try {
parsed = JSON.parse(trimmed)
}
catch {
return undefined
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
return undefined
const record = parsed as Record<string, unknown>
if (!Object.keys(record).some(key => STATE_KEYS.has(key)))
return undefined
const column = typeof record.column === 'string' ? COLUMN_LABEL[record.column] : undefined
if (Object.keys(record).length === 1 && column)
return `把工单放到${column}`
if (column)
return `更新了工单状态,放到${column}`
return '更新了工单状态'
}
+9
View File
@@ -29,6 +29,7 @@ import { webhookCoordinator } from '../webhook/coordinator'
import { makeNonce, webviewHtml, webviewUiRoot } from '../webview/html'
import { loadYouTrackIssues } from '../youtrack/issueLoader'
import * as handoffFlow from './handlers/handoffFlow'
import * as issueChat from './handlers/issueChat'
import * as issues from './handlers/issues'
import * as managedSessions from './handlers/managedSessions'
import * as prFiles from './handlers/prFiles'
@@ -728,6 +729,14 @@ export class KanbanWebviewPanel {
}
if (msg.type === 'pr-diff-mode/set') {
void prFiles.handleSetPrDiffMode(this, msg.mode)
return
}
if (msg.type === 'issue/comments-query') {
void issueChat.handleIssueComments(this, msg.issueNumber)
return
}
if (msg.type === 'issue/comment-post') {
void issueChat.handleIssueCommentPost(this, msg.issueNumber, msg.body)
}
}
+83
View File
@@ -0,0 +1,83 @@
import type { GiteaComment } from '../../gitea/api'
import type { KanbanWebviewPanel } from '../KanbanPanel'
import { listIssueComments, postIssueComment } from '../../gitea/api'
import { systemCommentText } from '../../gitea/issueComment'
import { logger } from '../../logging/logger'
import { resolveRepoCtx, toast } from './handoffFlow'
export interface IssueChatComment {
id: number
author: string
authorName?: string
avatarUrl?: string
createdAt: string
body: string
systemText?: string
}
function toChat(comment: GiteaComment): IssueChatComment {
const systemText = systemCommentText(comment.body)
return {
id: comment.id,
author: comment.user?.login ?? '未知',
authorName: comment.user?.full_name || undefined,
avatarUrl: comment.user?.avatar_url || undefined,
createdAt: comment.created_at,
body: comment.body,
...(systemText ? { systemText } : {}),
}
}
async function pushComments(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
const comments = await listIssueComments({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: issueNumber,
})
panel.postMessage({
type: 'issue/comments-result',
issueNumber,
comments: comments.map(toChat),
})
}
export async function handleIssueComments(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
try {
await pushComments(panel, issueNumber)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `读取工单 #${issueNumber} 评论失败`, details: message })
panel.postMessage({ type: 'issue/comments-result', issueNumber, comments: [], error: message })
}
}
export async function handleIssueCommentPost(panel: KanbanWebviewPanel, issueNumber: number, body: string): Promise<void> {
const text = body.trim()
if (!text)
return
const ctx = await resolveRepoCtx(panel)
if (!ctx)
return
try {
await postIssueComment({
host: ctx.host,
token: ctx.token,
owner: ctx.owner,
repo: ctx.repo,
index: issueNumber,
body: text,
})
await pushComments(panel, issueNumber)
}
catch (err) {
const message = err instanceof Error ? err.message : String(err)
logger.add({ level: 'error', source: 'panel', message: `发表工单 #${issueNumber} 评论失败`, details: message })
toast(panel, 'error', `评论没发出去:${message}`)
}
}
+5
View File
@@ -3,9 +3,11 @@ import type { Issue, IssueColumn } from '../gitea/types'
import type { LogEntry } from '../logging/logger'
import type { ProfilesData } from '../profiles/store'
import type { ManagedSession } from '../sessions/managedStore'
import type { IssueChatComment } from './handlers/issueChat'
export type { LogEntry } from '../logging/logger'
export type { ProfileRow, ProfilesData } from '../profiles/store'
export type { IssueChatComment } from './handlers/issueChat'
/**
* `managed-sessions/show` 的 payload 类型:在持久化的 ManagedSession 基础上
@@ -145,6 +147,7 @@ export type ExtensionToWebview
// profile 值编辑器粘贴的图片:存盘/读盘的回执,requestId 关联并发请求。
| { type: 'profile-asset/saved', requestId: string, assetPath?: string, error?: string }
| { type: 'profile-asset/loaded', requestId: string, dataUrl?: string, error?: string }
| { type: 'issue/comments-result', issueNumber: number, comments: IssueChatComment[], error?: string }
export type WebviewToExtension
= | { type: 'issues/refresh' }
@@ -264,3 +267,5 @@ export type WebviewToExtension
// profile 值编辑器:图片存盘(base64→磁盘)与读盘(磁盘→dataUrl)请求。
| { type: 'profile-asset/save', requestId: string, base64: string, mediaType: string }
| { type: 'profile-asset/load', requestId: string, assetPath: string }
| { type: 'issue/comments-query', issueNumber: number }
| { type: 'issue/comment-post', issueNumber: number, body: string }
+8
View File
@@ -25,6 +25,7 @@ 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 { DEFAULT_KANBAN_SCOPE, KANBAN_SCOPE_KEY } from '../panel/kanbanScope'
import { annotateMissingSessionIds } from '../sessions/sessionFiles'
@@ -1422,6 +1423,10 @@ 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 评论,直接排除。
@@ -1492,6 +1497,9 @@ class WebhookCoordinator {
source: 'webhook',
message: `issue_comment 评论在 PR #${event.prNumber},反查到工单 #${realIssueNumber}`,
})
if (this.activePanel) {
void issueChat.handleIssueComments(this.activePanel, realIssueNumber)
}
}
else {
logger.add({