✨ feat(vscode): 设置和看板按仓库类型区分 GitHub 与 Gitea,GitHub 显示 gh 登录状态,文案去 Gitea 化
This commit is contained in:
1 parent
ac3c882672
commit
ae06c8e5d1
42 files changed
+443
-195
No files matched your search
+5
-5
@@ -32,7 +32,7 @@
|
|||||||
"activationEvents": [
|
"activationEvents": [
|
||||||
"onView:superpowers.kanban",
|
"onView:superpowers.kanban",
|
||||||
"onCommand:superpowers.openKanban",
|
"onCommand:superpowers.openKanban",
|
||||||
"onCommand:superpowers.setGiteaToken",
|
"onCommand:superpowers.openSettings",
|
||||||
"onCommand:superpowers.configureHandoffStore",
|
"onCommand:superpowers.configureHandoffStore",
|
||||||
"onUri",
|
"onUri",
|
||||||
"onView:superpowers.codeReader.files",
|
"onView:superpowers.codeReader.files",
|
||||||
@@ -255,12 +255,12 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.refresh",
|
"command": "superpowers.refresh",
|
||||||
"title": "Refresh Issues (Gitea + YouTrack)",
|
"title": "Refresh Issues",
|
||||||
"category": "Superpowers"
|
"category": "Superpowers"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.setGiteaToken",
|
"command": "superpowers.openSettings",
|
||||||
"title": "Set Gitea Token",
|
"title": "Open Settings",
|
||||||
"category": "Superpowers"
|
"category": "Superpowers"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1423,7 +1423,7 @@
|
|||||||
"group": "navigation"
|
"group": "navigation"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "superpowers.setGiteaToken",
|
"command": "superpowers.openSettings",
|
||||||
"when": "view == superpowers.kanban",
|
"when": "view == superpowers.kanban",
|
||||||
"group": "navigation"
|
"group": "navigation"
|
||||||
},
|
},
|
||||||
|
|||||||
+26
-11
@@ -15,6 +15,9 @@ import { StatusBarAlignment, ThemeColor, window } from 'vscode'
|
|||||||
import { resolveForge } from '../forge/context'
|
import { resolveForge } from '../forge/context'
|
||||||
import { logger } from '../logging/logger'
|
import { logger } from '../logging/logger'
|
||||||
|
|
||||||
|
/** 打开看板设置的命令:配凭据、看当前登录状态都在设置里,状态栏点进去就是它。 */
|
||||||
|
export const OPEN_SETTINGS_COMMAND = 'superpowers.openSettings'
|
||||||
|
|
||||||
const userByHost = new Map<string, ForgeUser>()
|
const userByHost = new Map<string, ForgeUser>()
|
||||||
let statusBar: StatusBarItem | undefined
|
let statusBar: StatusBarItem | undefined
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export async function loginFor(forge: ForgeContext): Promise<string | undefined>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** token 变更后必须调用,否则状态栏和门禁还认旧身份。 */
|
/** 凭据变更(换 token、gh 换账号或登录失效)后必须调用,否则状态栏和门禁还认旧身份。 */
|
||||||
export function clearIdentityCache(host?: string): void {
|
export function clearIdentityCache(host?: string): void {
|
||||||
if (host)
|
if (host)
|
||||||
userByHost.delete(host)
|
userByHost.delete(host)
|
||||||
@@ -51,7 +54,7 @@ export function clearIdentityCache(host?: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 激活时(以及 token 保存后)调用:解析当前工作区的身份,展示到状态栏,并与 git 提交邮箱比对。
|
* 激活时(以及凭据变更后)调用:解析当前工作区的身份,展示到状态栏,并与 git 提交邮箱比对。
|
||||||
* token 被同步或复制到同事机器是发生过的真实事故(chw 机器),这里是最后一道人眼防线。
|
* token 被同步或复制到同事机器是发生过的真实事故(chw 机器),这里是最后一道人眼防线。
|
||||||
*/
|
*/
|
||||||
export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
||||||
@@ -59,6 +62,7 @@ export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
|||||||
statusBar = window.createStatusBarItem(StatusBarAlignment.Left, 0)
|
statusBar = window.createStatusBarItem(StatusBarAlignment.Left, 0)
|
||||||
ctx.subscriptions.push(statusBar)
|
ctx.subscriptions.push(statusBar)
|
||||||
}
|
}
|
||||||
|
statusBar.command = OPEN_SETTINGS_COMMAND
|
||||||
|
|
||||||
const resolved = await resolveForge(ctx)
|
const resolved = await resolveForge(ctx)
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
@@ -66,9 +70,14 @@ export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
|||||||
statusBar.hide()
|
statusBar.hide()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
statusBar.text = '$(account) spx: 未配置 token'
|
if (resolved.kind === 'github') {
|
||||||
statusBar.tooltip = `尚未为 ${resolved.host} 配置访问令牌,点击配置`
|
statusBar.text = '$(account) spx: 未登录 gh'
|
||||||
statusBar.command = 'superpowers.setGiteaToken'
|
statusBar.tooltip = 'GitHub 仓库用 GitHub CLI 的登录,请在终端运行 gh auth login'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
statusBar.text = '$(account) spx: 未配置 token'
|
||||||
|
statusBar.tooltip = `尚未为 ${resolved.host} 配置访问令牌,点击配置`
|
||||||
|
}
|
||||||
statusBar.backgroundColor = new ThemeColor('statusBarItem.warningBackground')
|
statusBar.backgroundColor = new ThemeColor('statusBarItem.warningBackground')
|
||||||
statusBar.show()
|
statusBar.show()
|
||||||
return
|
return
|
||||||
@@ -77,9 +86,14 @@ export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
|||||||
const { forge } = resolved
|
const { forge } = resolved
|
||||||
const login = await loginFor(forge)
|
const login = await loginFor(forge)
|
||||||
if (!login) {
|
if (!login) {
|
||||||
statusBar.text = '$(account) spx: token 失效'
|
if (forge.kind === 'github') {
|
||||||
statusBar.tooltip = `${forge.host} 的 token 无法解析身份,点击重新配置`
|
statusBar.text = '$(account) spx: gh 登录失效'
|
||||||
statusBar.command = 'superpowers.setGiteaToken'
|
statusBar.tooltip = 'GitHub CLI 的登录无法解析身份,请在终端运行 gh auth login 重新登录'
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
statusBar.text = '$(account) spx: token 失效'
|
||||||
|
statusBar.tooltip = `${forge.host} 的 token 无法解析身份,点击重新配置`
|
||||||
|
}
|
||||||
statusBar.backgroundColor = new ThemeColor('statusBarItem.errorBackground')
|
statusBar.backgroundColor = new ThemeColor('statusBarItem.errorBackground')
|
||||||
statusBar.show()
|
statusBar.show()
|
||||||
return
|
return
|
||||||
@@ -87,7 +101,6 @@ export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
|||||||
|
|
||||||
statusBar.text = `$(account) spx: ${login}`
|
statusBar.text = `$(account) spx: ${login}`
|
||||||
statusBar.tooltip = `身份 ${login} @ ${forge.host}`
|
statusBar.tooltip = `身份 ${login} @ ${forge.host}`
|
||||||
statusBar.command = 'superpowers.setGiteaToken'
|
|
||||||
statusBar.backgroundColor = undefined
|
statusBar.backgroundColor = undefined
|
||||||
statusBar.show()
|
statusBar.show()
|
||||||
logger.add({
|
logger.add({
|
||||||
@@ -100,7 +113,9 @@ export async function initIdentity(ctx: ExtensionContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代码托管账号邮箱对不上 `git config user.email`,基本等于用了别人的 token 或 git 身份没配;只警告不拦截,同名多邮箱的合法场景存在。
|
* 代码托管账号邮箱对不上 `git config user.email`,基本等于用了别人的凭据或 git 身份没配;只警告不拦截,同名多邮箱的合法场景存在。
|
||||||
|
*
|
||||||
|
* 账号没公开邮箱时(GitHub 常见)没得比,直接跳过。
|
||||||
*/
|
*/
|
||||||
function checkGitIdentityMatch(workspaceRoot: string, host: string, login: string): void {
|
function checkGitIdentityMatch(workspaceRoot: string, host: string, login: string): void {
|
||||||
const forgeEmail = userByHost.get(host)?.email?.trim().toLowerCase()
|
const forgeEmail = userByHost.get(host)?.email?.trim().toLowerCase()
|
||||||
@@ -116,6 +131,6 @@ function checkGitIdentityMatch(workspaceRoot: string, host: string, login: strin
|
|||||||
message: `代码托管身份与 git 提交邮箱不一致: ${login}(${forgeEmail}) vs git(${gitEmail})`,
|
message: `代码托管身份与 git 提交邮箱不一致: ${login}(${forgeEmail}) vs git(${gitEmail})`,
|
||||||
})
|
})
|
||||||
void window.showWarningMessage(
|
void window.showWarningMessage(
|
||||||
`token 身份是 ${login}(${forgeEmail}),但 git 提交邮箱是 ${gitEmail}。确认没有用错同事的 token。`,
|
`代码托管账号是 ${login}(${forgeEmail}),但 git 提交邮箱是 ${gitEmail}。确认没有用错同事的凭据。`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Helpers around VS Code's SecretStorage for storing per-host Gitea personal
|
* Helpers around VS Code's SecretStorage for storing per-host Gitea personal
|
||||||
* access tokens.
|
* access tokens. GitHub repos borrow the gh CLI login and store nothing here.
|
||||||
*
|
*
|
||||||
* Tokens are keyed by host so the user can connect to multiple Gitea instances
|
* Tokens are keyed by host so the user can connect to multiple Gitea instances
|
||||||
* without the extension confusing their credentials.
|
* without the extension confusing their credentials.
|
||||||
@@ -26,7 +26,7 @@ export async function deleteToken(ctx: ExtensionContext, host: string): Promise<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* YouTrack permanent tokens, keyed by host so they never collide with the
|
* YouTrack permanent tokens, keyed by host so they never collide with the
|
||||||
* gitea token of the same host.
|
* Gitea token of the same host.
|
||||||
*/
|
*/
|
||||||
export function youtrackTokenKey(host: string): string {
|
export function youtrackTokenKey(host: string): string {
|
||||||
return `youtrack-token:${host}`
|
return `youtrack-token:${host}`
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||||
/** Gitea 的用户名、仓库名只有字母、数字和 `_` `.` `-`;`/`、`!` 这类会弄乱 key 的字符一律不收。 */
|
/** Gitea 和 GitHub 的用户名、仓库名都只有字母、数字和 `_` `.` `-`;`/`、`!` 这类会弄乱 key 的字符一律不收。 */
|
||||||
const NAME_RE = /^[\w.-]+$/
|
const NAME_RE = /^[\w.-]+$/
|
||||||
const KEY_RE = /^spx-handoff\/([\w.-]+)\/([\w.-]+)\/session\/([\w.-]+)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([^!/]*)\.tar\.xz$/i
|
const KEY_RE = /^spx-handoff\/([\w.-]+)\/([\w.-]+)\/session\/([\w.-]+)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})!([\w.-]+)!([^!/]*)\.tar\.xz$/i
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export function slugSessionName(name: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* `to` 在这个仓库的会话交接收件箱:OSS 上的目录前缀 `spx-handoff/<owner>/<repo>/session/<to>/`。
|
* `to` 在这个仓库的会话交接收件箱:OSS 上的目录前缀 `spx-handoff/<owner>/<repo>/session/<to>/`。
|
||||||
* 结尾带 `/`,列 `bob` 的收件箱不会列到 `bobby` 的包。三段都转小写:Gitea 的用户名、仓库名
|
* 结尾带 `/`,列 `bob` 的收件箱不会列到 `bobby` 的包。三段都转小写:代码托管的用户名、仓库名
|
||||||
* 不分大小写,各人 git remote 地址里的写法可能不一样,转小写两边才对得上同一个收件箱。
|
* 不分大小写,各人 git remote 地址里的写法可能不一样,转小写两边才对得上同一个收件箱。
|
||||||
*
|
*
|
||||||
* @throws owner / repo / to 含 `[\w.-]` 以外的字符。
|
* @throws owner / repo / to 含 `[\w.-]` 以外的字符。
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function inReviewColumns(column: IssueColumn): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 选工单弹框列哪些工单:只要 Gitea 的(PR 在 Gitea 上,YouTrack 的不算);「审查」列的都列(没 PR 的选中时说一声),
|
* 选工单弹框列哪些工单:只要代码托管仓库的(PR 在那里,YouTrack 的不算);「审查」列的都列(没 PR 的选中时说一声),
|
||||||
* 「进行中」列的只列已经有 PR 的(没 PR 的还在开发,列出来只会添乱)。按列分组(REVIEW_COLUMNS 的次序),
|
* 「进行中」列的只列已经有 PR 的(没 PR 的还在开发,列出来只会添乱)。按列分组(REVIEW_COLUMNS 的次序),
|
||||||
* 组里工单号大的在前(和看板列里一样)。
|
* 组里工单号大的在前(和看板列里一样)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
} from 'vscode'
|
} from 'vscode'
|
||||||
import type { CodeReaderApi } from './codeReader/contract'
|
import type { CodeReaderApi } from './codeReader/contract'
|
||||||
import { commands, EventEmitter, window } from 'vscode'
|
import { commands, EventEmitter, window } from 'vscode'
|
||||||
import { initIdentity } from './auth/identity'
|
import { initIdentity, OPEN_SETTINGS_COMMAND } from './auth/identity'
|
||||||
import { setProfilesDirOverride } from './cc/profiles'
|
import { setProfilesDirOverride } from './cc/profiles'
|
||||||
import { registerCodeReader } from './codeReader'
|
import { registerCodeReader } from './codeReader'
|
||||||
import { migrateLegacySourceKeys } from './issues/localState'
|
import { migrateLegacySourceKeys } from './issues/localState'
|
||||||
@@ -107,7 +107,7 @@ export function activate(context: ExtensionContext): SuperpowersApi {
|
|||||||
commands.registerCommand('superpowers.refresh', () => {
|
commands.registerCommand('superpowers.refresh', () => {
|
||||||
KanbanWebviewPanel.refresh()
|
KanbanWebviewPanel.refresh()
|
||||||
}),
|
}),
|
||||||
commands.registerCommand('superpowers.setGiteaToken', () => {
|
commands.registerCommand(OPEN_SETTINGS_COMMAND, () => {
|
||||||
KanbanWebviewPanel.createOrShow(context)
|
KanbanWebviewPanel.createOrShow(context)
|
||||||
KanbanWebviewPanel.requestEditAuth()
|
KanbanWebviewPanel.requestEditAuth()
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Detects the workspace's forge repo coordinates (host / owner / repo) from `git remote get-url origin`.
|
* Detects the workspace's forge repo coordinates (host / owner / repo) from `git remote get-url origin`.
|
||||||
*
|
*
|
||||||
* Supports both HTTPS forms (`https://gitea.x.cn/owner/repo.git`) and SSH
|
* Supports both HTTPS forms (`https://git.example.com/owner/repo.git`) and SSH
|
||||||
* forms (`git@gitea.x.cn:owner/repo.git`, with or without a leading
|
* forms (`git@git.example.com:owner/repo.git`, with or without a leading
|
||||||
* `ssh://`). The pure URL parser is exported separately so it's unit-testable
|
* `ssh://`). The pure URL parser is exported separately so it's unit-testable
|
||||||
* without spawning a child process.
|
* without spawning a child process.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ export async function ensureWorktreeFromRemote(opts: WorktreeOpts): Promise<stri
|
|||||||
return worktreePath
|
return worktreePath
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 从 origin 拉 PR 的最新提交:Gitea 给每个 PR 留了 `refs/pull/<n>/head`,PR 的分支本机没有也拉得到。 */
|
/** 从 origin 拉 PR 的最新提交:Gitea 和 GitHub 都给每个 PR 留了 `refs/pull/<n>/head`,PR 的分支本机没有也拉得到。 */
|
||||||
export async function fetchPullRequestHead(workspaceRoot: string, pr: number): Promise<void> {
|
export async function fetchPullRequestHead(workspaceRoot: string, pr: number): Promise<void> {
|
||||||
await runGit(workspaceRoot, ['fetch', 'origin', `refs/pull/${pr}/head`], `拉取 PR #${pr}`)
|
await runGit(workspaceRoot, ['fetch', 'origin', `refs/pull/${pr}/head`], `拉取 PR #${pr}`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
* - WORKTREE_PATH — absolute path of the worktree
|
* - WORKTREE_PATH — absolute path of the worktree
|
||||||
* - WORKSPACE_ROOT — absolute path of the main workspace
|
* - WORKSPACE_ROOT — absolute path of the main workspace
|
||||||
* - BRANCH — feature branch name
|
* - BRANCH — feature branch name
|
||||||
* - ISSUE_NUMBER — gitea issue number (decimal string)
|
* - ISSUE_NUMBER — forge issue number (decimal string)
|
||||||
* - MAIN_BRANCH — settings.devBranch (default 'main')
|
* - MAIN_BRANCH — settings.devBranch (default 'main')
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export interface LogEntry {
|
|||||||
/** Milliseconds since epoch. */
|
/** Milliseconds since epoch. */
|
||||||
ts: number
|
ts: number
|
||||||
level: LogLevel
|
level: LogLevel
|
||||||
/** Short tag identifying the subsystem (e.g. 'webhook', 'gitea'). */
|
/** Short tag identifying the subsystem (e.g. 'webhook', 'identity'). */
|
||||||
source: string
|
source: string
|
||||||
message: string
|
message: string
|
||||||
/** Optional extra blob, newline-safe (stack trace, JSON snippet, etc.). */
|
/** Optional extra blob, newline-safe (stack trace, JSON snippet, etc.). */
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import type { KanbanScope } from './kanbanScope'
|
|||||||
import type { BoardIssues } from './kanbanSnapshot'
|
import type { BoardIssues } from './kanbanSnapshot'
|
||||||
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
import type { ExtensionToWebview, WebviewToExtension } from './messages'
|
||||||
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
|
||||||
|
import { clearIdentityCache } from '../auth/identity'
|
||||||
import { deleteToken } from '../auth/secrets'
|
import { deleteToken } from '../auth/secrets'
|
||||||
import { importPluginIntoFigma } from '../figma/importPlugin'
|
import { importPluginIntoFigma } from '../figma/importPlugin'
|
||||||
import { resolveForge } from '../forge/context'
|
import { resolveForge } from '../forge/context'
|
||||||
|
import { GH_RELOGIN_HINT } from '../forge/github/ghAuth'
|
||||||
import { loadIssues } from '../forge/issueLoader'
|
import { loadIssues } from '../forge/issueLoader'
|
||||||
import { ForgeApiError } from '../forge/types'
|
import { ForgeApiError } from '../forge/types'
|
||||||
import { overlayLocalIssueState } from '../issues/localState'
|
import { overlayLocalIssueState } from '../issues/localState'
|
||||||
@@ -69,7 +71,7 @@ export class KanbanWebviewPanel {
|
|||||||
private issueRefs = new Map<number, { source: 'forge' | 'youtrack', externalId?: string }>()
|
private issueRefs = new Map<number, { source: 'forge' | 'youtrack', externalId?: string }>()
|
||||||
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
|
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
|
||||||
private issueOwners = new Map<number, IssueOwnershipFacts>()
|
private issueOwners = new Map<number, IssueOwnershipFacts>()
|
||||||
/** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */
|
/** 本机凭据在代码托管仓库上对应的 login;解析失败为 undefined(此时不锁任何工单)。 */
|
||||||
private me: string | undefined
|
private me: string | undefined
|
||||||
/** 页面发过首次刷新请求后才推送卡片,避免页面启动时丢消息。 */
|
/** 页面发过首次刷新请求后才推送卡片,避免页面启动时丢消息。 */
|
||||||
private webviewReady = false
|
private webviewReady = false
|
||||||
@@ -882,15 +884,14 @@ export class KanbanWebviewPanel {
|
|||||||
* 2. Move the issue to in-progress and stamp branch/worktreePath/status
|
* 2. Move the issue to in-progress and stamp branch/worktreePath/status
|
||||||
* into the state-JSON comment.
|
* into the state-JSON comment.
|
||||||
* 3. Confirm the always-on webhook coordinator is bound to the current
|
* 3. Confirm the always-on webhook coordinator is bound to the current
|
||||||
* port, then register a gitea webhook scoped to the feature branch
|
* port and hand the bookkeeping to the coordinator.
|
||||||
* and hand the bookkeeping to the coordinator.
|
|
||||||
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
|
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
|
||||||
* tells cc to create the worktree, implement the plan, and emit
|
* tells cc to create the worktree, implement the plan, and emit
|
||||||
* `<request_review>$pr_no</request_review>` when done.
|
* `<request_review>$pr_no</request_review>` when done.
|
||||||
*
|
*
|
||||||
* The webhook coordinator (see src/webhook/coordinator.ts) receives the
|
* The webhook coordinator (see src/webhook/coordinator.ts) receives the
|
||||||
* resulting PR event, writes back the PR number, and deletes the gitea
|
* resulting PR event and writes back the PR number — even if this panel
|
||||||
* hook — even if this panel has since been closed.
|
* has since been closed.
|
||||||
*/
|
*/
|
||||||
// internal: handler 模块访问
|
// internal: handler 模块访问
|
||||||
async handleImplement(
|
async handleImplement(
|
||||||
@@ -924,7 +925,7 @@ export class KanbanWebviewPanel {
|
|||||||
* `done` column.
|
* `done` column.
|
||||||
*
|
*
|
||||||
* Fail-open: any error fetching issues is logged and we return
|
* Fail-open: any error fetching issues is logged and we return
|
||||||
* `{ locked: false }` so a transient Gitea hiccup can't lock the user out
|
* `{ locked: false }` so a transient forge hiccup can't lock the user out
|
||||||
* of starting a session.
|
* of starting a session.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
@@ -946,7 +947,7 @@ export class KanbanWebviewPanel {
|
|||||||
/**
|
/**
|
||||||
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
|
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
|
||||||
*
|
*
|
||||||
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
|
* `issues/update` 会用代码托管仓库里持久化的 state JSON 整体替换 webview
|
||||||
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
|
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
|
||||||
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
|
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
|
||||||
* 按钮能找到对应 terminal。
|
* 按钮能找到对应 terminal。
|
||||||
@@ -975,7 +976,7 @@ export class KanbanWebviewPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reloads issues from gitea and pushes to the webview. Public so the
|
* Reloads issues from the forge and YouTrack and pushes to the webview. Public so the
|
||||||
* always-on webhook coordinator can refresh us when a PR event arrives.
|
* always-on webhook coordinator can refresh us when a PR event arrives.
|
||||||
*/
|
*/
|
||||||
async loadAndPush(): Promise<void> {
|
async loadAndPush(): Promise<void> {
|
||||||
@@ -1014,7 +1015,7 @@ export class KanbanWebviewPanel {
|
|||||||
if (this.activeLoad !== load || load.invalidated)
|
if (this.activeLoad !== load || load.invalidated)
|
||||||
return
|
return
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
// 只有 Gitea 的 token 存在插件设置里,缺了就弹设置让用户填;别的原因直接显示。
|
// 只有 Gitea 的 token 存在插件设置里,缺了就弹设置让用户填;GitHub 要用户自己去终端登录 gh,和别的原因一样直接显示,页面上有重试。
|
||||||
if (resolved.reason === 'no-credentials' && resolved.kind === 'gitea')
|
if (resolved.reason === 'no-credentials' && resolved.kind === 'gitea')
|
||||||
await this.showTokenSettings(resolved.host)
|
await this.showTokenSettings(resolved.host)
|
||||||
else
|
else
|
||||||
@@ -1026,13 +1027,13 @@ export class KanbanWebviewPanel {
|
|||||||
const { workspaceRoot, host, owner, repo } = forge
|
const { workspaceRoot, host, owner, repo } = forge
|
||||||
const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
|
const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
|
||||||
try {
|
try {
|
||||||
const [{ issues: giteaIssues, me, avatars }, youtrackList] = await Promise.all([
|
const [{ issues: forgeIssues, me, avatars }, youtrackList] = await Promise.all([
|
||||||
loadIssues(forge, scope),
|
loadIssues(forge, scope),
|
||||||
this.loadYouTrackSafe(),
|
this.loadYouTrackSafe(),
|
||||||
])
|
])
|
||||||
if (this.activeLoad !== load || load.invalidated)
|
if (this.activeLoad !== load || load.invalidated)
|
||||||
return
|
return
|
||||||
const recoveryIssues = load.updates.reduce(applyIssueUpdate, [...giteaIssues, ...youtrackList])
|
const recoveryIssues = load.updates.reduce(applyIssueUpdate, [...forgeIssues, ...youtrackList])
|
||||||
this.issueRefs = new Map(
|
this.issueRefs = new Map(
|
||||||
recoveryIssues.map(i => [i.number, { source: i.source ?? 'forge', externalId: i.externalId }] as const),
|
recoveryIssues.map(i => [i.number, { source: i.source ?? 'forge', externalId: i.externalId }] as const),
|
||||||
)
|
)
|
||||||
@@ -1043,7 +1044,7 @@ export class KanbanWebviewPanel {
|
|||||||
// 会话文件的会话 id,再补 live terminal 态。
|
// 会话文件的会话 id,再补 live terminal 态。
|
||||||
const issues = this.withLiveTerminalTabState(
|
const issues = this.withLiveTerminalTabState(
|
||||||
await annotateMissingSessionIds(
|
await annotateMissingSessionIds(
|
||||||
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
|
overlayLocalIssueState(this.context, [...forgeIssues, ...youtrackList], workspaceRoot),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (this.activeLoad !== load || load.invalidated)
|
if (this.activeLoad !== load || load.invalidated)
|
||||||
@@ -1075,7 +1076,12 @@ export class KanbanWebviewPanel {
|
|||||||
catch (err) {
|
catch (err) {
|
||||||
if (this.activeLoad !== load || load.invalidated)
|
if (this.activeLoad !== load || load.invalidated)
|
||||||
return
|
return
|
||||||
if (err instanceof ForgeApiError && err.status === 401 && forge.kind === 'gitea') {
|
if (err instanceof ForgeApiError && err.status === 401) {
|
||||||
|
clearIdentityCache(host)
|
||||||
|
if (forge.kind === 'github') {
|
||||||
|
this.postMessage({ type: 'issues/error', message: GH_RELOGIN_HINT })
|
||||||
|
return
|
||||||
|
}
|
||||||
await deleteToken(this.context, host)
|
await deleteToken(this.context, host)
|
||||||
await this.showTokenSettings(host, 'Token 无效或已过期,请重新填写')
|
await this.showTokenSettings(host, 'Token 无效或已过期,请重新填写')
|
||||||
return
|
return
|
||||||
@@ -1092,6 +1098,7 @@ export class KanbanWebviewPanel {
|
|||||||
this.postMessage({
|
this.postMessage({
|
||||||
type: 'settings/show',
|
type: 'settings/show',
|
||||||
host,
|
host,
|
||||||
|
forgeKind: 'gitea',
|
||||||
...(errorMessage ? { errorMessage } : {}),
|
...(errorMessage ? { errorMessage } : {}),
|
||||||
tokenSaved: false,
|
tokenSaved: false,
|
||||||
webhookPort: s.webhookPort,
|
webhookPort: s.webhookPort,
|
||||||
@@ -1164,13 +1171,13 @@ export class KanbanWebviewPanel {
|
|||||||
return this.issueRefs.get(issueNumber)?.source === 'youtrack'
|
return this.issueRefs.get(issueNumber)?.source === 'youtrack'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read workflow state from the issue's tracker (gitea or youtrack). */
|
/** Read workflow state from the issue's tracker (forge or youtrack). */
|
||||||
// internal: handler 模块访问
|
// internal: handler 模块访问
|
||||||
readIssueState(issueNumber: number): Promise<Record<string, unknown>> {
|
readIssueState(issueNumber: number): Promise<Record<string, unknown>> {
|
||||||
return readIssueState(this.context, this.refFor(issueNumber))
|
return readIssueState(this.context, this.refFor(issueNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Merge workflow state into the issue's tracker (gitea or youtrack). */
|
/** Merge workflow state into the issue's tracker (forge or youtrack). */
|
||||||
// internal: handler 模块访问
|
// internal: handler 模块访问
|
||||||
mergeIssueState(issueNumber: number, extra: Record<string, unknown>): Promise<void> {
|
mergeIssueState(issueNumber: number, extra: Record<string, unknown>): Promise<void> {
|
||||||
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
|
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
|
||||||
|
|||||||
@@ -6,14 +6,14 @@ import { logger } from '../../logging/logger'
|
|||||||
* 清理一个 feature 分支(远程 + 本地),供「拖到完成」和「手动删除 worktree」两处共用。
|
* 清理一个 feature 分支(远程 + 本地),供「拖到完成」和「手动删除 worktree」两处共用。
|
||||||
*
|
*
|
||||||
* - 受保护分支(main/master/devBranch)直接跳过,绝不删。
|
* - 受保护分支(main/master/devBranch)直接跳过,绝不删。
|
||||||
* - 远程删除走 gitea API,需 remote + token;缺任一则只删本地,不阻断。
|
* - 远程删除走代码托管接口,需 remote + 凭据;缺任一则只删本地,不阻断。
|
||||||
* - 本地删除是强删(deleteLocalBranch 内部 `git branch -D`):调用方语义是「连分支一起删」,
|
* - 本地删除是强删(deleteLocalBranch 内部 `git branch -D`):调用方语义是「连分支一起删」,
|
||||||
* 未合并也照删。
|
* 未合并也照删。
|
||||||
* - 全程 best-effort:远程 / 本地各自 try/catch,失败只记日志——分支清理从不回滚已完成的主流程
|
* - 全程 best-effort:远程 / 本地各自 try/catch,失败只记日志——分支清理从不回滚已完成的主流程
|
||||||
* (worktree 已删、PR 已合并)。branch 为空直接返回。
|
* (worktree 已删、PR 已合并)。branch 为空直接返回。
|
||||||
*
|
*
|
||||||
* 放在 handlers 层而非 `git/branches.ts`:后者刻意不依赖 `vscode`(有单测覆盖
|
* 放在 handlers 层而非 `git/branches.ts`:后者刻意不依赖 `vscode`(有单测覆盖
|
||||||
* deleteLocalBranch),而本函数要用 logger(→ vscode)和 gitea API。
|
* deleteLocalBranch),而本函数要用 logger(→ vscode)和代码托管接口。
|
||||||
*/
|
*/
|
||||||
export async function cleanupFeatureBranch(opts: {
|
export async function cleanupFeatureBranch(opts: {
|
||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
return { ok: false, message }
|
return { ok: false, message }
|
||||||
}
|
}
|
||||||
|
|
||||||
// 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到
|
// 先拉远端 issue:既要兜底「远端已关闭但从没拖进看板」的工单,也要拿到
|
||||||
// assignees 判断「是否我负责」——两者都得在 canStartHandoff 判定前就绪。
|
// assignees 判断「是否我负责」——两者都得在 canStartHandoff 判定前就绪。
|
||||||
const issue = await provider.getIssue(issueNumber)
|
const issue = await provider.getIssue(issueNumber)
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
@@ -507,7 +507,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
|
|||||||
say('核对指派')
|
say('核对指派')
|
||||||
|
|
||||||
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
|
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
|
||||||
// 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。
|
// 重新指派、被删),用刚从代码托管仓库拉到的最新 issue 再判一次 canAcceptHandoff。
|
||||||
const issue = await provider.getIssue(issueNumber)
|
const issue = await provider.getIssue(issueNumber)
|
||||||
if (!issue) {
|
if (!issue) {
|
||||||
progress.dismiss()
|
progress.dismiss()
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Look up PR state on gitea.
|
// 3. Look up PR state on the forge.
|
||||||
let pullRequest: ForgePullRequest
|
let pullRequest: ForgePullRequest
|
||||||
try {
|
try {
|
||||||
pullRequest = await forge.provider.getPullRequest(prIndex)
|
pullRequest = await forge.provider.getPullRequest(prIndex)
|
||||||
@@ -224,7 +224,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
// 合并失败(冲突 / 已关闭 / 权限)回滚拖动到原列。
|
// 合并失败(冲突 / 已关闭 / 权限)回滚拖动到原列。
|
||||||
let outcome: MergeOutcome
|
let outcome: MergeOutcome
|
||||||
try {
|
try {
|
||||||
outcome = await forge.provider.mergePullRequest(prIndex, 'merge')
|
outcome = await forge.provider.mergePullRequest(prIndex, forge.provider.capabilities.mergeMethods[0])
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
outcome = { merged: false, reason: 'rejected', message: err instanceof Error ? err.message : String(err) }
|
outcome = { merged: false, reason: 'rejected', message: err instanceof Error ? err.message : String(err) }
|
||||||
@@ -745,8 +745,8 @@ export async function handleResetToTodo(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
* Validates the issue has a valid `planFile` recorded in its state,
|
* Validates the issue has a valid `planFile` recorded in its state,
|
||||||
* and that the file actually exists on disk, then delegates the full
|
* and that the file actually exists on disk, then delegates the full
|
||||||
* implementation pipeline to `handleImplement` (which owns issue locking,
|
* implementation pipeline to `handleImplement` (which owns issue locking,
|
||||||
* tab reuse, column/branch/worktreePath state writes, gitea webhook
|
* tab reuse, column/branch/worktreePath state writes, webhook
|
||||||
* registration, cc spawn). On any pre-flight failure, rolls the optimistic
|
* bookkeeping, cc spawn). On any pre-flight failure, rolls the optimistic
|
||||||
* column move back to the source column.
|
* column move back to the source column.
|
||||||
*/
|
*/
|
||||||
export async function handleDropToInProgress(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
export async function handleDropToInProgress(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
@@ -861,7 +861,7 @@ export async function handleDropToInProgress(panel: KanbanWebviewPanel, issueNum
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。
|
* 关闭代码托管仓库上的工单,不清理本地会话、worktree、PR 或分支。
|
||||||
* 成功后复用 issue/remove 消息,让 open issues 看板立即移除该工单。
|
* 成功后复用 issue/remove 消息,让 open issues 看板立即移除该工单。
|
||||||
*/
|
*/
|
||||||
export async function handleCloseIssue(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
export async function handleCloseIssue(panel: KanbanWebviewPanel, issueNumber: number): Promise<void> {
|
||||||
@@ -921,7 +921,7 @@ export async function handleCloseIssue(panel: KanbanWebviewPanel, issueNumber: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 硬删 Gitea 工单 + 关联资源(worktree / PR / feature branch / cc session
|
* 硬删代码托管仓库上的工单 + 关联资源(worktree / PR / feature branch / cc session
|
||||||
* tabs)。每步独立 try/catch,任一步失败立即停下并 toast 报错,让用户手动
|
* tabs)。每步独立 try/catch,任一步失败立即停下并 toast 报错,让用户手动
|
||||||
* 处理。前端在最后一步收到 `issue/remove` 后从 issues 数组移除该工单。
|
* 处理。前端在最后一步收到 `issue/remove` 后从 issues 数组移除该工单。
|
||||||
*/
|
*/
|
||||||
@@ -1029,7 +1029,7 @@ export async function handleDeleteIssue(panel: KanbanWebviewPanel, issueNumber:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. 关 PR(gitea 把 PR 当 issue subtype 处理,PATCH /issues/{prNumber} 即可)
|
// 6. 关 PR
|
||||||
if (prStr) {
|
if (prStr) {
|
||||||
const prIndex = Number.parseInt(prStr, 10)
|
const prIndex = Number.parseInt(prStr, 10)
|
||||||
if (Number.isFinite(prIndex)) {
|
if (Number.isFinite(prIndex)) {
|
||||||
@@ -1257,7 +1257,7 @@ export async function handleIssueCreate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 把 Gitea 工单状态改成 closed。failure non-fatal —— state JSON 已写、PR 已合、
|
* 把代码托管仓库上的工单关掉。failure non-fatal —— state JSON 已写、PR 已合、
|
||||||
* 看板已切到 done 列,rollback 意义不大。失败时打 warn 日志 + warning toast,
|
* 看板已切到 done 列,rollback 意义不大。失败时打 warn 日志 + warning toast,
|
||||||
* 用户可手动关闭工单。
|
* 用户可手动关闭工单。
|
||||||
*/
|
*/
|
||||||
@@ -1295,7 +1295,7 @@ export async function syncCloseIssue(panel: KanbanWebviewPanel, forge: ForgeCont
|
|||||||
* 完)时才退回一次全量拉取。
|
* 完)时才退回一次全量拉取。
|
||||||
*
|
*
|
||||||
* Fail-open: any error fetching issues is logged and we return
|
* Fail-open: any error fetching issues is logged and we return
|
||||||
* `{ locked: false }` so a transient Gitea hiccup can't lock the user out
|
* `{ locked: false }` so a transient forge hiccup can't lock the user out
|
||||||
* of starting a session.
|
* of starting a session.
|
||||||
*/
|
*/
|
||||||
export async function resolveLockedReason(
|
export async function resolveLockedReason(
|
||||||
@@ -1846,7 +1846,7 @@ export async function handleOpenFile(panel: KanbanWebviewPanel, relPath: string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the gitea PR URL for the current workspace's remote and open it
|
* Resolve the PR URL for the current workspace's remote and open it
|
||||||
* in the user's default browser. Called from the webview's pr-link
|
* in the user's default browser. Called from the webview's pr-link
|
||||||
* button.
|
* button.
|
||||||
*/
|
*/
|
||||||
@@ -1949,7 +1949,7 @@ export async function handleGeneratePrDiffSummary(panel: KanbanWebviewPanel, iss
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
const profilePath = resolution.profilePath
|
const profilePath = resolution.profilePath
|
||||||
const prompt = `你在一个 VS Code 扩展启动的后台 Claude 会话中工作。\n\n硬性限制:\n- 不要修改代码。\n- 不要创建或切换分支。\n- 不要提交。\n- 不要 push。\n- 不要写任何文件。\n- 只分析当前 PR 的代码变动,并把 Markdown 摘要正文直接输出到 stdout。\n\n任务:\n- 当前工作目录是工单 #\${issueNumber} 的 worktree。\n- 分析 PR #\${pr} 与主分支的代码差异。\n- 用 git 拿差异:先 git fetch origin main,再 git diff origin/main...HEAD(概览可加 --stat)。注意 tea 没有 diff 子命令,不要尝试 tea pulls diff。\n\n输出必须是合法 Markdown,预览器要能渲染出标题层级与代码块高亮,结构如下:\n1. 第一行是一级标题:# PR #\${pr} 代码变更摘要\n2. 紧接着用 1~2 句话概述本次变更:做了什么、新增了哪些包/模块、关键依赖方向(例如「stockpool/ 是与 orders/ catalog/ 平级的新子域包,其 service 只单向依赖 orders/catalog 的 repository,无循环导入」)。\n3. 然后写一行图例:图例:\\\`+\\\` = 新建文件 \\\`✎\\\` = 修改文件\n4. 然后是一棵统一的目录树,放进单个围栏代码块(语言标注用 text):树前一行写三个反引号紧跟 text,树后一行写三个反引号。\n\n目录树规则:\n- 用 box-drawing 连线字符画树:├──、└──、│,缩进对齐,像 tree 命令的输出,不要只用纯空格缩进糊一棵没有连线的树。\n- 从仓库根开始保留真实目录层级。\n- 每个文件行以标记开头:新建文件用「+ 」、修改文件用「✎ 」,后接文件名 + 若干空格对齐 + 一句中文目的描述(简短,点出关键符号/端点/职责)。\n- 目录节点行尾可加可选的阶段/分组标签(如 [Phase N]),仅当本次变更本身有清晰阶段时才加;没有就不加,不要硬编。\n- 关键:整棵树一定要写在围栏代码块之内,否则 Markdown 预览器会折叠空白、丢掉层级与连线。\n\n排序与重点:\n- 按架构重要性排列:核心业务/领域代码(实现逻辑、入口、API routes、数据模型、service/repository)靠前。\n- alembic 数据库迁移文件、测试文件不算重点:放在各自模块或整棵树的靠后位置,照常用 +/✎ 标记列出,但不要放在最前、不要强调。\n- 测试文件判定:文件名形如 test_* / *_test.* / *.test.* / *.spec.*,或路径包含 tests/、__tests__/ 目录,或 conftest.py、fixture、mock 文件。\n- 迁移文件判定:路径在 alembic/、migrations/、versions/ 之下的文件。\n\n格式示例(请照此结构输出,不要照抄内容):\n\n# PR #123 代码变更摘要\n\nstockpool/ 是与 orders/ catalog/ 平级的新子域包,其 service 只单向依赖 orders/catalog 的 repository,无循环导入。\n\n图例:\\\`+\\\` = 新建文件 \\\`✎\\\` = 修改文件\n\n\\\`\\\`\\\`text\nbackend/\n└── src/luffy_agent/commerce/\n ├── models.py ✎ +StockPool;ProductCatalog 加 pool_id\n ├── stockpool/\n │ ├── service.py + reserve_pool_or_raise + compute_pool_views\n │ └── routes.py + /admin/commerce/stock-pools\n ├── orders/service.py ✎ create_order 绑定池分支\n └── alembic/versions/ + stock_pool 建表 + pool_id 加列\n\\\`\\\`\\\`\n\n要求:\n- 每个新增/修改文件一行,一句话说明新增/修改了什么及其目的。\n- 整棵树只用一个围栏代码块,用 box-drawing 连线保留真实目录层级。\n- 只输出上述 Markdown 正文本身,不要写文件,不要任何前导说明/寒暄/结尾总结,第一行就是 # PR #\${pr} 代码变更摘要 标题。`
|
const prompt = `你在一个 VS Code 扩展启动的后台 Claude 会话中工作。\n\n硬性限制:\n- 不要修改代码。\n- 不要创建或切换分支。\n- 不要提交。\n- 不要 push。\n- 不要写任何文件。\n- 只分析当前 PR 的代码变动,并把 Markdown 摘要正文直接输出到 stdout。\n\n任务:\n- 当前工作目录是工单 #\${issueNumber} 的 worktree。\n- 分析 PR #\${pr} 与主分支的代码差异。\n- 用 git 拿差异:先 git fetch origin main,再 git diff origin/main...HEAD(概览可加 --stat)。差异只用 git 取,不要调用代码托管平台的命令行工具或接口。\n\n输出必须是合法 Markdown,预览器要能渲染出标题层级与代码块高亮,结构如下:\n1. 第一行是一级标题:# PR #\${pr} 代码变更摘要\n2. 紧接着用 1~2 句话概述本次变更:做了什么、新增了哪些包/模块、关键依赖方向(例如「stockpool/ 是与 orders/ catalog/ 平级的新子域包,其 service 只单向依赖 orders/catalog 的 repository,无循环导入」)。\n3. 然后写一行图例:图例:\\\`+\\\` = 新建文件 \\\`✎\\\` = 修改文件\n4. 然后是一棵统一的目录树,放进单个围栏代码块(语言标注用 text):树前一行写三个反引号紧跟 text,树后一行写三个反引号。\n\n目录树规则:\n- 用 box-drawing 连线字符画树:├──、└──、│,缩进对齐,像 tree 命令的输出,不要只用纯空格缩进糊一棵没有连线的树。\n- 从仓库根开始保留真实目录层级。\n- 每个文件行以标记开头:新建文件用「+ 」、修改文件用「✎ 」,后接文件名 + 若干空格对齐 + 一句中文目的描述(简短,点出关键符号/端点/职责)。\n- 目录节点行尾可加可选的阶段/分组标签(如 [Phase N]),仅当本次变更本身有清晰阶段时才加;没有就不加,不要硬编。\n- 关键:整棵树一定要写在围栏代码块之内,否则 Markdown 预览器会折叠空白、丢掉层级与连线。\n\n排序与重点:\n- 按架构重要性排列:核心业务/领域代码(实现逻辑、入口、API routes、数据模型、service/repository)靠前。\n- alembic 数据库迁移文件、测试文件不算重点:放在各自模块或整棵树的靠后位置,照常用 +/✎ 标记列出,但不要放在最前、不要强调。\n- 测试文件判定:文件名形如 test_* / *_test.* / *.test.* / *.spec.*,或路径包含 tests/、__tests__/ 目录,或 conftest.py、fixture、mock 文件。\n- 迁移文件判定:路径在 alembic/、migrations/、versions/ 之下的文件。\n\n格式示例(请照此结构输出,不要照抄内容):\n\n# PR #123 代码变更摘要\n\nstockpool/ 是与 orders/ catalog/ 平级的新子域包,其 service 只单向依赖 orders/catalog 的 repository,无循环导入。\n\n图例:\\\`+\\\` = 新建文件 \\\`✎\\\` = 修改文件\n\n\\\`\\\`\\\`text\nbackend/\n└── src/luffy_agent/commerce/\n ├── models.py ✎ +StockPool;ProductCatalog 加 pool_id\n ├── stockpool/\n │ ├── service.py + reserve_pool_or_raise + compute_pool_views\n │ └── routes.py + /admin/commerce/stock-pools\n ├── orders/service.py ✎ create_order 绑定池分支\n └── alembic/versions/ + stock_pool 建表 + pool_id 加列\n\\\`\\\`\\\`\n\n要求:\n- 每个新增/修改文件一行,一句话说明新增/修改了什么及其目的。\n- 整棵树只用一个围栏代码块,用 box-drawing 连线保留真实目录层级。\n- 只输出上述 Markdown 正文本身,不要写文件,不要任何前导说明/寒暄/结尾总结,第一行就是 # PR #\${pr} 代码变更摘要 标题。`
|
||||||
|
|
||||||
const result = await spawnClaude({
|
const result = await spawnClaude({
|
||||||
prompt,
|
prompt,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ function nonEmptyString(value: unknown): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 会话 id 来自 Gitea 共享状态,别的机器上开的会话本机没有会话文件,resume
|
* 会话 id 来自工单上的共享状态,别的机器上开的会话本机没有会话文件,resume
|
||||||
* 必然报「No conversation found」。各 resume 入口在开新终端前查一次,没有就
|
* 必然报「No conversation found」。各 resume 入口在开新终端前查一次,没有就
|
||||||
* 用这条 toast 拦下(已开着的 tab 直接复用,不受影响)。
|
* 用这条 toast 拦下(已开着的 tab 直接复用,不受影响)。
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +100,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
|
|||||||
try {
|
try {
|
||||||
// Server-side prerequisite gate. Webview already disables the entry
|
// Server-side prerequisite gate. Webview already disables the entry
|
||||||
// visually (see `isIssueLocked` in the kanban UI), but a user who
|
// visually (see `isIssueLocked` in the kanban UI), but a user who
|
||||||
// bypasses that — message tampering, editing Gitea directly, etc. —
|
// bypasses that — message tampering, editing the forge directly, etc. —
|
||||||
// would otherwise still get a session. Skip when `issueNumber` is
|
// would otherwise still get a session. Skip when `issueNumber` is
|
||||||
// unknown (legacy sessions) so we don't gate flows that never had a
|
// unknown (legacy sessions) so we don't gate flows that never had a
|
||||||
// prerequisite concept.
|
// prerequisite concept.
|
||||||
@@ -572,15 +572,14 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
|
|||||||
* 2. Move the issue to in-progress and stamp branch/worktreePath/status
|
* 2. Move the issue to in-progress and stamp branch/worktreePath/status
|
||||||
* into the state-JSON comment.
|
* into the state-JSON comment.
|
||||||
* 3. Confirm the always-on webhook coordinator is bound to the current
|
* 3. Confirm the always-on webhook coordinator is bound to the current
|
||||||
* port, then register a gitea webhook scoped to the feature branch
|
* port and hand the bookkeeping to the coordinator.
|
||||||
* and hand the bookkeeping to the coordinator.
|
|
||||||
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
|
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
|
||||||
* tells cc to create the worktree, implement the plan, and emit
|
* tells cc to create the worktree, implement the plan, and emit
|
||||||
* `<request_review>$pr_no</request_review>` when done.
|
* `<request_review>$pr_no</request_review>` when done.
|
||||||
*
|
*
|
||||||
* The webhook coordinator (see src/webhook/coordinator.ts) receives the
|
* The webhook coordinator (see src/webhook/coordinator.ts) receives the
|
||||||
* resulting PR event, writes back the PR number, and deletes the gitea
|
* resulting PR event and writes back the PR number — even if this panel
|
||||||
* hook — even if this panel has since been closed.
|
* has since been closed.
|
||||||
*/
|
*/
|
||||||
export async function handleImplement(
|
export async function handleImplement(
|
||||||
panel: KanbanWebviewPanel,
|
panel: KanbanWebviewPanel,
|
||||||
@@ -653,7 +652,7 @@ export async function handleImplement(
|
|||||||
|
|
||||||
// The webhook coordinator is started at extension activation; just
|
// The webhook coordinator is started at extension activation; just
|
||||||
// ensure the port matches the current setting (no-op if unchanged). The
|
// ensure the port matches the current setting (no-op if unchanged). The
|
||||||
// user must configure a single shared webhook in gitea pointing at
|
// user must configure a single shared webhook in the repo pointing at
|
||||||
// `<publicUrl>/webhook`; the extension no longer auto-creates per-issue
|
// `<publicUrl>/webhook`; the extension no longer auto-creates per-issue
|
||||||
// hooks, and PR→issue resolution happens via `Closes #N` in the PR body.
|
// hooks, and PR→issue resolution happens via `Closes #N` in the PR body.
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ import * as os from 'node:os'
|
|||||||
import * as path from 'node:path'
|
import * as path from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
import { commands, env, RelativePattern, Uri, workspace } from 'vscode'
|
import { commands, env, RelativePattern, Uri, workspace } from 'vscode'
|
||||||
import { clearIdentityCache, initIdentity } from '../../auth/identity'
|
import { clearIdentityCache, initIdentity, loginFor } from '../../auth/identity'
|
||||||
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
|
import { getToken, getYouTrackToken, setToken, setYouTrackToken } from '../../auth/secrets'
|
||||||
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
|
import { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
|
||||||
|
import { resolveForge } from '../../forge/context'
|
||||||
|
import { forgeKindForHost } from '../../forge/types'
|
||||||
import { detectRepo } from '../../git/remote'
|
import { detectRepo } from '../../git/remote'
|
||||||
import { logger } from '../../logging/logger'
|
import { logger } from '../../logging/logger'
|
||||||
import { readProfiles, writeProfiles } from '../../profiles/store'
|
import { readProfiles, writeProfiles } from '../../profiles/store'
|
||||||
@@ -100,18 +102,23 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
// profiles 目录:空串有意义(= 自动探测),只 trim。
|
// profiles 目录:空串有意义(= 自动探测),只 trim。
|
||||||
const trimmedProfilesDirectory = payload.profilesDirectory.trim()
|
const trimmedProfilesDirectory = payload.profilesDirectory.trim()
|
||||||
const prev = getSettings(panel.context)
|
const prev = getSettings(panel.context)
|
||||||
|
const forgeKind = forgeKindForHost(trimmedHost)
|
||||||
|
// GitHub 仓库用 gh 的登录,设置里没有 token 要存。
|
||||||
|
const usesToken = forgeKind === 'gitea'
|
||||||
// Capture the previous token *for this host* before overwriting it, so
|
// Capture the previous token *for this host* before overwriting it, so
|
||||||
// we can decide below whether the kanban needs a re-fetch. (Only host
|
// we can decide below whether the kanban needs a re-fetch. (Only host
|
||||||
// and token affect the issue list — port/url-prefix/prompts don't.)
|
// and token affect the issue list — port/url-prefix/prompts don't.)
|
||||||
const oldToken = trimmedHost ? await getToken(panel.context, trimmedHost) : undefined
|
const oldToken = usesToken && trimmedHost ? await getToken(panel.context, trimmedHost) : undefined
|
||||||
// Empty token + existing saved token = user wants to keep the existing
|
// Empty token + existing saved token = user wants to keep the existing
|
||||||
// one (placeholder semantics in the modal). Skip rewriting and skip the
|
// one (placeholder semantics in the modal). Skip rewriting and skip the
|
||||||
// kanban refresh since auth didn't change.
|
// kanban refresh since auth didn't change.
|
||||||
const keepExisting = trimmedToken === '' && !!oldToken
|
const keepExisting = trimmedToken === '' && !!oldToken
|
||||||
if (!trimmedHost || (!trimmedToken && !keepExisting)) {
|
const tokenChanged = usesToken && !keepExisting
|
||||||
|
if (usesToken && (!trimmedHost || (!trimmedToken && !keepExisting))) {
|
||||||
panel.postMessage({
|
panel.postMessage({
|
||||||
type: 'settings/show',
|
type: 'settings/show',
|
||||||
host: trimmedHost,
|
host: trimmedHost,
|
||||||
|
forgeKind,
|
||||||
errorMessage: 'Host 和 Token 都不能为空',
|
errorMessage: 'Host 和 Token 都不能为空',
|
||||||
tokenSaved: !!oldToken,
|
tokenSaved: !!oldToken,
|
||||||
webhookPort: payload.webhookPort,
|
webhookPort: payload.webhookPort,
|
||||||
@@ -187,14 +194,14 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
youtrackCloseCommand: trimmedYtClose,
|
youtrackCloseCommand: trimmedYtClose,
|
||||||
})
|
})
|
||||||
void handleProfilesList(panel)
|
void handleProfilesList(panel)
|
||||||
if (!keepExisting) {
|
if (tokenChanged) {
|
||||||
await setToken(panel.context, trimmedHost, trimmedToken)
|
await setToken(panel.context, trimmedHost, trimmedToken)
|
||||||
// 新 token 可能换了人,身份缓存和状态栏都要跟着重解析。
|
// 新 token 可能换了人,身份缓存和状态栏都要跟着重解析。
|
||||||
clearIdentityCache(trimmedHost)
|
clearIdentityCache(trimmedHost)
|
||||||
void initIdentity(panel.context)
|
void initIdentity(panel.context)
|
||||||
}
|
}
|
||||||
// YouTrack token: stored under the base URL's host. Empty input keeps the
|
// YouTrack token: stored under the base URL's host. Empty input keeps the
|
||||||
// existing token (placeholder semantics), matching the gitea flow above.
|
// existing token (placeholder semantics), matching the repository token flow above.
|
||||||
if (trimmedYtBase && trimmedYtToken)
|
if (trimmedYtBase && trimmedYtToken)
|
||||||
await setYouTrackToken(panel.context, youtrackHost(trimmedYtBase), trimmedYtToken)
|
await setYouTrackToken(panel.context, youtrackHost(trimmedYtBase), trimmedYtToken)
|
||||||
// Honor a port change without requiring a window reload. Restart the
|
// Honor a port change without requiring a window reload. Restart the
|
||||||
@@ -225,12 +232,12 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
|
|||||||
// actually changed. Saves a round-trip + visible loading flash when the
|
// actually changed. Saves a round-trip + visible loading flash when the
|
||||||
// user just tweaked prompts or webhook settings. `keepExisting` already
|
// user just tweaked prompts or webhook settings. `keepExisting` already
|
||||||
// guarantees no auth change.
|
// guarantees no auth change.
|
||||||
// Re-fetch when the gitea credential OR any YouTrack config changed (base
|
// Re-fetch when the repository token OR any YouTrack config changed (base
|
||||||
// URL / project / a freshly entered token) so newly-mirrored issues appear.
|
// URL / project / a freshly entered token) so newly-mirrored issues appear.
|
||||||
const youtrackChanged = prev.youtrackBaseUrl !== trimmedYtBase
|
const youtrackChanged = prev.youtrackBaseUrl !== trimmedYtBase
|
||||||
|| prev.youtrackProjectShortName !== trimmedYtProject
|
|| prev.youtrackProjectShortName !== trimmedYtProject
|
||||||
|| trimmedYtToken !== ''
|
|| trimmedYtToken !== ''
|
||||||
if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged)
|
if ((tokenChanged && oldToken !== trimmedToken) || youtrackChanged)
|
||||||
await panel.loadAndPush()
|
await panel.loadAndPush()
|
||||||
// 开发分支 / 发布前缀可能变了,刷新工具栏发布按钮。
|
// 开发分支 / 发布前缀可能变了,刷新工具栏发布按钮。
|
||||||
void panel.handleReleaseStatus()
|
void panel.handleReleaseStatus()
|
||||||
@@ -247,8 +254,10 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
|||||||
const s = getSettings(panel.context)
|
const s = getSettings(panel.context)
|
||||||
// 打开设置时同步 override,避免未 activate 路径或旧值漂移。
|
// 打开设置时同步 override,避免未 activate 路径或旧值漂移。
|
||||||
setProfilesDirOverride(s.profilesDirectory || undefined)
|
setProfilesDirOverride(s.profilesDirectory || undefined)
|
||||||
const tok = host ? await getToken(panel.context, host) : undefined
|
const forgeKind = forgeKindForHost(host)
|
||||||
|
const tok = forgeKind === 'gitea' && host ? await getToken(panel.context, host) : undefined
|
||||||
const tokenSaved = !!tok && tok.length > 0
|
const tokenSaved = !!tok && tok.length > 0
|
||||||
|
const ghLogin = forgeKind === 'github' ? await currentGhLogin(panel) : undefined
|
||||||
const ytTok = s.youtrackBaseUrl.trim()
|
const ytTok = s.youtrackBaseUrl.trim()
|
||||||
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
|
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
|
||||||
: undefined
|
: undefined
|
||||||
@@ -259,6 +268,8 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
|||||||
panel.postMessage({
|
panel.postMessage({
|
||||||
type: 'settings/show',
|
type: 'settings/show',
|
||||||
host,
|
host,
|
||||||
|
forgeKind,
|
||||||
|
...(ghLogin ? { ghLogin } : {}),
|
||||||
canCancel: true,
|
canCancel: true,
|
||||||
tokenSaved,
|
tokenSaved,
|
||||||
webhookPort: s.webhookPort,
|
webhookPort: s.webhookPort,
|
||||||
@@ -300,6 +311,21 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 现查 gh 登录的 GitHub 账号,没登录或登录失效返回 `undefined`。
|
||||||
|
*
|
||||||
|
* 用户可能刚在终端登录或换了账号,所以先丢掉身份缓存再查,顺带刷新状态栏。
|
||||||
|
*/
|
||||||
|
async function currentGhLogin(panel: KanbanWebviewPanel): Promise<string | undefined> {
|
||||||
|
const resolved = await resolveForge(panel.context)
|
||||||
|
if (!resolved.ok)
|
||||||
|
return undefined
|
||||||
|
clearIdentityCache(resolved.forge.host)
|
||||||
|
const login = await loginFor(resolved.forge)
|
||||||
|
void initIdentity(panel.context)
|
||||||
|
return login
|
||||||
|
}
|
||||||
|
|
||||||
/** Forces the open panel (if any) into the setup-auth state. */
|
/** Forces the open panel (if any) into the setup-auth state. */
|
||||||
export function requestEditAuth(panel: KanbanWebviewPanel): void {
|
export function requestEditAuth(panel: KanbanWebviewPanel): void {
|
||||||
void handleEditSettingsRequest(panel)
|
void handleEditSettingsRequest(panel)
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal:
|
|||||||
/**
|
/**
|
||||||
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
|
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
|
||||||
*
|
*
|
||||||
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
|
* `issues/update` 会用代码托管仓库里持久化的 state JSON 整体替换 webview
|
||||||
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
|
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
|
||||||
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
|
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
|
||||||
* 按钮能找到对应 terminal。
|
* 按钮能找到对应 terminal。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Panel handlers for YouTrack-sourced cards.
|
* Panel handlers for YouTrack-sourced cards.
|
||||||
*
|
*
|
||||||
* Kept separate from the gitea `issues.ts` handlers: these route workflow-state
|
* Kept separate from the forge `issues.ts` handlers: these route workflow-state
|
||||||
* writes to the issue's YouTrack comment (see `youtrack/stateComment.ts`) and
|
* writes to the issue's YouTrack comment (see `youtrack/stateComment.ts`) and
|
||||||
* resolve the issue in YouTrack when its card lands in the 完成 column — the
|
* resolve the issue in YouTrack when its card lands in the 完成 column — the
|
||||||
* only write-back the user asked for.
|
* only write-back the user asked for.
|
||||||
|
|||||||
@@ -8,9 +8,9 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface IssueOwnershipFacts {
|
export interface IssueOwnershipFacts {
|
||||||
/** Gitea 工单创建者 login;YouTrack 工单没有该字段。 */
|
/** 代码托管工单的创建者 login;YouTrack 工单没有该字段。 */
|
||||||
poster?: string
|
poster?: string
|
||||||
/** Gitea assignee login 列表;空/缺省表示未指派。 */
|
/** 代码托管工单的 assignee login 列表;空/缺省表示未指派。 */
|
||||||
assignees?: string[]
|
assignees?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +21,7 @@ export function isIssueMine(facts: IssueOwnershipFacts, me: string | undefined):
|
|||||||
const assignees = facts.assignees ?? []
|
const assignees = facts.assignees ?? []
|
||||||
if (assignees.length > 0)
|
if (assignees.length > 0)
|
||||||
return assignees.includes(me)
|
return assignees.includes(me)
|
||||||
// 无 assignee:创建者说了算;poster 缺省(非 Gitea 来源)不参与判定。
|
// 无 assignee:创建者说了算;poster 缺省(YouTrack 来源)不参与判定。
|
||||||
return facts.poster === undefined || facts.poster === me
|
return facts.poster === undefined || facts.poster === me
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface KanbanSnapshot {
|
|||||||
issues: Issue[]
|
issues: Issue[]
|
||||||
scope: 'mine' | 'all'
|
scope: 'mine' | 'all'
|
||||||
me?: string
|
me?: string
|
||||||
/** Gitea login → 头像 data URI;团队视图下卡片按人贴头像。 */
|
/** 代码托管 login → 头像 data URI;团队视图下卡片按人贴头像。 */
|
||||||
avatars?: Record<string, string>
|
avatars?: Record<string, string>
|
||||||
globalAutoReview: boolean
|
globalAutoReview: boolean
|
||||||
youtrackConfigured: boolean
|
youtrackConfigured: boolean
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ForgeKind } from '../forge/types'
|
||||||
import type { ReleaseCommitGroup } from '../git/release'
|
import type { ReleaseCommitGroup } from '../git/release'
|
||||||
import type { Issue, IssueColumn } from '../issues/types'
|
import type { Issue, IssueColumn } from '../issues/types'
|
||||||
import type { LogEntry } from '../logging/logger'
|
import type { LogEntry } from '../logging/logger'
|
||||||
@@ -68,6 +69,10 @@ export type ExtensionToWebview
|
|||||||
| {
|
| {
|
||||||
type: 'settings/show'
|
type: 'settings/show'
|
||||||
host: string
|
host: string
|
||||||
|
/** 当前工作区仓库是哪一家,决定设置里显示 Token 输入框还是 gh 登录状态。 */
|
||||||
|
forgeKind: ForgeKind
|
||||||
|
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
|
||||||
|
ghLogin?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet<string> = new Set([
|
|||||||
'session/close-tab',
|
'session/close-tab',
|
||||||
'brainstorm/continue',
|
'brainstorm/continue',
|
||||||
'issue/implement',
|
'issue/implement',
|
||||||
// 工单状态:写回 Gitea
|
// 工单状态:写回代码托管仓库
|
||||||
'column/change',
|
'column/change',
|
||||||
'issue/delete',
|
'issue/delete',
|
||||||
'issue/close',
|
'issue/close',
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
* `{ [filePath]: sig }`,sig 是该文件被标记已查看时记下的「变更指纹」。
|
* `{ [filePath]: sig }`,sig 是该文件被标记已查看时记下的「变更指纹」。
|
||||||
*
|
*
|
||||||
* 为什么用指纹而非 blob sha:要做到 GitHub 式「文件没变就一直算已查看,只有它在新
|
* 为什么用指纹而非 blob sha:要做到 GitHub 式「文件没变就一直算已查看,只有它在新
|
||||||
* push 里真改动了才重新标未查看」,需要一个能判定文件是否变过的标识。但 Gitea 的
|
* push 里真改动了才重新标未查看」,需要一个能判定文件是否变过的标识。但
|
||||||
* ChangedFile 不返回每文件 blob sha、compare 端点也不支持任意 commit sha 比较,拿
|
* [PR 改动文件](vscode/src/forge/types.ts#ForgePrFile) 里没有每个文件的 blob sha(Gitea 的接口不给),拿
|
||||||
* 不到现成的内容标识。故退而用 (status|additions|deletions) 作指纹——文件一旦改动,
|
* 不到现成的内容标识。故退而用 (status|additions|deletions) 作指纹——文件一旦改动,
|
||||||
* 这三者几乎必然变其一,据此判断是否需要重看,够用且零额外请求。
|
* 这三者几乎必然变其一,据此判断是否需要重看,够用且零额外请求。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。
|
* 枚举本机 claude / codex 会话文件,判定工单上的会话 id 能否 resume。
|
||||||
*
|
*
|
||||||
* 会话 id 存在 Gitea 共享状态里,别的机器上开的会话本机没有 transcript,
|
* 会话 id 存在工单的共享状态里,别的机器上开的会话本机没有 transcript,
|
||||||
* resume 必然失败;多人协作换机后只要把会话文件拷到本机就能 resume。
|
* resume 必然失败;多人协作换机后只要把会话文件拷到本机就能 resume。
|
||||||
*
|
*
|
||||||
* - claude:`~/.claude/projects/<编码后的 cwd>/<sid>.jsonl`。新版 `claude --resume`
|
* - claude:`~/.claude/projects/<编码后的 cwd>/<sid>.jsonl`。新版 `claude --resume`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Fetch-based YouTrack REST client.
|
* Fetch-based YouTrack REST client.
|
||||||
*
|
*
|
||||||
* Mirrors the role of the [Gitea provider](vscode/src/forge/gitea/provider.ts) for the second issue source. All
|
* Mirrors the role of a [forge provider](vscode/src/forge/types.ts#ForgeProvider) for the second issue source. All
|
||||||
* requests authenticate with a YouTrack permanent token via
|
* requests authenticate with a YouTrack permanent token via
|
||||||
* `Authorization: Bearer <token>`. Non-2xx responses surface as
|
* `Authorization: Bearer <token>`. Non-2xx responses surface as
|
||||||
* `YouTrackApiError` so callers can distinguish 401 (token invalid) from other
|
* `YouTrackApiError` so callers can distinguish 401 (token invalid) from other
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ export function youtrackHost(baseUrl: string): string {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Offset added to a YouTrack issue's numeric id so its board `number` never
|
* Offset added to a YouTrack issue's numeric id so its board `number` never
|
||||||
* collides with a gitea issue number (which is the routing key for
|
* collides with a forge issue number (which is the routing key for
|
||||||
* webview↔extension messages and the `number → IssueRef` map).
|
* webview↔extension messages and the `number → IssueRef` map).
|
||||||
* ponytail: assumes gitea numbers < 1,000,000 and a single mirrored project
|
* ponytail: assumes forge issue numbers < 1,000,000 and a single mirrored project
|
||||||
* (readable ids are unique within one project). Widen / hash if either breaks.
|
* (readable ids are unique within one project). Widen / hash if either breaks.
|
||||||
*/
|
*/
|
||||||
const YOUTRACK_NUMBER_OFFSET = 1_000_000
|
const YOUTRACK_NUMBER_OFFSET = 1_000_000
|
||||||
|
|||||||
@@ -578,6 +578,8 @@ export function App() {
|
|||||||
<SettingsModal
|
<SettingsModal
|
||||||
open={settings !== null}
|
open={settings !== null}
|
||||||
host={settings?.host ?? ''}
|
host={settings?.host ?? ''}
|
||||||
|
forgeKind={settings?.forgeKind ?? 'gitea'}
|
||||||
|
ghLogin={settings?.ghLogin}
|
||||||
errorMessage={settings?.errorMessage}
|
errorMessage={settings?.errorMessage}
|
||||||
canCancel={settings?.canCancel}
|
canCancel={settings?.canCancel}
|
||||||
initialTokenSaved={settings?.tokenSaved ?? false}
|
initialTokenSaved={settings?.tokenSaved ?? false}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export interface BottomTabsProps {
|
|||||||
onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
|
onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
|
||||||
onOpenLogs: () => void
|
onOpenLogs: () => void
|
||||||
me?: string
|
me?: string
|
||||||
/** Gitea login → 头像 data URI。 */
|
/** login → 头像 data URI。 */
|
||||||
avatars?: Record<string, string>
|
avatars?: Record<string, string>
|
||||||
onStartHandoff: (issueNumber: number) => void
|
onStartHandoff: (issueNumber: number) => void
|
||||||
onAcceptHandoff: (issueNumber: number) => void
|
onAcceptHandoff: (issueNumber: number) => void
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface IssueCardProps {
|
|||||||
lockedPrerequisite?: number
|
lockedPrerequisite?: number
|
||||||
/** 只读态:同事的工单。禁止拖拽,并在右上角贴归属人头像。 */
|
/** 只读态:同事的工单。禁止拖拽,并在右上角贴归属人头像。 */
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
/** 归属人 Gitea login(只读卡片才显示)。 */
|
/** 归属人 login(只读卡片才显示)。 */
|
||||||
ownerLogin?: string
|
ownerLogin?: string
|
||||||
/** 归属人头像 data URI;缺失时回退首字母圆点。 */
|
/** 归属人头像 data URI;缺失时回退首字母圆点。 */
|
||||||
ownerAvatar?: string
|
ownerAvatar?: string
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { ParsedCommentQuote } from './commentQuote'
|
|||||||
import { ExternalLink, Loader2, MessageCircle, Quote, RotateCw, Send, X } from 'lucide-react'
|
import { ExternalLink, Loader2, MessageCircle, Quote, RotateCw, Send, X } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { trackerName } from '../lib/trackerName'
|
||||||
import { onMessage, postMessage } from '../lib/vscode'
|
import { onMessage, postMessage } from '../lib/vscode'
|
||||||
import { parseCommentQuote } from './commentQuote'
|
import { parseCommentQuote } from './commentQuote'
|
||||||
|
|
||||||
@@ -297,8 +298,8 @@ export function IssueChatPanel({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tooltip={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
data-tooltip={`在 ${trackerName(issue)} 打开`}
|
||||||
aria-label={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
aria-label={`在 ${trackerName(issue)} 打开`}
|
||||||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
@@ -312,7 +313,11 @@ export function IssueChatPanel({
|
|||||||
<div className="flex h-full flex-1 flex-col items-center justify-center gap-2 p-6 text-[var(--vscode-descriptionForeground)]">
|
<div className="flex h-full flex-1 flex-col items-center justify-center gap-2 p-6 text-[var(--vscode-descriptionForeground)]">
|
||||||
<MessageCircle className="size-8 opacity-40" />
|
<MessageCircle className="size-8 opacity-40" />
|
||||||
<span className="text-sm font-medium">还没有讨论</span>
|
<span className="text-sm font-medium">还没有讨论</span>
|
||||||
<span className="text-xs opacity-60">在此发起对此工单的讨论,评论将双向同步至 Gitea</span>
|
<span className="text-xs opacity-60">
|
||||||
|
在此发起对此工单的讨论,评论将双向同步至
|
||||||
|
{' '}
|
||||||
|
{trackerName(issue)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{comments.map((comment) => {
|
{comments.map((comment) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { isIssueLocked } from '../lib/dependencies'
|
|||||||
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
|
import { isIssueMine, issueOwnerLogin } from '../lib/issueOwnership'
|
||||||
import { isSessionMissing } from '../lib/missingSession'
|
import { isSessionMissing } from '../lib/missingSession'
|
||||||
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
import { toReadOnlySchema } from '../lib/readOnlySchema'
|
||||||
|
import { trackerName } from '../lib/trackerName'
|
||||||
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
import { COLUMN_LABELS, COLUMN_ORDER } from '../types'
|
||||||
import { PropertyGrid } from './property-grid'
|
import { PropertyGrid } from './property-grid'
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ interface IssueDetailPanelProps {
|
|||||||
onOpenPrototypeInFigma: (issueNumber: number, path: string) => void
|
onOpenPrototypeInFigma: (issueNumber: number, path: string) => void
|
||||||
/** Kick off the end-to-end implementation flow for the given plan file. */
|
/** Kick off the end-to-end implementation flow for the given plan file. */
|
||||||
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
onImplement: (issueNumber: number, planFile: string, profilePath?: string, sessionId?: string) => void
|
||||||
/** Open the gitea PR page in the browser. */
|
/** Open the PR page in the browser. */
|
||||||
onOpenPr: (pr: string) => void
|
onOpenPr: (pr: string) => void
|
||||||
/** Start a background Claude run that writes the PR diff summary markdown. */
|
/** Start a background Claude run that writes the PR diff summary markdown. */
|
||||||
onGeneratePrDiffSummary: (issueNumber: number) => void
|
onGeneratePrDiffSummary: (issueNumber: number) => void
|
||||||
@@ -100,7 +101,7 @@ interface IssueDetailPanelProps {
|
|||||||
* 顶部垃圾桶按钮触发,扩展端做 modal confirm,可选。
|
* 顶部垃圾桶按钮触发,扩展端做 modal confirm,可选。
|
||||||
*/
|
*/
|
||||||
onDeleteIssue?: (issueNumber: number) => void
|
onDeleteIssue?: (issueNumber: number) => void
|
||||||
/** 关闭 Gitea 工单,不清理本地会话、worktree、PR 或分支。 */
|
/** 关闭代码托管仓库上的工单,不清理本地会话、worktree、PR 或分支。 */
|
||||||
onCloseIssue: (issueNumber: number) => void
|
onCloseIssue: (issueNumber: number) => void
|
||||||
/** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */
|
/** 将实施中 / 审查中的工单重置回待办(清 worktree / 未合并 PR / feature 分支)。 */
|
||||||
onResetToTodo: (issueNumber: number) => void
|
onResetToTodo: (issueNumber: number) => void
|
||||||
@@ -133,7 +134,7 @@ interface IssueDetailPanelProps {
|
|||||||
onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
|
onUpdatePrototypeProfilePath: (issueNumber: number, prototypeProfilePath: string) => void
|
||||||
/** Open the in-webview log modal. */
|
/** Open the in-webview log modal. */
|
||||||
onOpenLogs: () => void
|
onOpenLogs: () => void
|
||||||
/** 当前 Gitea login;undefined = 身份未知(接管按钮不显示)。 */
|
/** 当前登录者的 login;undefined = 身份未知(接管按钮不显示)。 */
|
||||||
me?: string
|
me?: string
|
||||||
onStartHandoff: (issueNumber: number) => void
|
onStartHandoff: (issueNumber: number) => void
|
||||||
onAcceptHandoff: (issueNumber: number) => void
|
onAcceptHandoff: (issueNumber: number) => void
|
||||||
@@ -142,14 +143,14 @@ interface IssueDetailPanelProps {
|
|||||||
onAcceptHandoffRequest: (issueNumber: number) => void
|
onAcceptHandoffRequest: (issueNumber: number) => void
|
||||||
onRejectHandoffRequest: (issueNumber: number) => void
|
onRejectHandoffRequest: (issueNumber: number) => void
|
||||||
isHandoffRunning: (issueNumber: number) => boolean
|
isHandoffRunning: (issueNumber: number) => boolean
|
||||||
/** Gitea login → 头像 data URI;只读工单在标题旁贴归属人头像。 */
|
/** login → 头像 data URI;只读工单在标题旁贴归属人头像。 */
|
||||||
avatars?: Record<string, string>
|
avatars?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
|
* 底部详情面板:以 PropertyGrid 形式展示当前选中 issue 的 state JSON。
|
||||||
*
|
*
|
||||||
* 顶部一行是 #编号/标题 + 「在 Gitea 打开」按钮;下面是 PropertyGrid
|
* 顶部一行是 #编号/标题 + 「在 GitHub / Gitea / YouTrack 打开」按钮;下面是 PropertyGrid
|
||||||
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
|
* 渲染的 state JSON 字段(当前为 column + sessionId)。sessionId 是
|
||||||
* 一个 action 类型字段,双击或选中卡按 Enter 都会触发
|
* 一个 action 类型字段,双击或选中卡按 Enter 都会触发
|
||||||
* `claude --resume <id>` 在新终端中恢复对话。
|
* `claude --resume <id>` 在新终端中恢复对话。
|
||||||
@@ -860,8 +861,8 @@ export function IssueDetailPanel({
|
|||||||
: null}
|
: null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tooltip={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
data-tooltip={`在 ${trackerName(issue)} 打开`}
|
||||||
aria-label={issue.source === 'youtrack' ? '在 YouTrack 打开' : '在 Gitea 打开'}
|
aria-label={`在 ${trackerName(issue)} 打开`}
|
||||||
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
onClick={() => onOpenInBrowser(issue.htmlUrl)}
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
>
|
>
|
||||||
@@ -914,8 +915,8 @@ export function IssueDetailPanel({
|
|||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{/* Close/delete write to gitea by number — hide for youtrack cards to
|
{/* Close/delete write to the forge by number — hide for youtrack cards to
|
||||||
avoid acting on a gitea issue of the same number. */}
|
avoid acting on a forge issue of the same number. */}
|
||||||
{issue.source !== 'youtrack' && !readOnly && (
|
{issue.source !== 'youtrack' && !readOnly && (
|
||||||
<>
|
<>
|
||||||
{issue.handoffBundleKey && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
{issue.handoffBundleKey && me !== undefined && (issue.assignees ?? []).includes(me) && (
|
||||||
@@ -964,7 +965,7 @@ export function IssueDetailPanel({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-tooltip="关闭工单(Gitea 里可重开)"
|
data-tooltip={`关闭工单(可在 ${trackerName(issue)} 重开)`}
|
||||||
onClick={() => onCloseIssue(issue.number)}
|
onClick={() => onCloseIssue(issue.number)}
|
||||||
aria-label="关闭工单"
|
aria-label="关闭工单"
|
||||||
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
className="inline-flex shrink-0 items-center justify-center rounded border border-[var(--vscode-panel-border)] p-1 text-xs text-[var(--vscode-foreground)] hover:border-yellow-500/60 hover:bg-yellow-500/10 hover:text-yellow-500"
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ interface KanbanBoardProps {
|
|||||||
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
|
onDependencySet?: (issueNumber: number, prerequisiteNumber: number) => void
|
||||||
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
|
onDependencyClear?: (issueNumber: number, prerequisiteNumber: number) => void
|
||||||
onSetReviewPassed?: (issueNumber: number, value: boolean) => void
|
onSetReviewPassed?: (issueNumber: number, value: boolean) => void
|
||||||
/** 当前 Gitea login;用于判定哪些卡是同事的(只读)。 */
|
/** 当前登录者的 login;用于判定哪些卡是同事的(只读)。 */
|
||||||
me?: string
|
me?: string
|
||||||
/** Gitea login → 头像 data URI。 */
|
/** login → 头像 data URI。 */
|
||||||
avatars?: Record<string, string>
|
avatars?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,8 +254,8 @@ export function PanelHeader({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onEditAuth}
|
onClick={onEditAuth}
|
||||||
data-tooltip="重新配置 Gitea Token"
|
data-tooltip="设置"
|
||||||
aria-label="重新配置 Gitea Token"
|
aria-label="设置"
|
||||||
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
|
className="flex h-6 w-6 items-center justify-center rounded hover:bg-[var(--vscode-toolbar-hoverBackground)]"
|
||||||
>
|
>
|
||||||
<Settings size={14} />
|
<Settings size={14} />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 「改动」tab(底部第四个):整个 PR 的改动以主从式呈现。
|
* 「改动」tab(底部第四个):整个 PR 的改动以主从式呈现。
|
||||||
*
|
*
|
||||||
* - 左栏:统计条(共 N 文件 · +X −Y 为全量口径,含 tests/,与 Gitea 网页一致;已查看 M/N
|
* - 左栏:统计条(共 N 文件 · +X −Y 为全量口径,含 tests/,与代码托管网页一致;已查看 M/N
|
||||||
* 的分母是可见文件数)+ 文件树(目录树、状态徽标、tests/ 过滤、已查看勾)。
|
* 的分母是可见文件数)+ 文件树(目录树、状态徽标、tests/ 过滤、已查看勾)。
|
||||||
* - 右栏:选中文件的内联 diff(git-diff-view,只渲染当前一个文件)。
|
* - 右栏:选中文件的内联 diff(git-diff-view,只渲染当前一个文件)。
|
||||||
*
|
*
|
||||||
* 自带 usePrFiles hook,直接经 lib/vscode 收发消息,不依赖上层回调。diff 左右两侧用
|
* 自带 usePrFiles hook,直接经 lib/vscode 收发消息,不依赖上层回调。diff 左右两侧用
|
||||||
* merge_base → head 的原文(与 Gitea 网页 diff 口径一致),已查看态按文件变更指纹落盘。
|
* merge_base → head 的原文(与代码托管网页的 PR diff 口径一致),已查看态按文件变更指纹落盘。
|
||||||
* 统一/分栏模式按 workspace 持久化,与当前工单无关。
|
* 统一/分栏模式按 workspace 持久化,与当前工单无关。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ export function PrFilesPanel({ issue }: PrFilesPanelProps) {
|
|||||||
// 路径 → 文件,选中时取 status/previousFilename。
|
// 路径 → 文件,选中时取 status/previousFilename。
|
||||||
const fileByPath = useMemo(() => new Map(visibleFiles.map(f => [f.path, f])), [visibleFiles])
|
const fileByPath = useMemo(() => new Map(visibleFiles.map(f => [f.path, f])), [visibleFiles])
|
||||||
|
|
||||||
// total/±行数为全量口径(含 tests/,与 Gitea 网页一致);已查看的分母是可见文件数。
|
// total/±行数为全量口径(含 tests/,与代码托管网页一致);已查看的分母是可见文件数。
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
let additions = 0
|
let additions = 0
|
||||||
let deletions = 0
|
let deletions = 0
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { SettingsModalProps } from './SettingsModal'
|
import type { SettingsModalProps } from './SettingsModal'
|
||||||
import { renderToStaticMarkup } from 'react-dom/server'
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { SettingsModal } from './SettingsModal'
|
import { SettingsModal, WebhookSecretHint, WebhookUrlHint } from './SettingsModal'
|
||||||
|
|
||||||
const PROPS: SettingsModalProps = {
|
const PROPS: SettingsModalProps = {
|
||||||
open: true,
|
open: true,
|
||||||
host: 'gitea.example.com',
|
host: 'gitea.example.com',
|
||||||
|
forgeKind: 'gitea',
|
||||||
initialTokenSaved: true,
|
initialTokenSaved: true,
|
||||||
initialWebhookPort: 8080,
|
initialWebhookPort: 8080,
|
||||||
initialWebhookSecret: '',
|
initialWebhookSecret: '',
|
||||||
@@ -92,3 +93,51 @@ describe('设置面板的重写注释默认模型', () => {
|
|||||||
expect(rewriteField({ initialDefaultRewriteProfilePath: '/p/deleted.json' })).toContain('不可用:/p/deleted.json')
|
expect(rewriteField({ initialDefaultRewriteProfilePath: '/p/deleted.json' })).toContain('不可用:/p/deleted.json')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('设置面板的仓库连接', () => {
|
||||||
|
const render = (props: Partial<SettingsModalProps>): string => renderToStaticMarkup(<SettingsModal {...PROPS} {...props} />)
|
||||||
|
|
||||||
|
it('gitea 仓库照旧填 Host 和 Token', () => {
|
||||||
|
const html = render({})
|
||||||
|
expect(html).toContain('Gitea Host')
|
||||||
|
expect(html).toContain('Personal Access Token')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gitHub 仓库不显示 Host、Token 输入框,显示 gh 已登录的账号', () => {
|
||||||
|
const html = render({ host: 'github.com', forgeKind: 'github', ghLogin: 'cruldra', initialTokenSaved: false })
|
||||||
|
expect(html).not.toContain('Gitea Host')
|
||||||
|
expect(html).not.toContain('Personal Access Token')
|
||||||
|
expect(html).not.toContain('type="password"')
|
||||||
|
expect(html).toContain('已登录为')
|
||||||
|
expect(html).toContain('cruldra')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gitHub 仓库 gh 没登录时给出登录指引', () => {
|
||||||
|
const html = render({ host: 'github.com', forgeKind: 'github', initialTokenSaved: false })
|
||||||
|
expect(html).toContain('未登录')
|
||||||
|
expect(html).toContain('gh auth login')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('webhook 配置说明', () => {
|
||||||
|
it('gitHub:仓库 Settings → Webhooks,公网地址加 /webhook,JSON,勾四类事件', () => {
|
||||||
|
const html = renderToStaticMarkup(<WebhookUrlHint kind="github" />)
|
||||||
|
expect(html).toContain('Settings → Webhooks')
|
||||||
|
expect(html).toContain('/webhook')
|
||||||
|
expect(html).toContain('application/json')
|
||||||
|
for (const event of ['Issues', 'Issue comments', 'Pull requests', 'Pushes'])
|
||||||
|
expect(html).toContain(event)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gitea 的说明里不出现 GitHub 的设置路径', () => {
|
||||||
|
const html = renderToStaticMarkup(<WebhookUrlHint kind="gitea" />)
|
||||||
|
expect(html).toContain('Gitea')
|
||||||
|
expect(html).not.toContain('Settings → Webhooks')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['gitea', 'github'] as const)('%s 的 Secret 说明都按 X-Hub-Signature-256 校验', (kind) => {
|
||||||
|
const html = renderToStaticMarkup(<WebhookSecretHint kind={kind} />)
|
||||||
|
expect(html).toContain('X-Hub-Signature-256')
|
||||||
|
expect(html).not.toContain('X-Gitea-Signature')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactElement } from 'react'
|
import type { ReactElement } from 'react'
|
||||||
|
import type { ForgeKind } from '../../../src/forge/types'
|
||||||
import type { ClaudeProfile } from '../hooks/useIssues'
|
import type { ClaudeProfile } from '../hooks/useIssues'
|
||||||
import { X } from 'lucide-react'
|
import { X } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
@@ -72,6 +73,10 @@ export interface YouTrackProjectsState {
|
|||||||
export interface SettingsModalProps {
|
export interface SettingsModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
host: string
|
host: string
|
||||||
|
/** 当前工作区仓库是哪一家:GitHub 用 gh 的登录,不填 Host 和 Token。 */
|
||||||
|
forgeKind: ForgeKind
|
||||||
|
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。只在 GitHub 仓库下有意义。 */
|
||||||
|
ghLogin?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
initialTokenSaved: boolean
|
initialTokenSaved: boolean
|
||||||
@@ -163,9 +168,121 @@ const FIELD_TO_GROUP: Record<ErrorKey, GroupKey> = {
|
|||||||
webhookPort: 'network',
|
webhookPort: 'network',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function GhLoginStatus({ login }: { login?: string }): ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="mb-3 text-xs">
|
||||||
|
<span className="mb-1 block opacity-80">GitHub 账号</span>
|
||||||
|
{login
|
||||||
|
? (
|
||||||
|
<span>
|
||||||
|
已登录为
|
||||||
|
{' '}
|
||||||
|
<b>{login}</b>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<span className="text-state-red">
|
||||||
|
未登录或登录已失效:请在终端运行
|
||||||
|
{' '}
|
||||||
|
<code>gh auth login</code>
|
||||||
|
{' '}
|
||||||
|
登录 github.com,然后回到看板点刷新
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="mt-1 block text-[10px] opacity-60">
|
||||||
|
GitHub 仓库直接用 GitHub CLI(gh)的登录,插件不另存 token;换账号用
|
||||||
|
{' '}
|
||||||
|
<code>gh auth switch</code>
|
||||||
|
。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebhookUrlHint({ kind }: { kind: ForgeKind }): ReactElement {
|
||||||
|
if (kind === 'github') {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
扩展 HTTP server 绑定的端口。GitHub 只能访问公网地址,先用隧道把公网地址转到此端口,再到 GitHub 仓库
|
||||||
|
{' '}
|
||||||
|
<b>Settings → Webhooks</b>
|
||||||
|
{' '}
|
||||||
|
添加 webhook:
|
||||||
|
<b>Payload URL</b>
|
||||||
|
{' '}
|
||||||
|
填
|
||||||
|
{' '}
|
||||||
|
<code>{'https://<公网地址>/webhook'}</code>
|
||||||
|
,
|
||||||
|
<b>Content type</b>
|
||||||
|
{' '}
|
||||||
|
选
|
||||||
|
{' '}
|
||||||
|
<code>application/json</code>
|
||||||
|
,事件选「Let me select individual events」并勾选
|
||||||
|
{' '}
|
||||||
|
<b>Issues</b>
|
||||||
|
、
|
||||||
|
<b>Issue comments</b>
|
||||||
|
、
|
||||||
|
<b>Pull requests</b>
|
||||||
|
、
|
||||||
|
<b>Pushes</b>
|
||||||
|
。
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
扩展 HTTP server 绑定的端口。在 Gitea 仓库 Webhooks 配置里把目标 URL 指向
|
||||||
|
{' '}
|
||||||
|
<code>{'http://<你的本机/公网域名>:<此端口>/webhook'}</code>
|
||||||
|
,事件需勾选:
|
||||||
|
<b>工单</b>
|
||||||
|
(issue opened/edited)、
|
||||||
|
<b>合并请求</b>
|
||||||
|
(PR opened/synchronize/closed)、
|
||||||
|
<b>工单评论</b>
|
||||||
|
(codex 审查回流)。
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebhookSecretHint({ kind }: { kind: ForgeKind }): ReactElement {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
与
|
||||||
|
{kind === 'github'
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
GitHub webhook 里填的
|
||||||
|
{' '}
|
||||||
|
<b>Secret</b>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
Gitea Webhooks 配置里的
|
||||||
|
{' '}
|
||||||
|
<b>密钥文本</b>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{' '}
|
||||||
|
保持一致,用于校验
|
||||||
|
{' '}
|
||||||
|
<code>X-Hub-Signature-256</code>
|
||||||
|
。webhook 走公网隧道时必填,否则任何人都能伪造事件;留空 = 不校验。
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsModal({
|
export function SettingsModal({
|
||||||
open,
|
open,
|
||||||
host: initialHost,
|
host: initialHost,
|
||||||
|
forgeKind,
|
||||||
|
ghLogin,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
canCancel,
|
canCancel,
|
||||||
initialTokenSaved,
|
initialTokenSaved,
|
||||||
@@ -326,12 +443,14 @@ export function SettingsModal({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const nextErrors: Partial<Record<ErrorKey, string>> = {}
|
const nextErrors: Partial<Record<ErrorKey, string>> = {}
|
||||||
const trimmedToken = token.trim()
|
const trimmedToken = token.trim()
|
||||||
if (!trimmedHost)
|
// GitHub 仓库用 gh 的登录,没有 Host 和 Token 要填。
|
||||||
|
const needsToken = forgeKind === 'gitea'
|
||||||
|
if (needsToken && !trimmedHost)
|
||||||
nextErrors.host = 'Host 不能为空'
|
nextErrors.host = 'Host 不能为空'
|
||||||
// When a token is already saved server-side, leaving the field blank
|
// When a token is already saved server-side, leaving the field blank
|
||||||
// means "keep the existing token" — don't flag it as an error. Only
|
// means "keep the existing token" — don't flag it as an error. Only
|
||||||
// require a new token when nothing is saved yet.
|
// require a new token when nothing is saved yet.
|
||||||
if (!trimmedToken && !initialTokenSaved)
|
if (needsToken && !trimmedToken && !initialTokenSaved)
|
||||||
nextErrors.token = 'Token 不能为空'
|
nextErrors.token = 'Token 不能为空'
|
||||||
const portNum = Number.parseInt(webhookPort, 10)
|
const portNum = Number.parseInt(webhookPort, 10)
|
||||||
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535)
|
||||||
@@ -399,7 +518,7 @@ export function SettingsModal({
|
|||||||
youtrackBaseUrl: youtrackBaseUrl.trim(),
|
youtrackBaseUrl: youtrackBaseUrl.trim(),
|
||||||
youtrackProjectShortName: youtrackProjectShortName.trim(),
|
youtrackProjectShortName: youtrackProjectShortName.trim(),
|
||||||
youtrackCloseCommand: youtrackCloseCommand.trim(),
|
youtrackCloseCommand: youtrackCloseCommand.trim(),
|
||||||
// Raw (may be empty = keep existing), mirroring the gitea token rule.
|
// Raw (may be empty = keep existing), mirroring the repository token rule.
|
||||||
youtrackToken: youtrackToken.trim(),
|
youtrackToken: youtrackToken.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -461,54 +580,60 @@ export function SettingsModal({
|
|||||||
|
|
||||||
{activeGroup === 'auth' && (
|
{activeGroup === 'auth' && (
|
||||||
<>
|
<>
|
||||||
<Field label="Gitea Host" error={errors.host}>
|
{forgeKind === 'github'
|
||||||
<input
|
? <GhLoginStatus login={ghLogin} />
|
||||||
type="text"
|
: (
|
||||||
value={host}
|
<>
|
||||||
onChange={e => setHost(e.target.value)}
|
<Field label="Gitea Host" error={errors.host}>
|
||||||
placeholder="gitea.example.com"
|
<input
|
||||||
className={inputClass}
|
type="text"
|
||||||
/>
|
value={host}
|
||||||
</Field>
|
onChange={e => setHost(e.target.value)}
|
||||||
|
placeholder="gitea.example.com"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Personal Access Token"
|
label="Personal Access Token"
|
||||||
error={errors.token}
|
error={errors.token}
|
||||||
hint={(
|
hint={(
|
||||||
<>
|
<>
|
||||||
在
|
在
|
||||||
{' '}
|
{' '}
|
||||||
{tokenPageUrl
|
{tokenPageUrl
|
||||||
? (
|
? (
|
||||||
<a
|
<a
|
||||||
href={tokenPageUrl}
|
href={tokenPageUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="text-[var(--vscode-textLink-foreground)] underline"
|
className="text-[var(--vscode-textLink-foreground)] underline"
|
||||||
>
|
>
|
||||||
{tokenPageUrl}
|
{tokenPageUrl}
|
||||||
</a>
|
</a>
|
||||||
)
|
)
|
||||||
: <span className="opacity-60">填写 Host 后这里会显示生成 token 的链接</span>}
|
: <span className="opacity-60">填写 Host 后这里会显示生成 token 的链接</span>}
|
||||||
{' '}
|
{' '}
|
||||||
生成一个 token
|
生成一个 token
|
||||||
{initialTokenSaved && (
|
{initialTokenSaved && (
|
||||||
<>
|
<>
|
||||||
<br />
|
<br />
|
||||||
已保存,留空则保留
|
已保存,留空则保留
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={token}
|
value={token}
|
||||||
onChange={e => setToken(e.target.value)}
|
onChange={e => setToken(e.target.value)}
|
||||||
placeholder={initialTokenSaved ? '••••••••' : '例如 1a2b3c4d…'}
|
placeholder={initialTokenSaved ? '••••••••' : '例如 1a2b3c4d…'}
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="开发分支"
|
label="开发分支"
|
||||||
@@ -830,20 +955,7 @@ export function SettingsModal({
|
|||||||
<Field
|
<Field
|
||||||
label="本地端口"
|
label="本地端口"
|
||||||
error={errors.webhookPort}
|
error={errors.webhookPort}
|
||||||
hint={(
|
hint={<WebhookUrlHint kind={forgeKind} />}
|
||||||
<>
|
|
||||||
扩展 HTTP server 绑定的端口。在 gitea 仓库 Webhooks 配置里把目标 URL 指向
|
|
||||||
{' '}
|
|
||||||
<code>{'http://<你的本机/公网域名>:<此端口>/webhook'}</code>
|
|
||||||
,事件需勾选:
|
|
||||||
<b>工单</b>
|
|
||||||
(issue opened/edited)、
|
|
||||||
<b>合并请求</b>
|
|
||||||
(PR opened/synchronize/closed)、
|
|
||||||
<b>工单评论</b>
|
|
||||||
(codex 审查回流)。
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -857,18 +969,7 @@ export function SettingsModal({
|
|||||||
|
|
||||||
<Field
|
<Field
|
||||||
label="Webhook Secret"
|
label="Webhook Secret"
|
||||||
hint={(
|
hint={<WebhookSecretHint kind={forgeKind} />}
|
||||||
<>
|
|
||||||
与 gitea Webhooks 配置里的
|
|
||||||
{' '}
|
|
||||||
<b>密钥文本</b>
|
|
||||||
{' '}
|
|
||||||
保持一致,用于校验
|
|
||||||
{' '}
|
|
||||||
<code>X-Gitea-Signature</code>
|
|
||||||
。webhook 走公网隧道时必填,否则任何人都能伪造事件;留空 = 不校验。
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -13,9 +13,10 @@
|
|||||||
* usual "加载中…".
|
* usual "加载中…".
|
||||||
*
|
*
|
||||||
* `setIssues` is exposed so the Kanban board can reorder cards locally
|
* `setIssues` is exposed so the Kanban board can reorder cards locally
|
||||||
* after a drag. Persisting drag moves back to Gitea is step 3+.
|
* after a drag. Persisting drag moves back to the forge is step 3+.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ForgeKind } from '../../../src/forge/types'
|
||||||
import type { ToastItem } from '../components/ToastStack'
|
import type { ToastItem } from '../components/ToastStack'
|
||||||
import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages'
|
import type { ExtensionToWebview, LogEntry, ReleaseCommitGroup } from '../lib/messages'
|
||||||
import type { Issue, IssueColumn } from '../types'
|
import type { Issue, IssueColumn } from '../types'
|
||||||
@@ -68,6 +69,8 @@ export interface SettingsValues {
|
|||||||
|
|
||||||
export interface SettingsOverlayState {
|
export interface SettingsOverlayState {
|
||||||
host: string
|
host: string
|
||||||
|
forgeKind: ForgeKind
|
||||||
|
ghLogin?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel: boolean
|
canCancel: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
@@ -143,7 +146,7 @@ export interface UseIssuesResult {
|
|||||||
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部。 */
|
/** 看板范围:'mine' 只看我的(assigned/created),'all' 团队全部。 */
|
||||||
scope: 'mine' | 'all'
|
scope: 'mine' | 'all'
|
||||||
toggleScope: () => void
|
toggleScope: () => void
|
||||||
/** Gitea login → 头像 data URI;同事工单的卡片按它贴头像。 */
|
/** login → 头像 data URI;同事工单的卡片按它贴头像。 */
|
||||||
avatars: Record<string, string>
|
avatars: Record<string, string>
|
||||||
/**
|
/**
|
||||||
* Latest known global `autoReview` value pushed alongside `issues/update`.
|
* Latest known global `autoReview` value pushed alongside `issues/update`.
|
||||||
@@ -204,12 +207,12 @@ export interface UseIssuesResult {
|
|||||||
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)。
|
* 硬删代码托管仓库上的工单及关联资源(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 或分支。 */
|
/** 关闭代码托管仓库上的工单,不清理本地会话、worktree、PR 或分支。 */
|
||||||
closeIssue: (issueNumber: number) => void
|
closeIssue: (issueNumber: number) => void
|
||||||
/**
|
/**
|
||||||
* Dispose the matching session terminal tab. Extension reacts via
|
* Dispose the matching session terminal tab. Extension reacts via
|
||||||
@@ -309,7 +312,7 @@ export interface UseIssuesResult {
|
|||||||
clearPendingSelect: () => void
|
clearPendingSelect: () => void
|
||||||
pendingSelectTab: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' | null
|
pendingSelectTab: 'issue' | 'chat' | 'profile' | 'sessions' | 'changes' | null
|
||||||
clearPendingSelectTab: () => void
|
clearPendingSelectTab: () => void
|
||||||
/** 当前 Gitea login,来自最近一次 `issues/update`;undefined = 身份未知。 */
|
/** 当前登录者的 login,来自最近一次 `issues/update`;undefined = 身份未知。 */
|
||||||
me: string | undefined
|
me: string | undefined
|
||||||
/** 最近一次 `requestHandoffUsers` 的结果;`null` = 仍在加载或尚未请求。 */
|
/** 最近一次 `requestHandoffUsers` 的结果;`null` = 仍在加载或尚未请求。 */
|
||||||
handoffUsers: { issueNumber: number, users: string[] } | null
|
handoffUsers: { issueNumber: number, users: string[] } | null
|
||||||
@@ -956,6 +959,8 @@ export function useIssues(): UseIssuesResult {
|
|||||||
// usual spinner text.
|
// usual spinner text.
|
||||||
setSettings({
|
setSettings({
|
||||||
host: msg.host,
|
host: msg.host,
|
||||||
|
forgeKind: msg.forgeKind,
|
||||||
|
ghLogin: msg.ghLogin,
|
||||||
errorMessage: msg.errorMessage,
|
errorMessage: msg.errorMessage,
|
||||||
canCancel: msg.canCancel === true,
|
canCancel: msg.canCancel === true,
|
||||||
tokenSaved: msg.tokenSaved,
|
tokenSaved: msg.tokenSaved,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 「改动」tab 的订阅 hook:按工单号拉取整个 PR 的改动文件清单,并按需取单个文件
|
* 「改动」tab 的订阅 hook:按工单号拉取整个 PR 的改动文件清单,并按需取单个文件
|
||||||
* 改动前后的原文(供 git-diff-view 内联渲染)。数据全程实时从扩展侧(Gitea)取,
|
* 改动前后的原文(供 git-diff-view 内联渲染)。数据全程实时从扩展侧(代码托管仓库)取,
|
||||||
* 本 hook 只做缓存与订阅。
|
* 本 hook 只做缓存与订阅。
|
||||||
*
|
*
|
||||||
* - issueNumber 变化(含首次)时 postMessage `pr-files/get`,并清空上一工单缓存。
|
* - issueNumber 变化(含首次)时 postMessage `pr-files/get`,并清空上一工单缓存。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* Kept in sync manually; if the two ever diverge that's a step-3+ concern.
|
* Kept in sync manually; if the two ever diverge that's a step-3+ concern.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ForgeKind } from '../../../src/forge/types'
|
||||||
import type { Issue, IssueColumn, ManagedSessionsData } from '../types'
|
import type { Issue, IssueColumn, ManagedSessionsData } from '../types'
|
||||||
|
|
||||||
export interface ToastLink {
|
export interface ToastLink {
|
||||||
@@ -107,6 +108,10 @@ export type ExtensionToWebview
|
|||||||
| {
|
| {
|
||||||
type: 'settings/show'
|
type: 'settings/show'
|
||||||
host: string
|
host: string
|
||||||
|
/** 当前工作区仓库是哪一家,决定设置里显示 Token 输入框还是 gh 登录状态。 */
|
||||||
|
forgeKind: ForgeKind
|
||||||
|
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
|
||||||
|
ghLogin?: string
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
canCancel?: boolean
|
canCancel?: boolean
|
||||||
tokenSaved: boolean
|
tokenSaved: boolean
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { trackerName } from './trackerName'
|
||||||
|
|
||||||
|
describe('trackerName', () => {
|
||||||
|
it('youTrack 工单叫 YouTrack,代码托管工单按网页地址的 host 认出哪一家', () => {
|
||||||
|
expect(trackerName({ source: 'youtrack', htmlUrl: 'https://yt.example/issue/A-1' })).toBe('YouTrack')
|
||||||
|
expect(trackerName({ source: 'forge', htmlUrl: 'https://github.com/o/r/issues/1' })).toBe('GitHub')
|
||||||
|
expect(trackerName({ htmlUrl: 'https://gitea.example/o/r/issues/1' })).toBe('Gitea')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('地址不合法时当 Gitea', () => {
|
||||||
|
expect(trackerName({ source: 'forge', htmlUrl: '' })).toBe('Gitea')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { Issue } from '../types'
|
||||||
|
import { FORGE_NAMES, forgeKindForHost } from '../../../src/forge/types'
|
||||||
|
|
||||||
|
/** 工单所在系统的名字,用在「在 GitHub 打开」这类文案里;代码托管工单看网页地址的 host 认是哪一家。 */
|
||||||
|
export function trackerName(issue: Pick<Issue, 'source' | 'htmlUrl'>): string {
|
||||||
|
if (issue.source === 'youtrack')
|
||||||
|
return 'YouTrack'
|
||||||
|
let host = ''
|
||||||
|
try {
|
||||||
|
host = new URL(issue.htmlUrl).host
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
return FORGE_NAMES[forgeKindForHost(host)]
|
||||||
|
}
|
||||||
@@ -86,7 +86,7 @@ export interface Issue {
|
|||||||
*/
|
*/
|
||||||
prototypeSessionId?: string
|
prototypeSessionId?: string
|
||||||
htmlUrl: string
|
htmlUrl: string
|
||||||
/** 前置任务 issue number (Gitea dependencies 取第一个) */
|
/** 前置任务 issue number(工单依赖里取第一个) */
|
||||||
prerequisite?: number
|
prerequisite?: number
|
||||||
/** Terminal/tab color id chosen on first session open, persisted in state JSON. */
|
/** Terminal/tab color id chosen on first session open, persisted in state JSON. */
|
||||||
color?: string
|
color?: string
|
||||||
@@ -97,13 +97,13 @@ export interface Issue {
|
|||||||
autoReview?: boolean
|
autoReview?: boolean
|
||||||
/** 最近一次审查是否通过。只影响审查列卡片上的小字,不改列。 */
|
/** 最近一次审查是否通过。只影响审查列卡片上的小字,不改列。 */
|
||||||
reviewPassed?: boolean
|
reviewPassed?: boolean
|
||||||
/** Gitea assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
|
/** 代码托管工单的 assignee login 列表;接管按钮只对 assignee 含当前登录者的工单显示。 */
|
||||||
assignees?: string[]
|
assignees?: string[]
|
||||||
/** Gitea 工单创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
|
/** 代码托管工单的创建者 login;无 assignee 的工单靠它判定归属与卡片头像。 */
|
||||||
poster?: string
|
poster?: string
|
||||||
/** 移交会话包在 OSS 上的对象 key;存在即「待接管」。 */
|
/** 移交会话包在 OSS 上的对象 key;存在即「待接管」。 */
|
||||||
handoffBundleKey?: string
|
handoffBundleKey?: string
|
||||||
/** 发起移交的 Gitea login。 */
|
/** 发起移交的人的 login。 */
|
||||||
handoffFrom?: string
|
handoffFrom?: string
|
||||||
/** 谁在申请把这个审查工单移交给自己。 */
|
/** 谁在申请把这个审查工单移交给自己。 */
|
||||||
handoffRequestFrom?: string
|
handoffRequestFrom?: string
|
||||||
|
|||||||
Reference in new issue
Block a user