✨ feat(vscode): 支持讨论评论引用与 macOS 系统级新评论桌面提醒
This commit is contained in:
1 parent
540c212bad
commit
a2c3c1f919
13 files changed
+615
-279
No files matched your search
@@ -73,6 +73,7 @@ export class KanbanWebviewPanel {
|
||||
private me: string | undefined
|
||||
/** webview 已发过 issues/refresh,此后才 postMessage,避免启动时消息丢。 */
|
||||
private webviewReady = false
|
||||
private pendingSelectOnReady?: { issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' }
|
||||
private loadInFlight: Promise<void> | undefined
|
||||
private lastIssuesUpdate: Extract<ExtensionToWebview, { type: 'issues/update' }> | undefined
|
||||
/**
|
||||
@@ -334,10 +335,10 @@ export class KanbanWebviewPanel {
|
||||
this.handleActiveTerminalChanged(activated)
|
||||
}
|
||||
|
||||
static createOrShow(context: ExtensionContext): void {
|
||||
static createOrShow(context: ExtensionContext): KanbanWebviewPanel {
|
||||
if (KanbanWebviewPanel.current) {
|
||||
KanbanWebviewPanel.current.panel.reveal(ViewColumn.Active)
|
||||
return
|
||||
return KanbanWebviewPanel.current
|
||||
}
|
||||
const panel = window.createWebviewPanel(
|
||||
KanbanWebviewPanel.viewType,
|
||||
@@ -352,6 +353,20 @@ export class KanbanWebviewPanel {
|
||||
},
|
||||
)
|
||||
KanbanWebviewPanel.current = new KanbanWebviewPanel(context, panel)
|
||||
return KanbanWebviewPanel.current
|
||||
}
|
||||
|
||||
reveal(viewColumn: ViewColumn = ViewColumn.Active): void {
|
||||
this.panel.reveal(viewColumn)
|
||||
}
|
||||
|
||||
selectIssueAndTab(issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'): void {
|
||||
if (this.webviewReady) {
|
||||
this.postMessage({ type: 'issue/select-by-number', issueNumber, tab })
|
||||
}
|
||||
else {
|
||||
this.pendingSelectOnReady = { issueNumber, tab }
|
||||
}
|
||||
}
|
||||
|
||||
/** Triggers a fresh load on the currently open panel, if any. */
|
||||
@@ -399,6 +414,11 @@ export class KanbanWebviewPanel {
|
||||
if (first && this.lastIssuesUpdate) {
|
||||
this.postMessage(this.lastIssuesUpdate)
|
||||
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
|
||||
if (this.pendingSelectOnReady) {
|
||||
const { issueNumber, tab } = this.pendingSelectOnReady
|
||||
this.pendingSelectOnReady = undefined
|
||||
this.postMessage({ type: 'issue/select-by-number', issueNumber, tab })
|
||||
}
|
||||
return
|
||||
}
|
||||
void this.loadAndPush()
|
||||
@@ -1116,6 +1136,11 @@ export class KanbanWebviewPanel {
|
||||
// refresh after git events already fired) still picks up the latest
|
||||
// value. Cheap — the webview discards if equal.
|
||||
this.postMessage({ type: 'commit/has-changes', value: this.hasGitChanges })
|
||||
if (this.pendingSelectOnReady) {
|
||||
const { issueNumber, tab } = this.pendingSelectOnReady
|
||||
this.pendingSelectOnReady = undefined
|
||||
this.postMessage({ type: 'issue/select-by-number', issueNumber, tab })
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
|
||||
@@ -64,7 +64,7 @@ export type ExtensionToWebview
|
||||
| { type: 'session-handoff/pending', items: SessionHandoffPendingItem[] }
|
||||
| { type: 'session-handoff/done', sessionId: string }
|
||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||
| { type: 'issue/select-by-number', issueNumber: number }
|
||||
| { type: 'issue/select-by-number', issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' }
|
||||
| {
|
||||
type: 'settings/show'
|
||||
host: string
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TabKey } from './components/BottomTabs'
|
||||
import type { PastedImage } from './components/NewIssueModal'
|
||||
import type { Issue } from './types'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
@@ -76,6 +77,8 @@ export function App() {
|
||||
clearLogs,
|
||||
pendingSelectId,
|
||||
clearPendingSelect,
|
||||
pendingSelectTab,
|
||||
clearPendingSelectTab,
|
||||
commitRunning,
|
||||
runCommit,
|
||||
hasChanges,
|
||||
@@ -123,6 +126,7 @@ export function App() {
|
||||
const [handoffIssue, setHandoffIssue] = useState<number | null>(null)
|
||||
const [handoffSession, setHandoffSession] = useState<{ id: string, name: string } | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [bottomTab, setBottomTab] = useState<TabKey>('issue')
|
||||
// 程序化设置的选中(反向选中 / pendingSelectId)记在这里,让下面的自动聚焦
|
||||
// effect 跳过它——否则 选中→聚焦→终端激活→反向选中 会死循环、CPU 飙升。
|
||||
const lastProgrammaticSelectRef = useRef<string | null>(null)
|
||||
@@ -157,8 +161,12 @@ export function App() {
|
||||
return
|
||||
lastProgrammaticSelectRef.current = pendingSelectId
|
||||
setSelectedId(pendingSelectId)
|
||||
if (pendingSelectTab) {
|
||||
setBottomTab(pendingSelectTab)
|
||||
clearPendingSelectTab()
|
||||
}
|
||||
clearPendingSelect()
|
||||
}, [pendingSelectId, readyIssues, clearPendingSelect])
|
||||
}, [pendingSelectId, pendingSelectTab, readyIssues, clearPendingSelect, clearPendingSelectTab])
|
||||
|
||||
// Issues partitioned by column, in COLUMN_ORDER. Used for arrow navigation.
|
||||
// 排序与看板视觉一致:完成列按 PR 合并时间降序,其余列按工单号降序。
|
||||
@@ -397,6 +405,8 @@ export function App() {
|
||||
</div>
|
||||
<div className="h-[40vh] shrink-0">
|
||||
<BottomTabs
|
||||
tab={bottomTab}
|
||||
onTabChange={setBottomTab}
|
||||
issue={selectedIssue}
|
||||
allIssues={state.issues}
|
||||
globalAutoReview={globalAutoReview}
|
||||
|
||||
@@ -21,9 +21,11 @@ import { ManagedSessionsPanel } from './ManagedSessionsPanel'
|
||||
import { PrFilesPanel } from './PrFilesPanel'
|
||||
import { ProfileGrid } from './ProfileGrid'
|
||||
|
||||
type TabKey = 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'
|
||||
export type TabKey = 'issue' | 'chat' | 'profile' | 'sessions' | 'changes'
|
||||
|
||||
interface BottomTabsProps {
|
||||
export interface BottomTabsProps {
|
||||
tab?: TabKey
|
||||
onTabChange?: (tab: TabKey) => void
|
||||
// Issue tab props (pass-through to IssueDetailPanel)
|
||||
issue: Issue | null
|
||||
allIssues: Issue[]
|
||||
@@ -92,7 +94,9 @@ interface BottomTabsProps {
|
||||
}
|
||||
|
||||
export function BottomTabs(props: BottomTabsProps) {
|
||||
const [tab, setTab] = useState<TabKey>('issue')
|
||||
const [internalTab, setInternalTab] = useState<TabKey>('issue')
|
||||
const tab = props.tab ?? internalTab
|
||||
const setTab = props.onTabChange ?? setInternalTab
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full bg-[var(--vscode-editor-background)]">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import type { IssueChatComment } from '../lib/messages'
|
||||
import type { Issue } from '../types'
|
||||
import { ExternalLink, Loader2, MessageCircle, RotateCw, Send } from 'lucide-react'
|
||||
import type { ParsedCommentQuote } from './commentQuote'
|
||||
import { ExternalLink, Loader2, MessageCircle, Quote, RotateCw, Send, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { onMessage, postMessage } from '../lib/vscode'
|
||||
import { parseCommentQuote } from './commentQuote'
|
||||
|
||||
export interface IssueChatPanelProps {
|
||||
issue: Issue | null
|
||||
@@ -51,18 +54,53 @@ function ChatMessageBubble({
|
||||
comment,
|
||||
isMine,
|
||||
avatarSrc,
|
||||
onQuote,
|
||||
}: {
|
||||
comment: IssueChatComment
|
||||
isMine: boolean
|
||||
avatarSrc?: string
|
||||
onQuote: (comment: IssueChatComment) => void
|
||||
}) {
|
||||
const timeStr = formatCommentTime(comment.createdAt)
|
||||
const { quote, replyText } = parseCommentQuote(comment.body)
|
||||
|
||||
const quoteBlock = quote && (
|
||||
<div
|
||||
className={`mb-1.5 rounded-r border-l-2 px-2 py-1 text-[11px] leading-relaxed select-text ${
|
||||
isMine
|
||||
? 'border-l-blue-300 bg-white/10 text-white/90'
|
||||
: 'border-l-[var(--vscode-focusBorder,#0078d4)] bg-black/15 dark:bg-white/5 text-[var(--vscode-descriptionForeground,#9d9d9d)]'
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-[var(--vscode-foreground)]">
|
||||
{quote.author ? `${quote.author}: ` : ''}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-words opacity-85">
|
||||
{quote.text}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
|
||||
const bodyBlock = (
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{replyText || (!quote ? comment.body : '')}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isMine) {
|
||||
return (
|
||||
<div className="flex items-start justify-end gap-2 pl-8">
|
||||
<div className="group flex items-start justify-end gap-2 pl-8">
|
||||
<div className="flex max-w-[85%] flex-col items-end">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip="引用"
|
||||
aria-label="引用"
|
||||
onClick={() => onQuote(comment)}
|
||||
className="inline-flex items-center justify-center rounded p-0.5 text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-black/10 hover:text-[var(--vscode-foreground)] group-hover:opacity-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<Quote className="size-3" />
|
||||
</button>
|
||||
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
|
||||
<span className="text-xs font-medium text-[var(--vscode-foreground)]">
|
||||
{comment.author}
|
||||
@@ -71,7 +109,8 @@ function ChatMessageBubble({
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words rounded-md border border-blue-500/50 bg-[#0e3753] px-3 py-2 text-xs leading-relaxed text-[var(--vscode-foreground)]">
|
||||
{comment.body}
|
||||
{quoteBlock}
|
||||
{bodyBlock}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--vscode-focusBorder,#0078d4)] text-[10px] font-medium text-white">
|
||||
@@ -88,7 +127,7 @@ function ChatMessageBubble({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-start gap-2 pr-8">
|
||||
<div className="group flex items-start justify-start gap-2 pr-8">
|
||||
<div className="flex size-6 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[var(--vscode-button-secondaryBackground,#3c3c3c)] text-[10px] font-medium text-[var(--vscode-foreground)]">
|
||||
{avatarSrc
|
||||
? (
|
||||
@@ -104,9 +143,19 @@ function ChatMessageBubble({
|
||||
{comment.authorName ? `${comment.authorName} (${comment.author})` : comment.author}
|
||||
</span>
|
||||
<span className="font-mono text-[10px] opacity-60">{timeStr}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip="引用"
|
||||
aria-label="引用"
|
||||
onClick={() => onQuote(comment)}
|
||||
className="inline-flex items-center justify-center rounded p-0.5 text-[var(--vscode-descriptionForeground)] opacity-0 transition-opacity hover:bg-black/10 hover:text-[var(--vscode-foreground)] group-hover:opacity-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<Quote className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words rounded-md border border-[var(--vscode-panel-border,#383838)] bg-[var(--vscode-sideBar-background,#262626)] px-3 py-2 text-xs leading-relaxed text-[var(--vscode-foreground)]">
|
||||
{comment.body}
|
||||
{quoteBlock}
|
||||
{bodyBlock}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,11 +172,14 @@ export function IssueChatPanel({
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sending, setSending] = useState(false)
|
||||
const [input, setInput] = useState('')
|
||||
const [quoting, setQuoting] = useState<ParsedCommentQuote | null>(null)
|
||||
const streamEndRef = useRef<HTMLDivElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const issueNumber = issue?.number
|
||||
|
||||
useEffect(() => {
|
||||
setQuoting(null)
|
||||
if (issueNumber == null) {
|
||||
setComments([])
|
||||
setLoading(false)
|
||||
@@ -168,17 +220,34 @@ export function IssueChatPanel({
|
||||
})
|
||||
}
|
||||
|
||||
const handleQuote = (comment: IssueChatComment) => {
|
||||
const { quote, replyText } = parseCommentQuote(comment.body)
|
||||
const quoteText = (replyText || quote?.text || comment.body).trim()
|
||||
setQuoting({
|
||||
author: comment.author,
|
||||
text: quoteText,
|
||||
})
|
||||
textareaRef.current?.focus()
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.trim()
|
||||
if (!text || issueNumber == null || sending)
|
||||
return
|
||||
let bodyToSend = text
|
||||
if (quoting) {
|
||||
const authorPart = quoting.author ? `@${quoting.author} ` : ''
|
||||
const formattedQuote = quoting.text.split('\n').join('\n> ')
|
||||
bodyToSend = `> ${authorPart}${formattedQuote}\n\n${text}`
|
||||
}
|
||||
setSending(true)
|
||||
postMessage({
|
||||
type: 'issue/comment-post',
|
||||
issueNumber,
|
||||
body: text,
|
||||
body: bodyToSend,
|
||||
})
|
||||
setInput('')
|
||||
setQuoting(null)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -258,6 +327,7 @@ export function IssueChatPanel({
|
||||
comment={comment}
|
||||
isMine={isMine}
|
||||
avatarSrc={avatarSrc}
|
||||
onQuote={handleQuote}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
@@ -265,8 +335,36 @@ export function IssueChatPanel({
|
||||
</div>
|
||||
|
||||
{/* 底部输入栏 */}
|
||||
<div className="flex items-end gap-2 border-t border-[var(--vscode-panel-border)] bg-[var(--vscode-sideBar-background,var(--vscode-editor-background))] p-2.5">
|
||||
<div className="flex flex-col border-t border-[var(--vscode-panel-border)] bg-[var(--vscode-sideBar-background,var(--vscode-editor-background))] p-2.5">
|
||||
{quoting && (
|
||||
<div className="mb-2 flex items-center justify-between gap-2 rounded border border-[var(--vscode-panel-border,#383838)] bg-[var(--vscode-editor-lineHighlightBackground,rgba(255,255,255,0.04))] px-2.5 py-1 text-xs text-[var(--vscode-foreground)]">
|
||||
<div className="flex min-w-0 items-center gap-1.5 border-l-2 border-l-[var(--vscode-focusBorder,#0078d4)] pl-2">
|
||||
<Quote className="size-3 shrink-0 opacity-60" />
|
||||
<span className="truncate">
|
||||
<span className="font-medium text-[var(--vscode-foreground)]">
|
||||
引用
|
||||
{' '}
|
||||
{quoting.author ? `@${quoting.author}:` : ':'}
|
||||
</span>
|
||||
<span className="opacity-75">
|
||||
{quoting.text.replace(/\s+/g, ' ')}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-tooltip="取消引用"
|
||||
aria-label="取消引用"
|
||||
onClick={() => setQuoting(null)}
|
||||
className="shrink-0 rounded p-0.5 opacity-60 hover:bg-black/10 hover:opacity-100 dark:hover:bg-white/10"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -291,5 +389,6 @@ export function IssueChatPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseCommentQuote } from './commentQuote'
|
||||
|
||||
describe('parseCommentQuote', () => {
|
||||
it('parses single-line quote with @author', () => {
|
||||
const input = '> @cruldra 原文内容\n\n回复内容'
|
||||
const result = parseCommentQuote(input)
|
||||
expect(result).toEqual({
|
||||
quote: {
|
||||
author: 'cruldra',
|
||||
text: '原文内容',
|
||||
},
|
||||
replyText: '回复内容',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses multiline quote with @author', () => {
|
||||
const input = '> @chehongweo 第一行原文\n> 第二行原文\n\n收到,正在处理'
|
||||
const result = parseCommentQuote(input)
|
||||
expect(result).toEqual({
|
||||
quote: {
|
||||
author: 'chehongweo',
|
||||
text: '第一行原文\n第二行原文',
|
||||
},
|
||||
replyText: '收到,正在处理',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses quote without @author', () => {
|
||||
const input = '> 纯引用文本\n\n回复'
|
||||
const result = parseCommentQuote(input)
|
||||
expect(result).toEqual({
|
||||
quote: {
|
||||
text: '纯引用文本',
|
||||
},
|
||||
replyText: '回复',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns plain text when there is no quote prefix', () => {
|
||||
const input = '这是一条普通的回复,没有引用。'
|
||||
const result = parseCommentQuote(input)
|
||||
expect(result).toEqual({
|
||||
replyText: input,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles quote without reply body', () => {
|
||||
const input = '> @alice 仅引用的情况'
|
||||
const result = parseCommentQuote(input)
|
||||
expect(result).toEqual({
|
||||
quote: {
|
||||
author: 'alice',
|
||||
text: '仅引用的情况',
|
||||
},
|
||||
replyText: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface ParsedCommentQuote {
|
||||
author?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ParsedCommentBody {
|
||||
quote?: ParsedCommentQuote
|
||||
replyText: string
|
||||
}
|
||||
|
||||
export function parseCommentQuote(body: string): ParsedCommentBody {
|
||||
const trimmed = body.trim()
|
||||
if (!trimmed.startsWith('>')) {
|
||||
return { replyText: body }
|
||||
}
|
||||
|
||||
const lines = trimmed.split('\n')
|
||||
const quoteLines: string[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length && lines[i].trimStart().startsWith('>')) {
|
||||
const rawLine = lines[i].trimStart()
|
||||
quoteLines.push(rawLine.replace(/^>\s?/, ''))
|
||||
i++
|
||||
}
|
||||
|
||||
while (i < lines.length && lines[i].trim() === '') {
|
||||
i++
|
||||
}
|
||||
|
||||
const replyText = lines.slice(i).join('\n')
|
||||
const fullQuoteText = quoteLines.join('\n').trim()
|
||||
|
||||
let author: string | undefined
|
||||
let text = fullQuoteText
|
||||
|
||||
if (fullQuoteText.startsWith('@')) {
|
||||
const spaceIdx = fullQuoteText.search(/\s/)
|
||||
if (spaceIdx !== -1) {
|
||||
author = fullQuoteText.slice(1, spaceIdx).trim()
|
||||
text = fullQuoteText.slice(spaceIdx).trim()
|
||||
}
|
||||
else {
|
||||
author = fullQuoteText.slice(1).trim()
|
||||
text = ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
quote: {
|
||||
...(author ? { author } : {}),
|
||||
text,
|
||||
},
|
||||
replyText,
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,8 @@ export interface UseIssuesResult {
|
||||
*/
|
||||
pendingSelectId: string | null
|
||||
clearPendingSelect: () => void
|
||||
pendingSelectTab: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' | null
|
||||
clearPendingSelectTab: () => void
|
||||
/** 当前 Gitea login,来自最近一次 `issues/update`;undefined = 身份未知。 */
|
||||
me: string | undefined
|
||||
/** 最近一次 `requestHandoffUsers` 的结果;`null` = 仍在加载或尚未请求。 */
|
||||
@@ -384,6 +386,7 @@ export function useIssues(): UseIssuesResult {
|
||||
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [pendingSelectId, setPendingSelectId] = useState<string | null>(null)
|
||||
const [pendingSelectTab, setPendingSelectTab] = useState<'issue' | 'chat' | 'profile' | 'sessions' | 'changes' | null>(null)
|
||||
const [commitRunning, setCommitRunning] = useState<boolean>(false)
|
||||
const [hasChanges, setHasChanges] = useState<boolean>(false)
|
||||
const [release, setRelease] = useState<ReleaseStatus>()
|
||||
@@ -410,6 +413,10 @@ export function useIssues(): UseIssuesResult {
|
||||
setPendingSelectId(null)
|
||||
}, [])
|
||||
|
||||
const clearPendingSelectTab = useCallback((): void => {
|
||||
setPendingSelectTab(null)
|
||||
}, [])
|
||||
|
||||
const toggleScope = useCallback((): void => {
|
||||
setState({ status: 'loading' })
|
||||
setScope(prev => prev === 'mine' ? 'all' : 'mine')
|
||||
@@ -927,8 +934,11 @@ export function useIssues(): UseIssuesResult {
|
||||
if (prev.status !== 'ready')
|
||||
return prev
|
||||
const issue = prev.issues.find(i => i.number === msg.issueNumber)
|
||||
if (issue)
|
||||
if (issue) {
|
||||
setPendingSelectId(issue.id)
|
||||
if (msg.tab)
|
||||
setPendingSelectTab(msg.tab)
|
||||
}
|
||||
return prev
|
||||
})
|
||||
break
|
||||
@@ -1087,5 +1097,5 @@ export function useIssues(): UseIssuesResult {
|
||||
])
|
||||
}, [state, me])
|
||||
|
||||
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning, askHandoff, cancelHandoffRequest, acceptHandoffRequest, rejectHandoffRequest }
|
||||
return { state, settings, avatars, isSessionTabPending, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, refreshing, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startReviewSession, startTestSession, resumeTestSession, startPrototypeSession, resumePrototypeSession, focusSession, openFile, openPrototypeInFigma, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, continueBrainstorm, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, clearSession, updateIssueTestProfilePath, updateIssuePrototypeProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, pendingSelectTab, clearPendingSelectTab, commitRunning, runCommit, hasChanges, release, runRelease, cherryPick, dismissCherryPick, confirmCherryPick, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock, me, handoffUsers, requestHandoffUsers, startHandoff, acceptHandoff, isHandoffRunning, askHandoff, cancelHandoffRequest, acceptHandoffRequest, rejectHandoffRequest }
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export type ExtensionToWebview
|
||||
| { type: 'session-handoff/done', sessionId: string }
|
||||
| { type: 'handoff/done', issueNumber: number }
|
||||
| { type: 'issue/append', issue: Issue, select?: boolean }
|
||||
| { type: 'issue/select-by-number', issueNumber: number }
|
||||
| { type: 'issue/select-by-number', issueNumber: number, tab?: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' }
|
||||
| {
|
||||
type: 'settings/show'
|
||||
host: string
|
||||
|
||||
Reference in new issue
Block a user