✨ feat(vscode): 多人协作二期:状态分层/写校验/签名/团队视图
- 本机状态(会话id/worktree/profile/prDiffFile)迁 workspaceState, 共享 state JSON 只留团队字段;读侧本地记录叠加,旧数据兜底 - mergeStateJsonComment 写后读回校验,并发覆盖时在最新状态上重放一次 - webhook 支持 Gitea secret,校验 X-Gitea-Signature(HMAC-SHA256), 不匹配 401;设置面板新增 Webhook Secret 字段 - 看板新增 我的/全部 范围切换(团队视图),按工作区持久化
This commit is contained in:
@@ -87,8 +87,9 @@ export async function listIssuesByFilter(opts: {
|
|||||||
token: string
|
token: string
|
||||||
owner: string
|
owner: string
|
||||||
repo: string
|
repo: string
|
||||||
filter: 'assigned_by' | 'created_by'
|
/** 缺省 = 不按人过滤,拉仓库全部工单(团队视图)。 */
|
||||||
user: string
|
filter?: 'assigned_by' | 'created_by'
|
||||||
|
user?: string
|
||||||
}): Promise<GiteaIssue[]> {
|
}): Promise<GiteaIssue[]> {
|
||||||
const out: GiteaIssue[] = []
|
const out: GiteaIssue[] = []
|
||||||
let page = 1
|
let page = 1
|
||||||
@@ -98,7 +99,8 @@ export async function listIssuesByFilter(opts: {
|
|||||||
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`)
|
const url = new URL(`${baseUrl(opts.host)}/repos/${opts.owner}/${opts.repo}/issues`)
|
||||||
url.searchParams.set('type', 'issues')
|
url.searchParams.set('type', 'issues')
|
||||||
url.searchParams.set('state', 'all')
|
url.searchParams.set('state', 'all')
|
||||||
url.searchParams.set(opts.filter, opts.user)
|
if (opts.filter && opts.user)
|
||||||
|
url.searchParams.set(opts.filter, opts.user)
|
||||||
url.searchParams.set('limit', String(PAGE_SIZE))
|
url.searchParams.set('limit', String(PAGE_SIZE))
|
||||||
url.searchParams.set('page', String(page))
|
url.searchParams.set('page', String(page))
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,10 @@
|
|||||||
* still display the issue in the computed column.
|
* still display the issue in the computed column.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'node:fs'
|
|
||||||
import * as path from 'node:path'
|
|
||||||
import { resolveWorktreePath } from '../git/worktree'
|
|
||||||
import type { GiteaComment, GiteaIssue } from './api'
|
import type { GiteaComment, GiteaIssue } from './api'
|
||||||
import type { Issue, IssueColumn } from './types'
|
import type { Issue, IssueColumn } from './types'
|
||||||
|
import * as fs from 'node:fs'
|
||||||
|
import { resolveWorktreePath } from '../git/worktree'
|
||||||
import {
|
import {
|
||||||
getCurrentUser,
|
getCurrentUser,
|
||||||
getDependencies,
|
getDependencies,
|
||||||
@@ -52,7 +51,7 @@ export function isValidSpxFilePath(v: unknown): v is string {
|
|||||||
|
|
||||||
function isValidPrDiffFilePath(v: unknown): v is string {
|
function isValidPrDiffFilePath(v: unknown): v is string {
|
||||||
return typeof v === 'string'
|
return typeof v === 'string'
|
||||||
&& /^docs\/pr-diff\/[^\s]+\.md$/.test(v)
|
&& /^docs\/pr-diff\/\S+\.md$/.test(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isIssueColumn(value: unknown): value is IssueColumn {
|
function isIssueColumn(value: unknown): value is IssueColumn {
|
||||||
@@ -351,7 +350,6 @@ async function buildIssue(opts: {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (postErr) {
|
catch (postErr) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`[superpowers] failed to seed state comment on ${id}:`, postErr)
|
console.warn(`[superpowers] failed to seed state comment on ${id}:`, postErr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -405,24 +403,34 @@ export async function loadIssues(opts: {
|
|||||||
repo: string
|
repo: string
|
||||||
/** Absolute workspace root used to resolve `worktreeExists` against disk. */
|
/** Absolute workspace root used to resolve `worktreeExists` against disk. */
|
||||||
workspaceRoot?: string
|
workspaceRoot?: string
|
||||||
|
/** 'mine'(默认) = assigned+created 给我的;'all' = 仓库全部工单(团队视图)。 */
|
||||||
|
scope?: 'mine' | 'all'
|
||||||
}): Promise<Issue[]> {
|
}): Promise<Issue[]> {
|
||||||
const { host, token, owner, repo, workspaceRoot } = opts
|
const { host, token, owner, repo, workspaceRoot } = opts
|
||||||
|
|
||||||
// `/user` validates the token and gives us the login. The repo-wide
|
// The repo-wide comments firehose doesn't depend on scope; kick it off first.
|
||||||
// comments firehose doesn't need the login, so we kick it off in parallel.
|
|
||||||
// The two issue-filter calls need `user.login` and run together after.
|
|
||||||
const userPromise = getCurrentUser({ host, token })
|
|
||||||
const commentsPromise = listAllRepoComments({ host, token, owner, repo })
|
const commentsPromise = listAllRepoComments({ host, token, owner, repo })
|
||||||
|
|
||||||
const user = await userPromise
|
let merged: Awaited<ReturnType<typeof listIssuesByFilter>>
|
||||||
|
let allComments: Awaited<typeof commentsPromise>
|
||||||
const [assigned, created, allComments] = await Promise.all([
|
if (opts.scope === 'all') {
|
||||||
listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }),
|
;[merged, allComments] = await Promise.all([
|
||||||
listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }),
|
listIssuesByFilter({ host, token, owner, repo }),
|
||||||
commentsPromise,
|
commentsPromise,
|
||||||
])
|
])
|
||||||
|
}
|
||||||
const merged = mergeIssues(assigned, created)
|
else {
|
||||||
|
// `/user` validates the token and gives us the login. The two
|
||||||
|
// issue-filter calls need `user.login` and run together after.
|
||||||
|
const user = await getCurrentUser({ host, token })
|
||||||
|
const [assigned, created, comments] = await Promise.all([
|
||||||
|
listIssuesByFilter({ host, token, owner, repo, filter: 'assigned_by', user: user.login }),
|
||||||
|
listIssuesByFilter({ host, token, owner, repo, filter: 'created_by', user: user.login }),
|
||||||
|
commentsPromise,
|
||||||
|
])
|
||||||
|
merged = mergeIssues(assigned, created)
|
||||||
|
allComments = comments
|
||||||
|
}
|
||||||
const buckets = groupCommentsByIssue(allComments)
|
const buckets = groupCommentsByIssue(allComments)
|
||||||
|
|
||||||
// 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。
|
// 先用各工单评论 bucket 算出 column,决定是否需要 per-issue 远程拉取。
|
||||||
|
|||||||
@@ -71,14 +71,31 @@ export interface MergeStateJsonCommentOpts {
|
|||||||
* are responsible for surfacing errors.
|
* are responsible for surfacing errors.
|
||||||
*/
|
*/
|
||||||
export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise<void> {
|
export async function mergeStateJsonComment(opts: MergeStateJsonCommentOpts): Promise<void> {
|
||||||
const currentState = await readStateJsonComment({
|
// 乐观并发:state 评论是"最后一条全量覆盖",两个写者并发时后写者会把
|
||||||
host: opts.host,
|
// 先写者的字段整体冲掉。门禁后共享写基本回到单写者,但人工拖列仍可能
|
||||||
owner: opts.owner,
|
// 与 webhook 写并发——post 后读回校验,本次字段若被并发覆盖,就在最新
|
||||||
repo: opts.repo,
|
// 状态上重放一次。两轮后仍冲突的概率可忽略,按最后一轮结果收场。
|
||||||
token: opts.token,
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
issueNumber: opts.issueNumber,
|
const currentState = await readStateJsonComment({
|
||||||
})
|
host: opts.host,
|
||||||
await postMergedStateJsonComment(opts, currentState, opts.extra)
|
owner: opts.owner,
|
||||||
|
repo: opts.repo,
|
||||||
|
token: opts.token,
|
||||||
|
issueNumber: opts.issueNumber,
|
||||||
|
})
|
||||||
|
await postMergedStateJsonComment(opts, currentState, opts.extra)
|
||||||
|
const latest = await readStateJsonComment({
|
||||||
|
host: opts.host,
|
||||||
|
owner: opts.owner,
|
||||||
|
repo: opts.repo,
|
||||||
|
token: opts.token,
|
||||||
|
issueNumber: opts.issueNumber,
|
||||||
|
})
|
||||||
|
const lost = Object.entries(opts.extra)
|
||||||
|
.some(([k, v]) => JSON.stringify(latest[k]) !== JSON.stringify(v))
|
||||||
|
if (!lost)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts {
|
export interface MergeStateJsonCommentGuardedOpts extends MergeStateJsonCommentOpts {
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* 机器本地的工单状态(会话 id、worktree 路径、profile 路径等)。
|
||||||
|
*
|
||||||
|
* 这些字段描述"这台机器上"的会话与文件系统,跨机毫无语义(别人机器的
|
||||||
|
* 绝对路径/会话 id 在本机不可用),写进 Gitea 共享评论只会互相污染——
|
||||||
|
* 所以落在 workspaceState,按 `source:number` 键控。共享评论里的旧数据
|
||||||
|
* 仍按读兜底(本地无记录时透出),写侧从此只产生本地记录。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionContext } from 'vscode'
|
||||||
|
import type { Issue } from '../gitea/types'
|
||||||
|
import * as fs from 'node:fs'
|
||||||
|
import { resolveWorktreePath } from '../git/worktree'
|
||||||
|
|
||||||
|
export const LOCAL_STATE_FIELDS = [
|
||||||
|
'sessionId',
|
||||||
|
'implementSessionId',
|
||||||
|
'reviewSessionId',
|
||||||
|
'testSessionId',
|
||||||
|
'profilePath',
|
||||||
|
'brainstormProfilePath',
|
||||||
|
'testProfilePath',
|
||||||
|
'worktreePath',
|
||||||
|
'prDiffFile',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const KEY = 'superpowers.localIssueState'
|
||||||
|
|
||||||
|
type Store = Record<string, Record<string, string>>
|
||||||
|
|
||||||
|
function refKey(source: string | undefined, issueNumber: number): string {
|
||||||
|
return `${source ?? 'gitea'}:${issueNumber}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalIssueState(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
source: string | undefined,
|
||||||
|
issueNumber: number,
|
||||||
|
): Record<string, string> {
|
||||||
|
const store = ctx.workspaceState.get<Store>(KEY) ?? {}
|
||||||
|
return store[refKey(source, issueNumber)] ?? {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空字符串是墓碑:字段已在本机清除,读侧不得回落到共享评论里的旧值
|
||||||
|
* (与 state JSON "空串=unset" 的既有约定一致)。null/undefined 视作清除。
|
||||||
|
*/
|
||||||
|
export async function mergeLocalIssueState(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
source: string | undefined,
|
||||||
|
issueNumber: number,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): Promise<void> {
|
||||||
|
const store = { ...(ctx.workspaceState.get<Store>(KEY) ?? {}) }
|
||||||
|
const key = refKey(source, issueNumber)
|
||||||
|
const next = { ...(store[key] ?? {}) }
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (typeof v === 'string')
|
||||||
|
next[k] = v
|
||||||
|
else if (v === null || v === undefined)
|
||||||
|
next[k] = ''
|
||||||
|
}
|
||||||
|
store[key] = next
|
||||||
|
await ctx.workspaceState.update(KEY, store)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把一次状态写入分成本地字段与共享字段两半。 */
|
||||||
|
export function splitLocalStateFields(extra: Record<string, unknown>): {
|
||||||
|
local: Record<string, unknown>
|
||||||
|
shared: Record<string, unknown>
|
||||||
|
} {
|
||||||
|
const localSet = new Set<string>(LOCAL_STATE_FIELDS)
|
||||||
|
const local: Record<string, unknown> = {}
|
||||||
|
const shared: Record<string, unknown> = {}
|
||||||
|
for (const [k, v] of Object.entries(extra)) {
|
||||||
|
if (localSet.has(k))
|
||||||
|
local[k] = v
|
||||||
|
else
|
||||||
|
shared[k] = v
|
||||||
|
}
|
||||||
|
return { local, shared }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把本地记录叠加到加载出的工单列表上:本地有键即生效('' 墓碑 → 置空)。
|
||||||
|
* 覆盖 worktreePath 时同步重算 worktreeExists,口径与 buildIssue 一致。
|
||||||
|
*/
|
||||||
|
export function overlayLocalIssueState(
|
||||||
|
ctx: ExtensionContext,
|
||||||
|
issues: Issue[],
|
||||||
|
workspaceRoot?: string,
|
||||||
|
): Issue[] {
|
||||||
|
const store = ctx.workspaceState.get<Store>(KEY) ?? {}
|
||||||
|
return issues.map((issue) => {
|
||||||
|
const local = store[refKey(issue.source, issue.number)]
|
||||||
|
if (!local)
|
||||||
|
return issue
|
||||||
|
const out = { ...issue } as Issue & Record<string, unknown>
|
||||||
|
for (const field of LOCAL_STATE_FIELDS) {
|
||||||
|
const v = local[field]
|
||||||
|
if (v === undefined)
|
||||||
|
continue
|
||||||
|
if (v === '')
|
||||||
|
delete out[field]
|
||||||
|
else
|
||||||
|
out[field] = v
|
||||||
|
if (field === 'worktreePath') {
|
||||||
|
delete out.worktreeExists
|
||||||
|
if (v !== '' && workspaceRoot) {
|
||||||
|
try {
|
||||||
|
out.worktreeExists = fs.existsSync(resolveWorktreePath(v, workspaceRoot))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
out.worktreeExists = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { getSettings } from '../settings/store'
|
|||||||
import { applyCommand, resolvedStateCommand } from '../youtrack/api'
|
import { applyCommand, resolvedStateCommand } from '../youtrack/api'
|
||||||
import { youtrackHost } from '../youtrack/issueLoader'
|
import { youtrackHost } from '../youtrack/issueLoader'
|
||||||
import { mergeStateComment, readStateComment } from '../youtrack/stateComment'
|
import { mergeStateComment, readStateComment } from '../youtrack/stateComment'
|
||||||
|
import { getLocalIssueState, mergeLocalIssueState, splitLocalStateFields } from './localState'
|
||||||
|
|
||||||
export interface IssueRef {
|
export interface IssueRef {
|
||||||
/** Absent = gitea (back-compat). */
|
/** Absent = gitea (back-compat). */
|
||||||
@@ -58,25 +59,43 @@ async function youtrackAuth(ctx: ExtensionContext): Promise<{ baseUrl: string, t
|
|||||||
return { baseUrl, token }
|
return { baseUrl, token }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read the workflow-state blob for an issue from its tracker. */
|
/**
|
||||||
|
* Read the workflow-state blob for an issue from its tracker, with this
|
||||||
|
* machine's local record overlaid on top(本地有键即覆盖,'' 墓碑与
|
||||||
|
* "空串=unset" 的既有约定一致)。
|
||||||
|
*/
|
||||||
export async function readIssueState(ctx: ExtensionContext, ref: IssueRef): Promise<Record<string, unknown>> {
|
export async function readIssueState(ctx: ExtensionContext, ref: IssueRef): Promise<Record<string, unknown>> {
|
||||||
|
let base: Record<string, unknown>
|
||||||
if (isYouTrack(ref)) {
|
if (isYouTrack(ref)) {
|
||||||
const auth = await youtrackAuth(ctx)
|
const auth = await youtrackAuth(ctx)
|
||||||
return readStateComment(auth, ref.externalId)
|
base = await readStateComment(auth, ref.externalId)
|
||||||
}
|
}
|
||||||
const d = await giteaDeps(ctx)
|
else {
|
||||||
return readStateJsonComment({ ...d, issueNumber: ref.number })
|
const d = await giteaDeps(ctx)
|
||||||
|
base = await readStateJsonComment({ ...d, issueNumber: ref.number })
|
||||||
|
}
|
||||||
|
return { ...base, ...getLocalIssueState(ctx, ref.source, ref.number) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge `extra` into the issue's workflow-state blob in its tracker. */
|
/**
|
||||||
|
* Merge `extra` into the issue's workflow state。机器本地字段
|
||||||
|
* (会话 id / worktree / profile 路径)落 workspaceState,只有共享字段
|
||||||
|
* (column / pr / branch / spec 等)才写进 tracker 评论——跨机没有语义的
|
||||||
|
* 值从此不进共享存储,也不参与多机写竞争。
|
||||||
|
*/
|
||||||
export async function mergeIssueState(ctx: ExtensionContext, ref: IssueRef, extra: Record<string, unknown>): Promise<void> {
|
export async function mergeIssueState(ctx: ExtensionContext, ref: IssueRef, extra: Record<string, unknown>): Promise<void> {
|
||||||
|
const { local, shared } = splitLocalStateFields(extra)
|
||||||
|
if (Object.keys(local).length > 0)
|
||||||
|
await mergeLocalIssueState(ctx, ref.source, ref.number, local)
|
||||||
|
if (Object.keys(shared).length === 0)
|
||||||
|
return
|
||||||
if (isYouTrack(ref)) {
|
if (isYouTrack(ref)) {
|
||||||
const auth = await youtrackAuth(ctx)
|
const auth = await youtrackAuth(ctx)
|
||||||
await mergeStateComment(auth, ref.externalId, extra)
|
await mergeStateComment(auth, ref.externalId, shared)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const d = await giteaDeps(ctx)
|
const d = await giteaDeps(ctx)
|
||||||
await mergeStateJsonComment({ ...d, issueNumber: ref.number, extra })
|
await mergeStateJsonComment({ ...d, issueNumber: ref.number, extra: shared })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import type {
|
|||||||
TerminalEditorLocationOptions,
|
TerminalEditorLocationOptions,
|
||||||
WebviewPanel,
|
WebviewPanel,
|
||||||
} from 'vscode'
|
} from 'vscode'
|
||||||
|
import type { HookContext } from '../git/worktreeHooks'
|
||||||
import type { Issue } from '../gitea/types'
|
import type { Issue } from '../gitea/types'
|
||||||
|
import type { IssueRef } from '../issues/stateRouter'
|
||||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import * as fs from 'node:fs'
|
import * as fs from 'node:fs'
|
||||||
@@ -12,13 +14,11 @@ import * as path from 'node:path'
|
|||||||
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
||||||
import { deleteToken, getToken } from '../auth/secrets'
|
import { deleteToken, getToken } from '../auth/secrets'
|
||||||
import { detectRepo } from '../git/remote'
|
import { detectRepo } from '../git/remote'
|
||||||
import type { HookContext } from '../git/worktreeHooks'
|
|
||||||
import {
|
import {
|
||||||
GiteaApiError,
|
GiteaApiError,
|
||||||
postIssueComment,
|
|
||||||
} from '../gitea/api'
|
} from '../gitea/api'
|
||||||
import { loadIssues } from '../gitea/issueLoader'
|
import { loadIssues } from '../gitea/issueLoader'
|
||||||
import type { IssueRef } from '../issues/stateRouter'
|
import { overlayLocalIssueState } from '../issues/localState'
|
||||||
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
|
import { closeIssueByRef, mergeIssueState, readIssueState } from '../issues/stateRouter'
|
||||||
import { logger } from '../logging/logger'
|
import { logger } from '../logging/logger'
|
||||||
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
|
import { getEffectiveCommitProfilePath, getSettings } from '../settings/store'
|
||||||
@@ -39,6 +39,9 @@ import { PALETTE, resolveIssueColor, themeColorIdToIconUri } from './issueColor'
|
|||||||
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
|
/** Resolved at runtime via getProfilesDir() — do not hardcode user paths. */
|
||||||
export { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../cc/profiles'
|
export { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../cc/profiles'
|
||||||
|
|
||||||
|
/** workspaceState key:看板范围('mine' 只看我的 / 'all' 团队全部)。 */
|
||||||
|
const SCOPE_KEY = 'superpowers.kanbanScope'
|
||||||
|
|
||||||
export class KanbanWebviewPanel {
|
export class KanbanWebviewPanel {
|
||||||
static readonly viewType = 'superpowers.kanbanPanel'
|
static readonly viewType = 'superpowers.kanbanPanel'
|
||||||
|
|
||||||
@@ -108,9 +111,11 @@ export class KanbanWebviewPanel {
|
|||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
inboxDir: string
|
inboxDir: string
|
||||||
terminalName: string
|
terminalName: string
|
||||||
/** The brainstorm terminal created synchronously in `handleIssueCreate`.
|
/**
|
||||||
|
* The brainstorm terminal created synchronously in `handleIssueCreate`.
|
||||||
* Stored here so `linkPendingTerminalToIssue` can promote it into
|
* Stored here so `linkPendingTerminalToIssue` can promote it into
|
||||||
* `newIssueTerminals` once the webhook tells us the issueNumber. */
|
* `newIssueTerminals` once the webhook tells us the issueNumber.
|
||||||
|
*/
|
||||||
terminal: Terminal
|
terminal: Terminal
|
||||||
createdAt: number
|
createdAt: number
|
||||||
}>()
|
}>()
|
||||||
@@ -360,6 +365,12 @@ export class KanbanWebviewPanel {
|
|||||||
void this.loadAndPush()
|
void this.loadAndPush()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (msg.type === 'issues/set-scope') {
|
||||||
|
// 视图偏好按工作区持久化;切换即重拉。
|
||||||
|
void this.context.workspaceState.update(SCOPE_KEY, msg.scope)
|
||||||
|
void this.loadAndPush()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (msg.type === 'settings/save') {
|
if (msg.type === 'settings/save') {
|
||||||
void settings.handleSettingsSave(this, msg)
|
void settings.handleSettingsSave(this, msg)
|
||||||
return
|
return
|
||||||
@@ -594,7 +605,6 @@ export class KanbanWebviewPanel {
|
|||||||
}
|
}
|
||||||
if (msg.type === 'pr-diff-mode/set') {
|
if (msg.type === 'pr-diff-mode/set') {
|
||||||
void prFiles.handleSetPrDiffMode(this, msg.mode)
|
void prFiles.handleSetPrDiffMode(this, msg.mode)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,7 +787,6 @@ export class KanbanWebviewPanel {
|
|||||||
return worktree.dispatchImplTabPostCloseAsync(this, issueNumber)
|
return worktree.dispatchImplTabPostCloseAsync(this, issueNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side lock check for prerequisite gating. Fetches a fresh issues
|
* Server-side lock check for prerequisite gating. Fetches a fresh issues
|
||||||
* snapshot (we don't trust webview state) and reports whether
|
* snapshot (we don't trust webview state) and reports whether
|
||||||
@@ -846,6 +855,7 @@ export class KanbanWebviewPanel {
|
|||||||
host,
|
host,
|
||||||
tokenSaved: false,
|
tokenSaved: false,
|
||||||
webhookPort: s.webhookPort,
|
webhookPort: s.webhookPort,
|
||||||
|
webhookSecret: s.webhookSecret,
|
||||||
brainstormPrompt: s.brainstormPrompt,
|
brainstormPrompt: s.brainstormPrompt,
|
||||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||||
implementPlanPrompt: s.implementPlanPrompt,
|
implementPlanPrompt: s.implementPlanPrompt,
|
||||||
@@ -869,8 +879,9 @@ export class KanbanWebviewPanel {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scope = this.context.workspaceState.get<'mine' | 'all'>(SCOPE_KEY) ?? 'mine'
|
||||||
try {
|
try {
|
||||||
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot })
|
const giteaIssues = await loadIssues({ host, token, owner, repo, workspaceRoot, scope })
|
||||||
// YouTrack is a best-effort second source: a failure here must never
|
// YouTrack is a best-effort second source: a failure here must never
|
||||||
// break the gitea board, so swallow it into a toast + log.
|
// break the gitea board, so swallow it into a toast + log.
|
||||||
let youtrackList: Issue[] = []
|
let youtrackList: Issue[] = []
|
||||||
@@ -888,7 +899,10 @@ export class KanbanWebviewPanel {
|
|||||||
dismissOnTimer: 6000,
|
dismissOnTimer: 6000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
const issues = this.withLiveTerminalTabState([...giteaIssues, ...youtrackList])
|
// 本机记录(会话 id / worktree 等)叠加在共享状态之上,再补 live terminal 态。
|
||||||
|
const issues = this.withLiveTerminalTabState(
|
||||||
|
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
|
||||||
|
)
|
||||||
this.issueRefs = new Map(
|
this.issueRefs = new Map(
|
||||||
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
|
issues.map(i => [i.number, { source: i.source ?? 'gitea', externalId: i.externalId }] as const),
|
||||||
)
|
)
|
||||||
@@ -896,6 +910,7 @@ export class KanbanWebviewPanel {
|
|||||||
this.postMessage({
|
this.postMessage({
|
||||||
type: 'issues/update',
|
type: 'issues/update',
|
||||||
issues,
|
issues,
|
||||||
|
scope,
|
||||||
globalAutoReview: ytSettings.autoReview,
|
globalAutoReview: ytSettings.autoReview,
|
||||||
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
|
youtrackConfigured: ytSettings.youtrackBaseUrl.trim() !== '' && ytSettings.youtrackProjectShortName.trim() !== '',
|
||||||
})
|
})
|
||||||
@@ -914,6 +929,7 @@ export class KanbanWebviewPanel {
|
|||||||
errorMessage: 'Token 无效或已过期,请重新填写',
|
errorMessage: 'Token 无效或已过期,请重新填写',
|
||||||
tokenSaved: false,
|
tokenSaved: false,
|
||||||
webhookPort: s.webhookPort,
|
webhookPort: s.webhookPort,
|
||||||
|
webhookSecret: s.webhookSecret,
|
||||||
brainstormPrompt: s.brainstormPrompt,
|
brainstormPrompt: s.brainstormPrompt,
|
||||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||||
implementPlanPrompt: s.implementPlanPrompt,
|
implementPlanPrompt: s.implementPlanPrompt,
|
||||||
@@ -970,8 +986,10 @@ export class KanbanWebviewPanel {
|
|||||||
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
|
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve/close the issue in its tracker. Returns false when a youtrack
|
/**
|
||||||
* close command can't be determined. */
|
* Resolve/close the issue in its tracker. Returns false when a youtrack
|
||||||
|
* close command can't be determined.
|
||||||
|
*/
|
||||||
// internal: handler 模块访问
|
// internal: handler 模块访问
|
||||||
closeIssueByNumber(issueNumber: number): Promise<boolean> {
|
closeIssueByNumber(issueNumber: number): Promise<boolean> {
|
||||||
return closeIssueByRef(this.context, this.refFor(issueNumber))
|
return closeIssueByRef(this.context, this.refFor(issueNumber))
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ import * as fs from 'node:fs'
|
|||||||
import { promises as fsp } from 'node:fs'
|
import { promises as fsp } from 'node:fs'
|
||||||
import * as os from 'node:os'
|
import * as os from 'node:os'
|
||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
|
||||||
import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode'
|
import { commands, env, ThemeColor, Uri, window, workspace } from 'vscode'
|
||||||
import { getToken } from '../../auth/secrets'
|
import { getToken } from '../../auth/secrets'
|
||||||
import { buildCcCommand } from '../../cc/ccCommand'
|
import { buildCcCommand } from '../../cc/ccCommand'
|
||||||
|
import { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../../cc/profiles'
|
||||||
import { getBrainstormPrompt } from '../../cc/prompts'
|
import { getBrainstormPrompt } from '../../cc/prompts'
|
||||||
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
import { projectsDirFor, watchForNewSession } from '../../cc/sessionWatcher'
|
||||||
import { spawnClaude } from '../../cc/spawnClaude'
|
import { spawnClaude } from '../../cc/spawnClaude'
|
||||||
import { gitFetch, resolveFeatureBranch } from '../../git/branchSync'
|
import { gitFetch, resolveFeatureBranch } from '../../git/branchSync'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
|
import { killProcessesUsingWorktree, removeWorktreeDir, resolveWorktreePath } from '../../git/worktree'
|
||||||
import {
|
import {
|
||||||
addDependency,
|
addDependency,
|
||||||
closeIssue,
|
closeIssue,
|
||||||
@@ -31,7 +32,6 @@ import { logger } from '../../logging/logger'
|
|||||||
import { getSettings } from '../../settings/store'
|
import { getSettings } from '../../settings/store'
|
||||||
import { webhookCoordinator } from '../../webhook/coordinator'
|
import { webhookCoordinator } from '../../webhook/coordinator'
|
||||||
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
|
import { pickRandomIssueColor, themeColorIdToIconUri } from '../issueColor'
|
||||||
import { getDefaultProfilePath, getPrDiffSummaryProfilePath } from '../../cc/profiles'
|
|
||||||
import { makeNonce } from '../KanbanPanel'
|
import { makeNonce } from '../KanbanPanel'
|
||||||
import { cleanupFeatureBranch } from './branchCleanup'
|
import { cleanupFeatureBranch } from './branchCleanup'
|
||||||
import * as sessions from './sessions'
|
import * as sessions from './sessions'
|
||||||
@@ -345,22 +345,16 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. PR is merged — persist column='done' + prMerged=true + 清空 worktreePath
|
// 4. PR is merged — persist column='done' + prMerged=true + 清空 worktreePath。
|
||||||
// 到 state JSON。state JSON 用空字符串清空(loader 把 length===0 视为 unset)。
|
// 走 mergeIssueState 漏斗:worktreePath 是本机字段落 workspaceState,
|
||||||
|
// 其余共享字段进 state JSON(空字符串清空,loader 把 length===0 视为 unset)。
|
||||||
try {
|
try {
|
||||||
await mergeStateJsonComment({
|
await panel.mergeIssueState(issueNumber, {
|
||||||
host: remote.host,
|
column: 'done',
|
||||||
owner: remote.owner,
|
worktreePath: '',
|
||||||
repo: remote.repo,
|
branch: '',
|
||||||
token,
|
prMerged: true,
|
||||||
issueNumber,
|
prMergedAt: pullRequest.merged_at ?? new Date().toISOString(),
|
||||||
extra: {
|
|
||||||
column: 'done',
|
|
||||||
worktreePath: '',
|
|
||||||
branch: '',
|
|
||||||
prMerged: true,
|
|
||||||
prMergedAt: pullRequest.merged_at ?? new Date().toISOString(),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
@@ -2035,14 +2029,8 @@ export async function handleGeneratePrDiffSummary(panel: KanbanWebviewPanel, iss
|
|||||||
fs.mkdirSync(path.dirname(outputAbsPath), { recursive: true })
|
fs.mkdirSync(path.dirname(outputAbsPath), { recursive: true })
|
||||||
fs.writeFileSync(outputAbsPath, summary, 'utf8')
|
fs.writeFileSync(outputAbsPath, summary, 'utf8')
|
||||||
|
|
||||||
await mergeStateJsonComment({
|
// prDiffFile 是本机生成的文件路径,经漏斗落 workspaceState,不进共享评论。
|
||||||
host: remote.host,
|
await panel.mergeIssueState(issueNumber, { prDiffFile: outputRelPath })
|
||||||
owner: remote.owner,
|
|
||||||
repo: remote.repo,
|
|
||||||
token,
|
|
||||||
issueNumber,
|
|
||||||
extra: { prDiffFile: outputRelPath },
|
|
||||||
})
|
|
||||||
panel.postMessage({
|
panel.postMessage({
|
||||||
type: 'issue/patch',
|
type: 'issue/patch',
|
||||||
issueNumber,
|
issueNumber,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
@@ -90,6 +91,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
errorMessage: 'Host 和 Token 都不能为空',
|
errorMessage: 'Host 和 Token 都不能为空',
|
||||||
tokenSaved: !!oldToken,
|
tokenSaved: !!oldToken,
|
||||||
webhookPort: payload.webhookPort,
|
webhookPort: payload.webhookPort,
|
||||||
|
webhookSecret: payload.webhookSecret,
|
||||||
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
|
brainstormPrompt: payload.brainstormPrompt || prev.brainstormPrompt,
|
||||||
brainstormContinuePrompt: payload.brainstormContinuePrompt || prev.brainstormContinuePrompt,
|
brainstormContinuePrompt: payload.brainstormContinuePrompt || prev.brainstormContinuePrompt,
|
||||||
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
|
implementPlanPrompt: payload.implementPlanPrompt || prev.implementPlanPrompt,
|
||||||
@@ -114,6 +116,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
}
|
}
|
||||||
await saveSettings(panel.context, {
|
await saveSettings(panel.context, {
|
||||||
webhookPort: payload.webhookPort,
|
webhookPort: payload.webhookPort,
|
||||||
|
webhookSecret: payload.webhookSecret.trim(),
|
||||||
brainstormPrompt: payload.brainstormPrompt,
|
brainstormPrompt: payload.brainstormPrompt,
|
||||||
brainstormContinuePrompt: payload.brainstormContinuePrompt,
|
brainstormContinuePrompt: payload.brainstormContinuePrompt,
|
||||||
implementPlanPrompt: payload.implementPlanPrompt,
|
implementPlanPrompt: payload.implementPlanPrompt,
|
||||||
@@ -164,6 +167,7 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
message: `端口配置变更,重启监听 :${newPort}`,
|
message: `端口配置变更,重启监听 :${newPort}`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
webhookCoordinator.ensureSecret(getSettings(panel.context).webhookSecret)
|
||||||
try {
|
try {
|
||||||
await webhookCoordinator.ensurePort(newPort)
|
await webhookCoordinator.ensurePort(newPort)
|
||||||
}
|
}
|
||||||
@@ -216,6 +220,7 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
|||||||
canCancel: true,
|
canCancel: true,
|
||||||
tokenSaved,
|
tokenSaved,
|
||||||
webhookPort: s.webhookPort,
|
webhookPort: s.webhookPort,
|
||||||
|
webhookSecret: s.webhookSecret,
|
||||||
brainstormPrompt: s.brainstormPrompt,
|
brainstormPrompt: s.brainstormPrompt,
|
||||||
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
brainstormContinuePrompt: s.brainstormContinuePrompt,
|
||||||
implementPlanPrompt: s.implementPlanPrompt,
|
implementPlanPrompt: s.implementPlanPrompt,
|
||||||
@@ -310,12 +315,52 @@ export async function handleProfilesList(panel: KanbanWebviewPanel): Promise<voi
|
|||||||
* are treated as text too — is handed to the OS default application.
|
* are treated as text too — is handed to the OS default application.
|
||||||
*/
|
*/
|
||||||
const TEXT_OPEN_EXTENSIONS = new Set([
|
const TEXT_OPEN_EXTENSIONS = new Set([
|
||||||
'.md', '.markdown', '.txt', '.text', '.csv', '.tsv', '.json', '.jsonc',
|
'.md',
|
||||||
'.yaml', '.yml', '.toml', '.ini', '.conf', '.cfg', '.log', '.xml', '.html',
|
'.markdown',
|
||||||
'.htm', '.css', '.scss', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx',
|
'.txt',
|
||||||
'.vue', '.py', '.go', '.rs', '.java', '.kt', '.c', '.h', '.cpp', '.hpp',
|
'.text',
|
||||||
'.cc', '.sh', '.bash', '.zsh', '.sql', '.env', '.properties', '.gradle',
|
'.csv',
|
||||||
'.dockerfile', '.gitignore',
|
'.tsv',
|
||||||
|
'.json',
|
||||||
|
'.jsonc',
|
||||||
|
'.yaml',
|
||||||
|
'.yml',
|
||||||
|
'.toml',
|
||||||
|
'.ini',
|
||||||
|
'.conf',
|
||||||
|
'.cfg',
|
||||||
|
'.log',
|
||||||
|
'.xml',
|
||||||
|
'.html',
|
||||||
|
'.htm',
|
||||||
|
'.css',
|
||||||
|
'.scss',
|
||||||
|
'.js',
|
||||||
|
'.mjs',
|
||||||
|
'.cjs',
|
||||||
|
'.ts',
|
||||||
|
'.tsx',
|
||||||
|
'.jsx',
|
||||||
|
'.vue',
|
||||||
|
'.py',
|
||||||
|
'.go',
|
||||||
|
'.rs',
|
||||||
|
'.java',
|
||||||
|
'.kt',
|
||||||
|
'.c',
|
||||||
|
'.h',
|
||||||
|
'.cpp',
|
||||||
|
'.hpp',
|
||||||
|
'.cc',
|
||||||
|
'.sh',
|
||||||
|
'.bash',
|
||||||
|
'.zsh',
|
||||||
|
'.sql',
|
||||||
|
'.env',
|
||||||
|
'.properties',
|
||||||
|
'.gradle',
|
||||||
|
'.dockerfile',
|
||||||
|
'.gitignore',
|
||||||
])
|
])
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export type ToastLevel = 'info' | 'success' | 'error'
|
|||||||
|
|
||||||
export type ExtensionToWebview
|
export type ExtensionToWebview
|
||||||
= | { type: 'issues/loading' }
|
= | { type: 'issues/loading' }
|
||||||
| { type: 'issues/update', issues: Issue[], globalAutoReview: boolean, youtrackConfigured: boolean }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
@@ -53,6 +53,7 @@ export type ExtensionToWebview
|
|||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
@@ -117,11 +118,13 @@ export type ExtensionToWebview
|
|||||||
|
|
||||||
export type WebviewToExtension
|
export type WebviewToExtension
|
||||||
= | { type: 'issues/refresh' }
|
= | { type: 'issues/refresh' }
|
||||||
|
| { type: 'issues/set-scope', scope: 'mine' | 'all' }
|
||||||
| {
|
| {
|
||||||
type: 'settings/save'
|
type: 'settings/save'
|
||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
|
|||||||
@@ -10,11 +10,11 @@
|
|||||||
* a field.
|
* a field.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionContext } from 'vscode'
|
||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import type { ExtensionContext } from 'vscode'
|
|
||||||
import { listClaudeProfiles } from '../cc/profiles'
|
|
||||||
import { seedCommitProfilePath } from '../cc/commitProfile'
|
import { seedCommitProfilePath } from '../cc/commitProfile'
|
||||||
|
import { listClaudeProfiles } from '../cc/profiles'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fallback prompt strings used when the on-disk markdown can't be read.
|
* Fallback prompt strings used when the on-disk markdown can't be read.
|
||||||
@@ -46,7 +46,6 @@ export function readDefaultPrompt(extensionPath: string, name: PromptName): stri
|
|||||||
return readFileSync(filePath, 'utf-8')
|
return readFileSync(filePath, 'utf-8')
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(`[spx] 读 ${filePath} 失败,使用内联 fallback:`, err)
|
console.warn(`[spx] 读 ${filePath} 失败,使用内联 fallback:`, err)
|
||||||
switch (name) {
|
switch (name) {
|
||||||
case 'brainstorm': return FALLBACK_BRAINSTORM_PROMPT
|
case 'brainstorm': return FALLBACK_BRAINSTORM_PROMPT
|
||||||
@@ -62,6 +61,12 @@ export const DEFAULT_WEBHOOK_PORT = 17421
|
|||||||
export interface Settings {
|
export interface Settings {
|
||||||
/** Local HTTP port for receiving gitea webhook callbacks. */
|
/** Local HTTP port for receiving gitea webhook callbacks. */
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
/**
|
||||||
|
* Gitea webhook 的 secret,用于校验 `X-Gitea-Signature`(HMAC-SHA256)。
|
||||||
|
* webhook 经 frp 暴露公网,不校验等于任何人都能伪造事件触发 codex。
|
||||||
|
* 空 = 不校验(未在 Gitea 侧配置 secret 的过渡状态)。
|
||||||
|
*/
|
||||||
|
webhookSecret: string
|
||||||
/**
|
/**
|
||||||
* Prompt template for the brainstorming flow (issue creation + ongoing
|
* Prompt template for the brainstorming flow (issue creation + ongoing
|
||||||
* session conventions for spec/plan body annotations). `{userRequest}`
|
* session conventions for spec/plan body annotations). `{userRequest}`
|
||||||
@@ -198,6 +203,7 @@ export const SETTINGS_KEY = 'superpowers.settings'
|
|||||||
function defaults(ctx: ExtensionContext): Settings {
|
function defaults(ctx: ExtensionContext): Settings {
|
||||||
return {
|
return {
|
||||||
webhookPort: DEFAULT_WEBHOOK_PORT,
|
webhookPort: DEFAULT_WEBHOOK_PORT,
|
||||||
|
webhookSecret: '',
|
||||||
brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
|
brainstormPrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm'),
|
||||||
brainstormContinuePrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue'),
|
brainstormContinuePrompt: readDefaultPrompt(ctx.extensionPath, 'brainstorm-continue'),
|
||||||
implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'),
|
implementPlanPrompt: readDefaultPrompt(ctx.extensionPath, 'implement-plan'),
|
||||||
@@ -231,6 +237,10 @@ export function getSettings(ctx: ExtensionContext): Settings {
|
|||||||
&& stored.webhookPort >= 1 && stored.webhookPort <= 65535
|
&& stored.webhookPort >= 1 && stored.webhookPort <= 65535
|
||||||
? stored.webhookPort
|
? stored.webhookPort
|
||||||
: base.webhookPort
|
: base.webhookPort
|
||||||
|
// webhook secret:'' 有意义(= 不校验),只认字符串类型。
|
||||||
|
const webhookSecret = typeof stored.webhookSecret === 'string'
|
||||||
|
? stored.webhookSecret
|
||||||
|
: base.webhookSecret
|
||||||
|
|
||||||
const brainstormPrompt = typeof stored.brainstormPrompt === 'string' && stored.brainstormPrompt.length > 0
|
const brainstormPrompt = typeof stored.brainstormPrompt === 'string' && stored.brainstormPrompt.length > 0
|
||||||
? stored.brainstormPrompt
|
? stored.brainstormPrompt
|
||||||
@@ -323,6 +333,7 @@ export function getSettings(ctx: ExtensionContext): Settings {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
webhookPort,
|
webhookPort,
|
||||||
|
webhookSecret,
|
||||||
brainstormPrompt,
|
brainstormPrompt,
|
||||||
brainstormContinuePrompt,
|
brainstormContinuePrompt,
|
||||||
implementPlanPrompt,
|
implementPlanPrompt,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { detectRepo } from '../git/remote'
|
|||||||
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
|
import { deleteWebhook, getIssue, getPullRequest, listIssueComments } from '../gitea/api'
|
||||||
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
|
import { loadIssues, loadSingleIssue } from '../gitea/issueLoader'
|
||||||
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
|
import { mergeStateJsonComment, mergeStateJsonCommentGuarded, readStateJsonComment } from '../gitea/stateJson'
|
||||||
|
import { getLocalIssueState, mergeLocalIssueState, overlayLocalIssueState } from '../issues/localState'
|
||||||
import { logger } from '../logging/logger'
|
import { logger } from '../logging/logger'
|
||||||
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
|
import { hasLiveIssueSessionTerminal } from '../panel/handlers/terminals'
|
||||||
import { getSettings } from '../settings/store'
|
import { getSettings } from '../settings/store'
|
||||||
@@ -86,6 +87,7 @@ class WebhookCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.server = new WebhookServer()
|
this.server = new WebhookServer()
|
||||||
|
this.server.setSecret(getSettings(ctx).webhookSecret)
|
||||||
this.eventSubscription = this.server.onEvent((event: WebhookEvent) => {
|
this.eventSubscription = this.server.onEvent((event: WebhookEvent) => {
|
||||||
void this.handleEvent(event)
|
void this.handleEvent(event)
|
||||||
})
|
})
|
||||||
@@ -160,6 +162,11 @@ class WebhookCoordinator {
|
|||||||
await srv.start(port)
|
await srv.start(port)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 设置面板保存后热更新签名 secret,无需重启监听。 */
|
||||||
|
ensureSecret(secret: string): void {
|
||||||
|
this.server?.setSecret(secret)
|
||||||
|
}
|
||||||
|
|
||||||
/** Register a pending hook after createWebhook on gitea succeeded. */
|
/** Register a pending hook after createWebhook on gitea succeeded. */
|
||||||
async addPending(issueNumber: number, info: PendingHook): Promise<void> {
|
async addPending(issueNumber: number, info: PendingHook): Promise<void> {
|
||||||
this.pendingHooks.set(issueNumber, info)
|
this.pendingHooks.set(issueNumber, info)
|
||||||
@@ -529,14 +536,15 @@ class WebhookCoordinator {
|
|||||||
message: `匹配到 pending 创建 nonce=${nonce} → 写入 state JSON`,
|
message: `匹配到 pending 创建 nonce=${nonce} → 写入 state JSON`,
|
||||||
details: `issue=#${event.issueNumber} sessionId=${pending.sessionId ?? '<待定>'}`,
|
details: `issue=#${event.issueNumber} sessionId=${pending.sessionId ?? '<待定>'}`,
|
||||||
})
|
})
|
||||||
const extra: Record<string, unknown> = {
|
// 会话 id / profile 路径是本机状态,落 workspaceState;共享评论只写
|
||||||
column: 'todo',
|
// column / color 这类团队可见字段。
|
||||||
color: pending.color,
|
const localPatch: Record<string, unknown> = {}
|
||||||
}
|
|
||||||
if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0)
|
if (typeof pending.sessionId === 'string' && pending.sessionId.length > 0)
|
||||||
extra.sessionId = pending.sessionId
|
localPatch.sessionId = pending.sessionId
|
||||||
if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0)
|
if (typeof pending.brainstormProfilePath === 'string' && pending.brainstormProfilePath.length > 0)
|
||||||
extra.brainstormProfilePath = pending.brainstormProfilePath
|
localPatch.brainstormProfilePath = pending.brainstormProfilePath
|
||||||
|
if (Object.keys(localPatch).length > 0)
|
||||||
|
await mergeLocalIssueState(this.ctx, 'gitea', event.issueNumber, localPatch)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await mergeStateJsonComment({
|
await mergeStateJsonComment({
|
||||||
@@ -545,7 +553,10 @@ class WebhookCoordinator {
|
|||||||
repo: remote.repo,
|
repo: remote.repo,
|
||||||
token,
|
token,
|
||||||
issueNumber: event.issueNumber,
|
issueNumber: event.issueNumber,
|
||||||
extra,
|
extra: {
|
||||||
|
column: 'todo',
|
||||||
|
color: pending.color,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -589,7 +600,7 @@ class WebhookCoordinator {
|
|||||||
|
|
||||||
if (this.activePanel) {
|
if (this.activePanel) {
|
||||||
try {
|
try {
|
||||||
const issue = await loadSingleIssue({
|
const loaded = await loadSingleIssue({
|
||||||
host: remote.host,
|
host: remote.host,
|
||||||
owner: remote.owner,
|
owner: remote.owner,
|
||||||
repo: remote.repo,
|
repo: remote.repo,
|
||||||
@@ -597,6 +608,7 @@ class WebhookCoordinator {
|
|||||||
workspaceRoot: ws,
|
workspaceRoot: ws,
|
||||||
issueNumber: event.issueNumber,
|
issueNumber: event.issueNumber,
|
||||||
})
|
})
|
||||||
|
const issue = loaded ? overlayLocalIssueState(this.ctx, [loaded], ws)[0] : null
|
||||||
if (issue) {
|
if (issue) {
|
||||||
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
|
this.activePanel.postMessage({ type: 'issue/append', issue, select: pending ? true : undefined })
|
||||||
if (pending) {
|
if (pending) {
|
||||||
@@ -1473,39 +1485,35 @@ class WebhookCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-fetch state JSON to pick up the issue's worktreePath. We don't
|
// worktree 是本机状态:优先 workspaceState 本地记录('' 墓碑 = 本机已删,
|
||||||
// hard-fail if it's missing — triggerAutoReviewTab will fall back to
|
// 不回落);本地无记录时读共享评论兜底(存量数据)。缺失不硬失败——
|
||||||
// workspaceRoot and toast the user.
|
// triggerAutoReviewTab 会回退 workspaceRoot 并 toast。
|
||||||
let worktreePath = ''
|
let worktreePath = ''
|
||||||
try {
|
const local = getLocalIssueState(this.ctx, 'gitea', issueNumber)
|
||||||
const comments = await listIssueComments({
|
if (local.worktreePath !== undefined) {
|
||||||
host: ctx.host,
|
worktreePath = local.worktreePath
|
||||||
token: ctx.token,
|
|
||||||
owner: ctx.owner,
|
|
||||||
repo: ctx.repo,
|
|
||||||
index: issueNumber,
|
|
||||||
})
|
|
||||||
const last = comments[comments.length - 1]
|
|
||||||
const body = (last?.body ?? '').trim()
|
|
||||||
if (body) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(body) as { worktreePath?: unknown }
|
|
||||||
if (typeof parsed?.worktreePath === 'string' && parsed.worktreePath.length > 0)
|
|
||||||
worktreePath = parsed.worktreePath
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
// last comment isn't JSON — proceed without a worktree.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
else {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
try {
|
||||||
logger.add({
|
const state = await readStateJsonComment({
|
||||||
level: 'warn',
|
host: ctx.host,
|
||||||
source: 'webhook',
|
token: ctx.token,
|
||||||
message: `读取 state JSON 失败(triggerReview)#${issueNumber}`,
|
owner: ctx.owner,
|
||||||
details: message,
|
repo: ctx.repo,
|
||||||
})
|
issueNumber,
|
||||||
|
})
|
||||||
|
if (typeof state.worktreePath === 'string')
|
||||||
|
worktreePath = state.worktreePath
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
logger.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'webhook',
|
||||||
|
message: `读取 state JSON 失败(triggerReview)#${issueNumber}`,
|
||||||
|
details: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.add({
|
logger.add({
|
||||||
|
|||||||
@@ -24,6 +24,8 @@
|
|||||||
|
|
||||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||||
import type { Event } from 'vscode'
|
import type { Event } from 'vscode'
|
||||||
|
import { Buffer } from 'node:buffer'
|
||||||
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
||||||
import { createServer } from 'node:http'
|
import { createServer } from 'node:http'
|
||||||
import { EventEmitter } from 'vscode'
|
import { EventEmitter } from 'vscode'
|
||||||
import { logger } from '../logging/logger'
|
import { logger } from '../logging/logger'
|
||||||
@@ -111,10 +113,33 @@ export interface PushWebhookEvent {
|
|||||||
export class WebhookServer {
|
export class WebhookServer {
|
||||||
private server?: Server
|
private server?: Server
|
||||||
private port?: number
|
private port?: number
|
||||||
|
private secret = ''
|
||||||
private readonly emitter = new EventEmitter<WebhookEvent>()
|
private readonly emitter = new EventEmitter<WebhookEvent>()
|
||||||
|
|
||||||
readonly onEvent: Event<WebhookEvent> = this.emitter.event
|
readonly onEvent: Event<WebhookEvent> = this.emitter.event
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置(或清空)Gitea webhook secret。非空时对每个请求校验
|
||||||
|
* `X-Gitea-Signature`(HMAC-SHA256 over 原始 body 字节),不匹配一律 401
|
||||||
|
* ——端口经 frp 暴露公网,没有签名任何人都能伪造事件触发 codex 执行。
|
||||||
|
*/
|
||||||
|
setSecret(secret: string): void {
|
||||||
|
this.secret = secret
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 时序安全的签名比对;头缺失/长度不符/非法 hex 都按不通过处理。 */
|
||||||
|
private verifySignature(rawBody: Buffer, signatureHeader: string): boolean {
|
||||||
|
const expected = createHmac('sha256', this.secret).update(rawBody).digest()
|
||||||
|
let received: Buffer
|
||||||
|
try {
|
||||||
|
received = Buffer.from(signatureHeader, 'hex')
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return received.length === expected.length && timingSafeEqual(received, expected)
|
||||||
|
}
|
||||||
|
|
||||||
/** The port currently bound, or undefined if the server is not listening. */
|
/** The port currently bound, or undefined if the server is not listening. */
|
||||||
get currentPort(): number | undefined {
|
get currentPort(): number | undefined {
|
||||||
return this.port
|
return this.port
|
||||||
@@ -220,7 +245,21 @@ export class WebhookServer {
|
|||||||
req.on('data', (c: Buffer) => chunks.push(c))
|
req.on('data', (c: Buffer) => chunks.push(c))
|
||||||
req.on('end', () => {
|
req.on('end', () => {
|
||||||
try {
|
try {
|
||||||
const body = Buffer.concat(chunks).toString('utf-8')
|
const rawBody = Buffer.concat(chunks)
|
||||||
|
if (this.secret) {
|
||||||
|
const signatureHeader = (req.headers['x-gitea-signature'] || '').toString()
|
||||||
|
if (!signatureHeader || !this.verifySignature(rawBody, signatureHeader)) {
|
||||||
|
logger.add({
|
||||||
|
level: 'warn',
|
||||||
|
source: 'webhook',
|
||||||
|
message: `签名校验失败,拒绝请求 (signature=${signatureHeader ? '不匹配' : '<缺失>'})`,
|
||||||
|
})
|
||||||
|
res.writeHead(401, { 'Content-Type': 'application/json' })
|
||||||
|
res.end(JSON.stringify({ ok: false, error: 'invalid_signature' }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = rawBody.toString('utf-8')
|
||||||
const eventHeader = (req.headers['x-gitea-event'] || '').toString() || '<missing>'
|
const eventHeader = (req.headers['x-gitea-event'] || '').toString() || '<missing>'
|
||||||
logger.add({
|
logger.add({
|
||||||
level: 'info',
|
level: 'info',
|
||||||
|
|||||||
@@ -133,11 +133,17 @@ describe('state JSON 尾部普通评论不丢状态', () => {
|
|||||||
worktreePath: '.claude/worktrees/be3beabc',
|
worktreePath: '.claude/worktrees/be3beabc',
|
||||||
implementStatus: 'running',
|
implementStatus: 'running',
|
||||||
}
|
}
|
||||||
listIssueComments.mockResolvedValue([
|
// merge 现在 post 后会读回校验(乐观并发检测),mock 必须有状态:
|
||||||
|
// postIssueComment 之后列表要包含新评论,否则校验误判丢失导致重放。
|
||||||
|
const comments = [
|
||||||
{ body: JSON.stringify(fullState) },
|
{ body: JSON.stringify(fullState) },
|
||||||
{ body: '这是一条普通评论,不是 state JSON' },
|
{ body: '这是一条普通评论,不是 state JSON' },
|
||||||
{ body: JSON.stringify({ note: '像 JSON 但没有任何已知 state 字段' }) },
|
{ body: JSON.stringify({ note: '像 JSON 但没有任何已知 state 字段' }) },
|
||||||
])
|
]
|
||||||
|
listIssueComments.mockImplementation(async () => [...comments])
|
||||||
|
postIssueComment.mockImplementation(async (opts: { body: string }) => {
|
||||||
|
comments.push({ body: opts.body })
|
||||||
|
})
|
||||||
|
|
||||||
await mergeStateJsonComment({
|
await mergeStateJsonComment({
|
||||||
host: 'https://gitea.example',
|
host: 'https://gitea.example',
|
||||||
@@ -156,6 +162,40 @@ describe('state JSON 尾部普通评论不丢状态', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('post 后读回发现字段被并发覆盖时,在最新状态上重放一次', async () => {
|
||||||
|
const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js')
|
||||||
|
const comments: Array<{ body: string }> = [
|
||||||
|
{ body: JSON.stringify({ column: 'in-progress' }) },
|
||||||
|
]
|
||||||
|
listIssueComments.mockImplementation(async () => [...comments])
|
||||||
|
let postCount = 0
|
||||||
|
postIssueComment.mockImplementation(async (opts: { body: string }) => {
|
||||||
|
postCount++
|
||||||
|
if (postCount === 1) {
|
||||||
|
// 模拟并发写者在我们 post 之后又盖了一条:我们的字段丢失。
|
||||||
|
comments.push({ body: JSON.stringify({ column: 'review' }) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
comments.push({ body: opts.body })
|
||||||
|
})
|
||||||
|
|
||||||
|
await mergeStateJsonComment({
|
||||||
|
host: 'https://gitea.example',
|
||||||
|
owner: 'owner',
|
||||||
|
repo: 'repo',
|
||||||
|
token: 'token',
|
||||||
|
issueNumber: 7,
|
||||||
|
extra: { pr: '99' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(postIssueComment).toHaveBeenCalledTimes(2)
|
||||||
|
// 重放基于并发写者的最新状态,两边的字段都保留。
|
||||||
|
expect(JSON.parse(postIssueComment.mock.calls[1][0].body)).toEqual({
|
||||||
|
column: 'review',
|
||||||
|
pr: '99',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('read 时跳过尾部普通评论返回最近一条 state JSON', async () => {
|
it('read 时跳过尾部普通评论返回最近一条 state JSON', async () => {
|
||||||
const { readStateJsonComment } = await import('../../src/gitea/stateJson.js')
|
const { readStateJsonComment } = await import('../../src/gitea/stateJson.js')
|
||||||
listIssueComments.mockResolvedValue([
|
listIssueComments.mockResolvedValue([
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type { PastedImage } from './components/NewIssueModal'
|
||||||
|
import type { Issue } from './types'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { BottomTabs } from './components/BottomTabs'
|
import { BottomTabs } from './components/BottomTabs'
|
||||||
import { KanbanBoard } from './components/KanbanBoard'
|
import { KanbanBoard } from './components/KanbanBoard'
|
||||||
import { LogModal } from './components/LogModal'
|
import { LogModal } from './components/LogModal'
|
||||||
import type { PastedImage } from './components/NewIssueModal'
|
|
||||||
import { NewIssueModal } from './components/NewIssueModal'
|
import { NewIssueModal } from './components/NewIssueModal'
|
||||||
import { PanelHeader } from './components/PanelHeader'
|
import { PanelHeader } from './components/PanelHeader'
|
||||||
import { SettingsModal } from './components/SettingsModal'
|
import { SettingsModal } from './components/SettingsModal'
|
||||||
@@ -11,7 +12,6 @@ import { useIssues } from './hooks/useIssues'
|
|||||||
import { useManagedSessions } from './hooks/useManagedSessions'
|
import { useManagedSessions } from './hooks/useManagedSessions'
|
||||||
import { useProfiles } from './hooks/useProfiles'
|
import { useProfiles } from './hooks/useProfiles'
|
||||||
import { compareIssuesInColumn } from './lib/issueSort'
|
import { compareIssuesInColumn } from './lib/issueSort'
|
||||||
import type { Issue } from './types'
|
|
||||||
import { COLUMN_ORDER } from './types'
|
import { COLUMN_ORDER } from './types'
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
@@ -23,6 +23,8 @@ export function App() {
|
|||||||
importYouTrack,
|
importYouTrack,
|
||||||
toasts,
|
toasts,
|
||||||
profiles,
|
profiles,
|
||||||
|
scope,
|
||||||
|
toggleScope,
|
||||||
setIssues,
|
setIssues,
|
||||||
refresh,
|
refresh,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
@@ -283,6 +285,8 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
scope={scope}
|
||||||
|
onToggleScope={toggleScope}
|
||||||
onEditAuth={requestEditAuth}
|
onEditAuth={requestEditAuth}
|
||||||
commitRunning={commitRunning}
|
commitRunning={commitRunning}
|
||||||
hasChanges={hasChanges}
|
hasChanges={hasChanges}
|
||||||
@@ -310,6 +314,8 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
scope={scope}
|
||||||
|
onToggleScope={toggleScope}
|
||||||
onEditAuth={requestEditAuth}
|
onEditAuth={requestEditAuth}
|
||||||
commitRunning={commitRunning}
|
commitRunning={commitRunning}
|
||||||
hasChanges={hasChanges}
|
hasChanges={hasChanges}
|
||||||
@@ -391,6 +397,8 @@ export function App() {
|
|||||||
<>
|
<>
|
||||||
<PanelHeader
|
<PanelHeader
|
||||||
onRefresh={refresh}
|
onRefresh={refresh}
|
||||||
|
scope={scope}
|
||||||
|
onToggleScope={toggleScope}
|
||||||
onEditAuth={requestEditAuth}
|
onEditAuth={requestEditAuth}
|
||||||
commitRunning={commitRunning}
|
commitRunning={commitRunning}
|
||||||
hasChanges={hasChanges}
|
hasChanges={hasChanges}
|
||||||
@@ -452,6 +460,7 @@ export function App() {
|
|||||||
canCancel={settings?.canCancel}
|
canCancel={settings?.canCancel}
|
||||||
initialTokenSaved={settings?.tokenSaved ?? false}
|
initialTokenSaved={settings?.tokenSaved ?? false}
|
||||||
initialWebhookPort={settings?.webhookPort ?? 17421}
|
initialWebhookPort={settings?.webhookPort ?? 17421}
|
||||||
|
initialWebhookSecret={settings?.webhookSecret ?? ''}
|
||||||
initialBrainstormPrompt={settings?.brainstormPrompt ?? ''}
|
initialBrainstormPrompt={settings?.brainstormPrompt ?? ''}
|
||||||
initialBrainstormContinuePrompt={settings?.brainstormContinuePrompt ?? ''}
|
initialBrainstormContinuePrompt={settings?.brainstormContinuePrompt ?? ''}
|
||||||
initialImplementPlanPrompt={settings?.implementPlanPrompt ?? ''}
|
initialImplementPlanPrompt={settings?.implementPlanPrompt ?? ''}
|
||||||
|
|||||||
@@ -1,49 +1,72 @@
|
|||||||
import { GitCommit, GitMerge, ListPlus, Loader2, Lock, RefreshCw, Settings, Unlock } from 'lucide-react'
|
import { GitCommit, GitMerge, ListPlus, Loader2, Lock, RefreshCw, Settings, Unlock, User, Users } from 'lucide-react'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onRefresh: () => void
|
onRefresh: () => void
|
||||||
|
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部工单。 */
|
||||||
|
scope: 'mine' | 'all'
|
||||||
|
onToggleScope: () => void
|
||||||
onEditAuth: () => void
|
onEditAuth: () => void
|
||||||
commitRunning: boolean
|
commitRunning: boolean
|
||||||
/** Whether the workspace git working tree has uncommitted changes. The
|
/**
|
||||||
|
* Whether the workspace git working tree has uncommitted changes. The
|
||||||
* commit button is only rendered when this is true (or `commitRunning` is
|
* commit button is only rendered when this is true (or `commitRunning` is
|
||||||
* true — we keep it visible mid-run so the spinner stays on screen even
|
* true — we keep it visible mid-run so the spinner stays on screen even
|
||||||
* if `cc` clears the tree before finishing). */
|
* if `cc` clears the tree before finishing).
|
||||||
|
*/
|
||||||
hasChanges: boolean
|
hasChanges: boolean
|
||||||
onCommit: () => void
|
onCommit: () => void
|
||||||
/** Commits the remote auto-build branch is behind the remote dev branch.
|
/**
|
||||||
|
* Commits the remote auto-build branch is behind the remote dev branch.
|
||||||
* Combined with `branchSyncDisabled` to compute the button's disabled
|
* Combined with `branchSyncDisabled` to compute the button's disabled
|
||||||
* state. */
|
* state.
|
||||||
|
*/
|
||||||
branchSyncBehind: number
|
branchSyncBehind: number
|
||||||
branchSyncRunning: boolean
|
branchSyncRunning: boolean
|
||||||
/** True when sync is structurally unavailable (same-branch / fetch fail
|
/**
|
||||||
* / not a repo / …). Disables the button regardless of `branchSyncBehind`. */
|
* True when sync is structurally unavailable (same-branch / fetch fail
|
||||||
|
* / not a repo / …). Disables the button regardless of `branchSyncBehind`.
|
||||||
|
*/
|
||||||
branchSyncDisabled: boolean
|
branchSyncDisabled: boolean
|
||||||
/** Hover tooltip, already includes branch names + behind count or the
|
/**
|
||||||
* unavailable reason. */
|
* Hover tooltip, already includes branch names + behind count or the
|
||||||
|
* unavailable reason.
|
||||||
|
*/
|
||||||
branchSyncTitle: string
|
branchSyncTitle: string
|
||||||
onSyncBranch: () => void
|
onSyncBranch: () => void
|
||||||
/** Whether the workspace's `.env*` files are currently chmod-locked (444).
|
/**
|
||||||
* Icon flips between Lock and Unlock based on this. */
|
* Whether the workspace's `.env*` files are currently chmod-locked (444).
|
||||||
|
* Icon flips between Lock and Unlock based on this.
|
||||||
|
*/
|
||||||
envLocked: boolean
|
envLocked: boolean
|
||||||
/** Number of `.env*` files discovered in the workspace at the last scan.
|
/**
|
||||||
* The button is disabled when 0 — nothing to lock. */
|
* Number of `.env*` files discovered in the workspace at the last scan.
|
||||||
|
* The button is disabled when 0 — nothing to lock.
|
||||||
|
*/
|
||||||
envFileCount: number
|
envFileCount: number
|
||||||
/** True while a chmod batch is in flight; disables the button to avoid
|
/**
|
||||||
* duplicate toggles. */
|
* True while a chmod batch is in flight; disables the button to avoid
|
||||||
|
* duplicate toggles.
|
||||||
|
*/
|
||||||
envLockRunning: boolean
|
envLockRunning: boolean
|
||||||
/** Hover tooltip, already includes file count + current lock state. */
|
/** Hover tooltip, already includes file count + current lock state. */
|
||||||
envLockTitle: string
|
envLockTitle: string
|
||||||
onToggleEnvLock: () => void
|
onToggleEnvLock: () => void
|
||||||
/** Whether YouTrack is configured. The「导入 YouTrack 工单」button is only
|
/**
|
||||||
* rendered when true. */
|
* Whether YouTrack is configured. The「导入 YouTrack 工单」button is only
|
||||||
|
* rendered when true.
|
||||||
|
*/
|
||||||
youtrackConfigured: boolean
|
youtrackConfigured: boolean
|
||||||
/** Open the native multi-select dialog to pick which YouTrack issues to
|
/**
|
||||||
* mirror onto the board. */
|
* Open the native multi-select dialog to pick which YouTrack issues to
|
||||||
|
* mirror onto the board.
|
||||||
|
*/
|
||||||
onImportYouTrack: () => void
|
onImportYouTrack: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelHeader({
|
export function PanelHeader({
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
scope,
|
||||||
|
onToggleScope,
|
||||||
onEditAuth,
|
onEditAuth,
|
||||||
commitRunning,
|
commitRunning,
|
||||||
hasChanges,
|
hasChanges,
|
||||||
@@ -122,6 +145,15 @@ export function PanelHeader({
|
|||||||
<ListPlus size={14} />
|
<ListPlus size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleScope}
|
||||||
|
title={scope === 'mine' ? '当前:只看我的工单,点击切换为全部' : '当前:团队全部工单,点击切换为只看我的'}
|
||||||
|
aria-label={scope === 'mine' ? '切换为全部工单' : '切换为只看我的'}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
|
||||||
|
>
|
||||||
|
{scope === 'mine' ? <User size={14} /> : <Users size={14} />}
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onRefresh}
|
onClick={onRefresh}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { X } from 'lucide-react'
|
|
||||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
|
type GroupKey = 'auth' | 'network' | 'youtrack' | 'prompts' | 'hooks'
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ interface SubmitValues {
|
|||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
@@ -63,6 +64,7 @@ export interface SettingsModalProps {
|
|||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
initialTokenSaved: boolean
|
initialTokenSaved: boolean
|
||||||
initialWebhookPort: number
|
initialWebhookPort: number
|
||||||
|
initialWebhookSecret: string
|
||||||
initialBrainstormPrompt: string
|
initialBrainstormPrompt: string
|
||||||
initialBrainstormContinuePrompt: string
|
initialBrainstormContinuePrompt: string
|
||||||
initialImplementPlanPrompt: string
|
initialImplementPlanPrompt: string
|
||||||
@@ -127,8 +129,10 @@ const GROUPS: Array<{ key: GroupKey, label: string }> = [
|
|||||||
|
|
||||||
type ErrorKey = 'host' | 'token' | 'webhookPort'
|
type ErrorKey = 'host' | 'token' | 'webhookPort'
|
||||||
|
|
||||||
/** Map a field name to the group it lives in, so we can auto-switch to the
|
/**
|
||||||
* first invalid field's group on save. */
|
* Map a field name to the group it lives in, so we can auto-switch to the
|
||||||
|
* first invalid field's group on save.
|
||||||
|
*/
|
||||||
const FIELD_TO_GROUP: Record<ErrorKey, GroupKey> = {
|
const FIELD_TO_GROUP: Record<ErrorKey, GroupKey> = {
|
||||||
host: 'auth',
|
host: 'auth',
|
||||||
token: 'auth',
|
token: 'auth',
|
||||||
@@ -142,6 +146,7 @@ export function SettingsModal({
|
|||||||
canCancel,
|
canCancel,
|
||||||
initialTokenSaved,
|
initialTokenSaved,
|
||||||
initialWebhookPort,
|
initialWebhookPort,
|
||||||
|
initialWebhookSecret,
|
||||||
initialBrainstormPrompt,
|
initialBrainstormPrompt,
|
||||||
initialBrainstormContinuePrompt,
|
initialBrainstormContinuePrompt,
|
||||||
initialImplementPlanPrompt,
|
initialImplementPlanPrompt,
|
||||||
@@ -175,6 +180,7 @@ export function SettingsModal({
|
|||||||
const [host, setHost] = useState(initialHost)
|
const [host, setHost] = useState(initialHost)
|
||||||
const [token, setToken] = useState('')
|
const [token, setToken] = useState('')
|
||||||
const [webhookPort, setWebhookPort] = useState<string>(String(initialWebhookPort))
|
const [webhookPort, setWebhookPort] = useState<string>(String(initialWebhookPort))
|
||||||
|
const [webhookSecret, setWebhookSecret] = useState(initialWebhookSecret)
|
||||||
const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt)
|
const [brainstormPrompt, setBrainstormPrompt] = useState(initialBrainstormPrompt)
|
||||||
const [brainstormContinuePrompt, setBrainstormContinuePrompt] = useState(initialBrainstormContinuePrompt)
|
const [brainstormContinuePrompt, setBrainstormContinuePrompt] = useState(initialBrainstormContinuePrompt)
|
||||||
const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt)
|
const [implementPlanPrompt, setImplementPlanPrompt] = useState(initialImplementPlanPrompt)
|
||||||
@@ -210,6 +216,7 @@ export function SettingsModal({
|
|||||||
setHost(initialHost)
|
setHost(initialHost)
|
||||||
setToken('')
|
setToken('')
|
||||||
setWebhookPort(String(initialWebhookPort))
|
setWebhookPort(String(initialWebhookPort))
|
||||||
|
setWebhookSecret(initialWebhookSecret)
|
||||||
setBrainstormPrompt(initialBrainstormPrompt)
|
setBrainstormPrompt(initialBrainstormPrompt)
|
||||||
setBrainstormContinuePrompt(initialBrainstormContinuePrompt)
|
setBrainstormContinuePrompt(initialBrainstormContinuePrompt)
|
||||||
setImplementPlanPrompt(initialImplementPlanPrompt)
|
setImplementPlanPrompt(initialImplementPlanPrompt)
|
||||||
@@ -294,6 +301,7 @@ export function SettingsModal({
|
|||||||
// extension side detects empty + existing-saved as "preserve".
|
// extension side detects empty + existing-saved as "preserve".
|
||||||
token: trimmedToken,
|
token: trimmedToken,
|
||||||
webhookPort: portNum,
|
webhookPort: portNum,
|
||||||
|
webhookSecret: webhookSecret.trim(),
|
||||||
brainstormPrompt,
|
brainstormPrompt,
|
||||||
brainstormContinuePrompt,
|
brainstormContinuePrompt,
|
||||||
implementPlanPrompt,
|
implementPlanPrompt,
|
||||||
@@ -578,7 +586,13 @@ export function SettingsModal({
|
|||||||
扩展 HTTP server 绑定的端口。在 gitea 仓库 Webhooks 配置里把目标 URL 指向
|
扩展 HTTP server 绑定的端口。在 gitea 仓库 Webhooks 配置里把目标 URL 指向
|
||||||
{' '}
|
{' '}
|
||||||
<code>{'http://<你的本机/公网域名>:<此端口>/webhook'}</code>
|
<code>{'http://<你的本机/公网域名>:<此端口>/webhook'}</code>
|
||||||
,事件需勾选:<b>工单</b>(issue opened/edited)、<b>合并请求</b>(PR opened/synchronize/closed)、<b>工单评论</b>(codex 审查回流)。
|
,事件需勾选:
|
||||||
|
<b>工单</b>
|
||||||
|
(issue opened/edited)、
|
||||||
|
<b>合并请求</b>
|
||||||
|
(PR opened/synchronize/closed)、
|
||||||
|
<b>工单评论</b>
|
||||||
|
(codex 审查回流)。
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -592,6 +606,30 @@ export function SettingsModal({
|
|||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Webhook Secret"
|
||||||
|
hint={(
|
||||||
|
<>
|
||||||
|
与 gitea Webhooks 配置里的
|
||||||
|
{' '}
|
||||||
|
<b>密钥文本</b>
|
||||||
|
{' '}
|
||||||
|
保持一致,用于校验
|
||||||
|
{' '}
|
||||||
|
<code>X-Gitea-Signature</code>
|
||||||
|
。webhook 走公网隧道时必填,否则任何人都能伪造事件;留空 = 不校验。
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={webhookSecret}
|
||||||
|
onChange={e => setWebhookSecret(e.target.value)}
|
||||||
|
placeholder="留空表示不校验签名"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="开启自动审查"
|
label="开启自动审查"
|
||||||
hint="PR 创建后自动用 codex exec review 跑审查并把结果灌到实施会话终端"
|
hint="PR 创建后自动用 codex exec review 跑审查并把结果灌到实施会话终端"
|
||||||
@@ -791,7 +829,12 @@ export function SettingsModal({
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="审查提示词"
|
label="审查提示词"
|
||||||
hint={<>可用占位符:<code>{'{prNumber}'}</code></>}
|
hint={(
|
||||||
|
<>
|
||||||
|
可用占位符:
|
||||||
|
<code>{'{prNumber}'}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<textarea
|
<textarea
|
||||||
rows={4}
|
rows={4}
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
* after a drag. Persisting drag moves back to Gitea is step 3+.
|
* after a drag. Persisting drag moves back to Gitea is step 3+.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
||||||
import type { Issue, IssueColumn } from '../types'
|
|
||||||
import type { ToastItem } from '../components/ToastStack'
|
import type { ToastItem } from '../components/ToastStack'
|
||||||
import type { LogEntry } from '../lib/messages'
|
import type { LogEntry } from '../lib/messages'
|
||||||
|
import type { Issue, IssueColumn } from '../types'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { compareIssuesInColumn } from '../lib/issueSort'
|
import { compareIssuesInColumn } from '../lib/issueSort'
|
||||||
import { onMessage, postMessage } from '../lib/vscode'
|
import { onMessage, postMessage } from '../lib/vscode'
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ export interface SettingsValues {
|
|||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
@@ -68,6 +69,7 @@ export interface SettingsOverlayState {
|
|||||||
canCancel: boolean
|
canCancel: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
@@ -107,10 +109,10 @@ export interface YouTrackProjectsState {
|
|||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UseIssuesState =
|
export type UseIssuesState
|
||||||
| { status: 'loading' }
|
= | { status: 'loading' }
|
||||||
| { status: 'ready', issues: Issue[] }
|
| { status: 'ready', issues: Issue[] }
|
||||||
| { status: 'error', message: string }
|
| { status: 'error', message: string }
|
||||||
|
|
||||||
/** Mirror of the extension-side ClaudeProfile in src/cc/profiles.ts. */
|
/** Mirror of the extension-side ClaudeProfile in src/cc/profiles.ts. */
|
||||||
export interface ClaudeProfile {
|
export interface ClaudeProfile {
|
||||||
@@ -121,23 +123,34 @@ export interface ClaudeProfile {
|
|||||||
export interface UseIssuesResult {
|
export interface UseIssuesResult {
|
||||||
state: UseIssuesState
|
state: UseIssuesState
|
||||||
settings: SettingsOverlayState | null
|
settings: SettingsOverlayState | null
|
||||||
/** Latest known global `autoReview` value pushed alongside `issues/update`.
|
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部。 */
|
||||||
|
scope: 'mine' | 'all'
|
||||||
|
toggleScope: () => void
|
||||||
|
/**
|
||||||
|
* Latest known global `autoReview` value pushed alongside `issues/update`.
|
||||||
* Used by the detail panel to display the fallback value when an issue has
|
* Used by the detail panel to display the fallback value when an issue has
|
||||||
* no per-issue override. */
|
* no per-issue override.
|
||||||
|
*/
|
||||||
globalAutoReview: boolean
|
globalAutoReview: boolean
|
||||||
/** Whether YouTrack is configured (base URL + project set), pushed alongside
|
/**
|
||||||
* `issues/update`. Drives whether the「导入 YouTrack 工单」toolbar button shows. */
|
* Whether YouTrack is configured (base URL + project set), pushed alongside
|
||||||
|
* `issues/update`. Drives whether the「导入 YouTrack 工单」toolbar button shows.
|
||||||
|
*/
|
||||||
youtrackConfigured: boolean
|
youtrackConfigured: boolean
|
||||||
/** Open the native multi-select dialog to pick which YouTrack issues to
|
/**
|
||||||
* mirror onto the board. */
|
* Open the native multi-select dialog to pick which YouTrack issues to
|
||||||
|
* mirror onto the board.
|
||||||
|
*/
|
||||||
importYouTrack: () => void
|
importYouTrack: () => void
|
||||||
toasts: ToastItem[]
|
toasts: ToastItem[]
|
||||||
profiles: ClaudeProfile[]
|
profiles: ClaudeProfile[]
|
||||||
setIssues: (issues: Issue[]) => void
|
setIssues: (issues: Issue[]) => void
|
||||||
refresh: () => void
|
refresh: () => void
|
||||||
saveSettings: (values: SettingsValues) => void
|
saveSettings: (values: SettingsValues) => void
|
||||||
/** Fetch the YouTrack project list for the settings dropdown using the
|
/**
|
||||||
* in-form base URL + token (so the user can pick before saving). */
|
* Fetch the YouTrack project list for the settings dropdown using the
|
||||||
|
* in-form base URL + token (so the user can pick before saving).
|
||||||
|
*/
|
||||||
listYouTrackProjects: (baseUrl: string, token: string) => void
|
listYouTrackProjects: (baseUrl: string, token: string) => void
|
||||||
/** Result of the most recent `listYouTrackProjects` call. */
|
/** Result of the most recent `listYouTrackProjects` call. */
|
||||||
youtrackProjects: YouTrackProjectsState
|
youtrackProjects: YouTrackProjectsState
|
||||||
@@ -159,81 +172,115 @@ export interface UseIssuesResult {
|
|||||||
openWorktree: (path: string) => void
|
openWorktree: (path: string) => void
|
||||||
deleteWorktree: (issueNumber: number, path: string) => void
|
deleteWorktree: (issueNumber: number, path: string) => void
|
||||||
mergeBranch: (issueNumber: number, branch: string) => void
|
mergeBranch: (issueNumber: number, branch: string) => void
|
||||||
/** 硬删 Gitea 工单及关联资源(worktree / PR / feature branch / cc tabs)。
|
/**
|
||||||
|
* 硬删 Gitea 工单及关联资源(worktree / PR / feature branch / cc tabs)。
|
||||||
* 用户在详情面板顶部点垃圾桶按钮触发。扩展端做 modal confirm + 串行清理,
|
* 用户在详情面板顶部点垃圾桶按钮触发。扩展端做 modal confirm + 串行清理,
|
||||||
* 全部成功后 push `issue/remove`,webview 将该 issue 从 issues 数组移除。 */
|
* 全部成功后 push `issue/remove`,webview 将该 issue 从 issues 数组移除。
|
||||||
|
*/
|
||||||
deleteIssue: (issueNumber: number) => void
|
deleteIssue: (issueNumber: number) => void
|
||||||
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
||||||
closeIssue: (issueNumber: number) => void
|
closeIssue: (issueNumber: number) => void
|
||||||
/** Dispose the matching session terminal tab. Extension reacts via
|
/**
|
||||||
* onDidCloseTerminal and clears `*TabOpen` so the X button disappears. */
|
* Dispose the matching session terminal tab. Extension reacts via
|
||||||
|
* onDidCloseTerminal and clears `*TabOpen` so the X button disappears.
|
||||||
|
*/
|
||||||
closeSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
closeSessionTab: (issueNumber: number, kind: 'brainstorm' | 'implement' | 'review' | 'test') => void
|
||||||
/** Spawn a fresh "规划" cc tab for an existing issue whose sessionId is
|
/**
|
||||||
|
* Spawn a fresh "规划" cc tab for an existing issue whose sessionId is
|
||||||
* still empty. Extension 用工单级 brainstormProfilePath(空 = 默认),
|
* still empty. Extension 用工单级 brainstormProfilePath(空 = 默认),
|
||||||
* watches `~/.claude/projects/<encoded-workspaceRoot>` for the new
|
* watches `~/.claude/projects/<encoded-workspaceRoot>` for the new
|
||||||
* session jsonl, then writes the id back as `sessionId`. */
|
* session jsonl, then writes the id back as `sessionId`.
|
||||||
|
*/
|
||||||
startBrainstormSession: (issueNumber: number) => void
|
startBrainstormSession: (issueNumber: number) => void
|
||||||
changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
|
changeColumn: (issueNumber: number, toColumn: IssueColumn, source?: 'gitea' | 'youtrack', externalId?: string) => void
|
||||||
setDependency: (issueNumber: number, prerequisiteNumber: number) => void
|
setDependency: (issueNumber: number, prerequisiteNumber: number) => void
|
||||||
clearDependency: (issueNumber: number, prerequisiteNumber: number) => void
|
clearDependency: (issueNumber: number, prerequisiteNumber: number) => void
|
||||||
updateIssueAutoReview: (issueNumber: number, value: boolean) => void
|
updateIssueAutoReview: (issueNumber: number, value: boolean) => void
|
||||||
/** 覆盖该工单实施会话使用的 Claude 配置文件,乐观更新本地 state 后
|
/**
|
||||||
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。 */
|
* 覆盖该工单实施会话使用的 Claude 配置文件,乐观更新本地 state 后
|
||||||
|
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。
|
||||||
|
*/
|
||||||
updateIssueProfilePath: (issueNumber: number, profilePath: string) => void
|
updateIssueProfilePath: (issueNumber: number, profilePath: string) => void
|
||||||
/** 覆盖该工单头脑风暴会话专用的 Claude 配置文件,乐观更新本地 state 后
|
/**
|
||||||
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。 */
|
* 覆盖该工单头脑风暴会话专用的 Claude 配置文件,乐观更新本地 state 后
|
||||||
|
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。
|
||||||
|
*/
|
||||||
updateIssueBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
|
updateIssueBrainstormProfilePath: (issueNumber: number, brainstormProfilePath: string) => void
|
||||||
/** 覆盖该工单测试会话专用的 Claude 配置文件,乐观更新本地 state 后
|
/**
|
||||||
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。 */
|
* 覆盖该工单测试会话专用的 Claude 配置文件,乐观更新本地 state 后
|
||||||
|
* postMessage 持久化到 state JSON。失败时扩展端 push `issue/patch` 回滚。
|
||||||
|
*/
|
||||||
updateIssueTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
updateIssueTestProfilePath: (issueNumber: number, testProfilePath: string) => void
|
||||||
logs: LogEntry[]
|
logs: LogEntry[]
|
||||||
fetchLogs: () => void
|
fetchLogs: () => void
|
||||||
clearLogs: () => void
|
clearLogs: () => void
|
||||||
/** True while the "提交当前代码" claude -p run is in flight. Used to
|
/**
|
||||||
* disable the toolbar button and swap its icon to a spinner. */
|
* True while the "提交当前代码" claude -p run is in flight. Used to
|
||||||
|
* disable the toolbar button and swap its icon to a spinner.
|
||||||
|
*/
|
||||||
commitRunning: boolean
|
commitRunning: boolean
|
||||||
/** Trigger a background `claude -p "提交下代码"` run in the workspace
|
/**
|
||||||
|
* Trigger a background `claude -p "提交下代码"` run in the workspace
|
||||||
* root, gated by `commitRunning`. The extension responds with
|
* root, gated by `commitRunning`. The extension responds with
|
||||||
* `commit/state` messages and a `toast/show` on completion. */
|
* `commit/state` messages and a `toast/show` on completion.
|
||||||
|
*/
|
||||||
runCommit: () => void
|
runCommit: () => void
|
||||||
/** Whether the workspace git working tree currently has any uncommitted
|
/**
|
||||||
|
* Whether the workspace git working tree currently has any uncommitted
|
||||||
* changes (working tree / index / untracked). Pushed by the extension via
|
* changes (working tree / index / untracked). Pushed by the extension via
|
||||||
* `commit/has-changes` and used by `PanelHeader` to skip rendering the
|
* `commit/has-changes` and used by `PanelHeader` to skip rendering the
|
||||||
* commit button when the tree is clean. Defaults to `false` until the
|
* commit button when the tree is clean. Defaults to `false` until the
|
||||||
* extension reports its first observation. */
|
* extension reports its first observation.
|
||||||
|
*/
|
||||||
hasChanges: boolean
|
hasChanges: boolean
|
||||||
/** Commits the remote auto-build branch is behind the remote dev branch
|
/**
|
||||||
* (per latest `branch-sync/status`). 0 ⇒ in sync ⇒ disable button. */
|
* Commits the remote auto-build branch is behind the remote dev branch
|
||||||
|
* (per latest `branch-sync/status`). 0 ⇒ in sync ⇒ disable button.
|
||||||
|
*/
|
||||||
branchSyncBehind: number
|
branchSyncBehind: number
|
||||||
/** True while a `branch-sync/run` request is in flight. The extension
|
/**
|
||||||
|
* True while a `branch-sync/run` request is in flight. The extension
|
||||||
* doesn't echo a "running" state itself — we set this on click and clear
|
* doesn't echo a "running" state itself — we set this on click and clear
|
||||||
* it when the next `branch-sync/status` arrives. */
|
* it when the next `branch-sync/status` arrives.
|
||||||
|
*/
|
||||||
branchSyncRunning: boolean
|
branchSyncRunning: boolean
|
||||||
/** True when sync is structurally unavailable (same branch, missing
|
/**
|
||||||
* remote, fetch error, …). Disables the button regardless of behind. */
|
* True when sync is structurally unavailable (same branch, missing
|
||||||
|
* remote, fetch error, …). Disables the button regardless of behind.
|
||||||
|
*/
|
||||||
branchSyncDisabled: boolean
|
branchSyncDisabled: boolean
|
||||||
/** Tooltip text for the sync button (already includes branch names /
|
/**
|
||||||
* behind count / unavailable reason). */
|
* Tooltip text for the sync button (already includes branch names /
|
||||||
|
* behind count / unavailable reason).
|
||||||
|
*/
|
||||||
branchSyncTitle: string
|
branchSyncTitle: string
|
||||||
/** Trigger a fast-forward push from remote dev to remote auto-build. */
|
/** Trigger a fast-forward push from remote dev to remote auto-build. */
|
||||||
runBranchSync: () => void
|
runBranchSync: () => void
|
||||||
/** Whether the workspace's `.env*` files are currently chmod-locked
|
/**
|
||||||
* according to `workspaceState`. Persisted per-workspace. */
|
* Whether the workspace's `.env*` files are currently chmod-locked
|
||||||
|
* according to `workspaceState`. Persisted per-workspace.
|
||||||
|
*/
|
||||||
envLocked: boolean
|
envLocked: boolean
|
||||||
/** File count from the latest `env-lock/status`. 0 disables the toggle. */
|
/** File count from the latest `env-lock/status`. 0 disables the toggle. */
|
||||||
envFileCount: number
|
envFileCount: number
|
||||||
/** True while a `env-lock/toggle` chmod batch is in flight. Cleared when
|
/**
|
||||||
* the next `env-lock/status` arrives. */
|
* True while a `env-lock/toggle` chmod batch is in flight. Cleared when
|
||||||
|
* the next `env-lock/status` arrives.
|
||||||
|
*/
|
||||||
envLockRunning: boolean
|
envLockRunning: boolean
|
||||||
/** Tooltip text for the env-lock button, pre-composed from lock state +
|
/**
|
||||||
* file count. */
|
* Tooltip text for the env-lock button, pre-composed from lock state +
|
||||||
|
* file count.
|
||||||
|
*/
|
||||||
envLockTitle: string
|
envLockTitle: string
|
||||||
/** Flip the env-lock state: chmod all `.env*` files to 444 or 644. */
|
/** Flip the env-lock state: chmod all `.env*` files to 444 or 644. */
|
||||||
toggleEnvLock: () => void
|
toggleEnvLock: () => void
|
||||||
/** ID of an issue that should be auto-selected after an `issue/append`
|
/**
|
||||||
|
* ID of an issue that should be auto-selected after an `issue/append`
|
||||||
* with `select: true` (i.e. user-initiated webhook creation). Consumers
|
* with `select: true` (i.e. user-initiated webhook creation). Consumers
|
||||||
* read this in a `useEffect`, apply the selection, then call
|
* read this in a `useEffect`, apply the selection, then call
|
||||||
* `clearPendingSelect()` to reset. */
|
* `clearPendingSelect()` to reset.
|
||||||
|
*/
|
||||||
pendingSelectId: string | null
|
pendingSelectId: string | null
|
||||||
clearPendingSelect: () => void
|
clearPendingSelect: () => void
|
||||||
}
|
}
|
||||||
@@ -243,6 +290,7 @@ export function useIssues(): UseIssuesResult {
|
|||||||
const [settings, setSettings] = useState<SettingsOverlayState | null>(null)
|
const [settings, setSettings] = useState<SettingsOverlayState | null>(null)
|
||||||
const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] })
|
const [youtrackProjects, setYoutrackProjects] = useState<YouTrackProjectsState>({ status: 'idle', projects: [] })
|
||||||
const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(true)
|
const [globalAutoReview, setGlobalAutoReview] = useState<boolean>(true)
|
||||||
|
const [scope, setScope] = useState<'mine' | 'all'>('mine')
|
||||||
const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(false)
|
const [youtrackConfigured, setYoutrackConfigured] = useState<boolean>(false)
|
||||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||||
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
|
const [profiles, setProfiles] = useState<ClaudeProfile[]>([])
|
||||||
@@ -271,6 +319,12 @@ export function useIssues(): UseIssuesResult {
|
|||||||
setPendingSelectId(null)
|
setPendingSelectId(null)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const toggleScope = useCallback((): void => {
|
||||||
|
setState({ status: 'loading' })
|
||||||
|
setScope(prev => prev === 'mine' ? 'all' : 'mine')
|
||||||
|
postMessage({ type: 'issues/set-scope', scope: scope === 'mine' ? 'all' : 'mine' })
|
||||||
|
}, [scope])
|
||||||
|
|
||||||
const refresh = useCallback((): void => {
|
const refresh = useCallback((): void => {
|
||||||
setState({ status: 'loading' })
|
setState({ status: 'loading' })
|
||||||
postMessage({ type: 'issues/refresh' })
|
postMessage({ type: 'issues/refresh' })
|
||||||
@@ -290,6 +344,7 @@ export function useIssues(): UseIssuesResult {
|
|||||||
host: values.host,
|
host: values.host,
|
||||||
token: values.token,
|
token: values.token,
|
||||||
webhookPort: values.webhookPort,
|
webhookPort: values.webhookPort,
|
||||||
|
webhookSecret: values.webhookSecret,
|
||||||
brainstormPrompt: values.brainstormPrompt,
|
brainstormPrompt: values.brainstormPrompt,
|
||||||
brainstormContinuePrompt: values.brainstormContinuePrompt,
|
brainstormContinuePrompt: values.brainstormContinuePrompt,
|
||||||
implementPlanPrompt: values.implementPlanPrompt,
|
implementPlanPrompt: values.implementPlanPrompt,
|
||||||
@@ -557,6 +612,7 @@ export function useIssues(): UseIssuesResult {
|
|||||||
break
|
break
|
||||||
case 'issues/update':
|
case 'issues/update':
|
||||||
setState({ status: 'ready', issues: msg.issues })
|
setState({ status: 'ready', issues: msg.issues })
|
||||||
|
setScope(msg.scope)
|
||||||
setGlobalAutoReview(msg.globalAutoReview)
|
setGlobalAutoReview(msg.globalAutoReview)
|
||||||
setYoutrackConfigured(msg.youtrackConfigured)
|
setYoutrackConfigured(msg.youtrackConfigured)
|
||||||
break
|
break
|
||||||
@@ -638,6 +694,7 @@ export function useIssues(): UseIssuesResult {
|
|||||||
canCancel: msg.canCancel === true,
|
canCancel: msg.canCancel === true,
|
||||||
tokenSaved: msg.tokenSaved,
|
tokenSaved: msg.tokenSaved,
|
||||||
webhookPort: msg.webhookPort,
|
webhookPort: msg.webhookPort,
|
||||||
|
webhookSecret: msg.webhookSecret,
|
||||||
brainstormPrompt: msg.brainstormPrompt,
|
brainstormPrompt: msg.brainstormPrompt,
|
||||||
brainstormContinuePrompt: msg.brainstormContinuePrompt,
|
brainstormContinuePrompt: msg.brainstormContinuePrompt,
|
||||||
implementPlanPrompt: msg.implementPlanPrompt,
|
implementPlanPrompt: msg.implementPlanPrompt,
|
||||||
@@ -760,5 +817,5 @@ export function useIssues(): UseIssuesResult {
|
|||||||
return cleanup
|
return cleanup
|
||||||
}, [clearPrDiffSummaryRunning])
|
}, [clearPrDiffSummaryRunning])
|
||||||
|
|
||||||
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, branchSyncBehind, branchSyncRunning, branchSyncDisabled, branchSyncTitle, runBranchSync, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock }
|
return { state, settings, globalAutoReview, youtrackConfigured, importYouTrack, toasts, profiles, setIssues, refresh, scope, toggleScope, saveSettings, listYouTrackProjects, youtrackProjects, dismissSettings, requestEditAuth, createIssue, dismissToast, openUrl, resumeSession, resumeReviewSession, startTestSession, resumeTestSession, focusSession, openFile, implement, generatePrDiffSummary, isPrDiffSummaryRunning, openPr, openWorktree, deleteWorktree, mergeBranch, deleteIssue, closeIssue, closeSessionTab, startBrainstormSession, changeColumn, setDependency, clearDependency, updateIssueAutoReview, updateIssueProfilePath, updateIssueBrainstormProfilePath, updateIssueTestProfilePath, logs, fetchLogs, clearLogs, pendingSelectId, clearPendingSelect, commitRunning, runCommit, hasChanges, branchSyncBehind, branchSyncRunning, branchSyncDisabled, branchSyncTitle, runBranchSync, envLocked, envFileCount, envLockRunning, envLockTitle, toggleEnvLock }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export interface ProfilesData {
|
|||||||
|
|
||||||
export type ExtensionToWebview
|
export type ExtensionToWebview
|
||||||
= | { type: 'issues/loading' }
|
= | { type: 'issues/loading' }
|
||||||
| { type: 'issues/update', issues: Issue[], globalAutoReview: boolean, youtrackConfigured: boolean }
|
| { type: 'issues/update', issues: Issue[], scope: 'mine' | 'all', globalAutoReview: boolean, youtrackConfigured: boolean }
|
||||||
| { type: 'issues/error', message: string }
|
| { type: 'issues/error', message: string }
|
||||||
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
| { type: 'issue/patch', issueNumber: number, patch: { autoReview?: boolean, specFile?: string, planFile?: string, prDiffFile?: string, sessionId?: string, implementSessionId?: string, reviewSessionId?: string, testSessionId?: string, pr?: string | null, implementStatus?: 'running' | 'done' | 'failed', column?: IssueColumn, worktreePath?: string | null, prMerged?: boolean, prMergedAt?: string, branch?: string | null, color?: string, worktreeExists?: boolean, brainstormTabOpen?: boolean, implementTabOpen?: boolean, reviewTabOpen?: boolean, testTabOpen?: boolean, profilePath?: string, brainstormProfilePath?: string, testProfilePath?: string } }
|
||||||
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
| { type: 'issue/pr-diff-summary-done', issueNumber: number }
|
||||||
@@ -68,6 +68,7 @@ export type ExtensionToWebview
|
|||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
@@ -132,11 +133,13 @@ export type ExtensionToWebview
|
|||||||
|
|
||||||
export type WebviewToExtension
|
export type WebviewToExtension
|
||||||
= | { type: 'issues/refresh' }
|
= | { type: 'issues/refresh' }
|
||||||
|
| { type: 'issues/set-scope', scope: 'mine' | 'all' }
|
||||||
| {
|
| {
|
||||||
type: 'settings/save'
|
type: 'settings/save'
|
||||||
host: string
|
host: string
|
||||||
token: string
|
token: string
|
||||||
webhookPort: number
|
webhookPort: number
|
||||||
|
webhookSecret: string
|
||||||
brainstormPrompt: string
|
brainstormPrompt: string
|
||||||
brainstormContinuePrompt: string
|
brainstormContinuePrompt: string
|
||||||
implementPlanPrompt: string
|
implementPlanPrompt: string
|
||||||
@@ -178,7 +181,7 @@ export type WebviewToExtension
|
|||||||
| { type: 'pr/open', pr: string }
|
| { type: 'pr/open', pr: string }
|
||||||
| { type: 'worktree/open', path: string }
|
| { type: 'worktree/open', path: string }
|
||||||
| { type: 'worktree/delete', issueNumber: number, path: string }
|
| { type: 'worktree/delete', issueNumber: number, path: string }
|
||||||
| { type: 'git/merge-preview', issueNumber: number, branch: string }
|
| { type: 'git/merge-preview', issueNumber: number, branch: string }
|
||||||
| { type: 'column/change', issueNumber: number, toColumn: 'todo' | 'in-progress' | 'review' | 'done', source?: 'gitea' | 'youtrack', externalId?: string }
|
| { type: 'column/change', issueNumber: number, toColumn: 'todo' | 'in-progress' | 'review' | 'done', source?: 'gitea' | 'youtrack', externalId?: string }
|
||||||
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
| { type: 'dependency/set', issueNumber: number, prerequisiteNumber: number }
|
||||||
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
| { type: 'dependency/clear', issueNumber: number, prerequisiteNumber: number }
|
||||||
|
|||||||
Reference in New Issue
Block a user