✨ feat(vscode): 设置和看板按仓库类型区分 GitHub 与 Gitea,GitHub 显示 gh 登录状态,文案去 Gitea 化

This commit is contained in:
cruldra committed 2026-10-19 08:25:10 +11:00
1 parent ac3c882672
commit ae06c8e5d1
42 files changed
+443 -195

No files matched your search

+22 -15
View File
@@ -14,9 +14,11 @@ import type { KanbanScope } from './kanbanScope'
import type { BoardIssues } from './kanbanSnapshot'
import type { ExtensionToWebview, WebviewToExtension } from './messages'
import { env, TabInputTerminal, ThemeColor, Uri, ViewColumn, window, workspace } from 'vscode'
import { clearIdentityCache } from '../auth/identity'
import { deleteToken } from '../auth/secrets'
import { importPluginIntoFigma } from '../figma/importPlugin'
import { resolveForge } from '../forge/context'
import { GH_RELOGIN_HINT } from '../forge/github/ghAuth'
import { loadIssues } from '../forge/issueLoader'
import { ForgeApiError } from '../forge/types'
import { overlayLocalIssueState } from '../issues/localState'
@@ -69,7 +71,7 @@ export class KanbanWebviewPanel {
private issueRefs = new Map<number, { source: 'forge' | 'youtrack', externalId?: string }>()
/** 工单号 → 归属信息,供写操作门禁判定「这是不是我的工单」。 */
private issueOwners = new Map<number, IssueOwnershipFacts>()
/** 本机 token 对应的 Gitea login;解析失败为 undefined(此时不锁任何工单)。 */
/** 本机凭据在代码托管仓库上对应的 login;解析失败为 undefined(此时不锁任何工单)。 */
private me: string | undefined
/** 页面发过首次刷新请求后才推送卡片,避免页面启动时丢消息。 */
private webviewReady = false
@@ -882,15 +884,14 @@ export class KanbanWebviewPanel {
* 2. Move the issue to in-progress and stamp branch/worktreePath/status
* into the state-JSON comment.
* 3. Confirm the always-on webhook coordinator is bound to the current
* port, then register a gitea webhook scoped to the feature branch
* and hand the bookkeeping to the coordinator.
* port and hand the bookkeeping to the coordinator.
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
* tells cc to create the worktree, implement the plan, and emit
* `<request_review>$pr_no</request_review>` when done.
*
* The webhook coordinator (see src/webhook/coordinator.ts) receives the
* resulting PR event, writes back the PR number, and deletes the gitea
* hook — even if this panel has since been closed.
* resulting PR event and writes back the PR number — even if this panel
* has since been closed.
*/
// internal: handler 模块访问
async handleImplement(
@@ -924,7 +925,7 @@ export class KanbanWebviewPanel {
* `done` column.
*
* 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.
*/
/**
@@ -946,7 +947,7 @@ export class KanbanWebviewPanel {
/**
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
*
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
* `issues/update` 会用代码托管仓库里持久化的 state JSON 整体替换 webview
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
* 按钮能找到对应 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.
*/
async loadAndPush(): Promise<void> {
@@ -1014,7 +1015,7 @@ export class KanbanWebviewPanel {
if (this.activeLoad !== load || load.invalidated)
return
if (!resolved.ok) {
// 只有 Gitea 的 token 存在插件设置里,缺了就弹设置让用户填;别的原因直接显示。
// 只有 Gitea 的 token 存在插件设置里,缺了就弹设置让用户填;GitHub 要用户自己去终端登录 gh,和别的原因一样直接显示,页面上有重试。
if (resolved.reason === 'no-credentials' && resolved.kind === 'gitea')
await this.showTokenSettings(resolved.host)
else
@@ -1026,13 +1027,13 @@ export class KanbanWebviewPanel {
const { workspaceRoot, host, owner, repo } = forge
const scope = this.context.workspaceState.get<KanbanScope>(KANBAN_SCOPE_KEY) ?? DEFAULT_KANBAN_SCOPE
try {
const [{ issues: giteaIssues, me, avatars }, youtrackList] = await Promise.all([
const [{ issues: forgeIssues, me, avatars }, youtrackList] = await Promise.all([
loadIssues(forge, scope),
this.loadYouTrackSafe(),
])
if (this.activeLoad !== load || load.invalidated)
return
const recoveryIssues = load.updates.reduce(applyIssueUpdate, [...giteaIssues, ...youtrackList])
const recoveryIssues = load.updates.reduce(applyIssueUpdate, [...forgeIssues, ...youtrackList])
this.issueRefs = new Map(
recoveryIssues.map(i => [i.number, { source: i.source ?? 'forge', externalId: i.externalId }] as const),
)
@@ -1043,7 +1044,7 @@ export class KanbanWebviewPanel {
// 会话文件的会话 id,再补 live terminal 态。
const issues = this.withLiveTerminalTabState(
await annotateMissingSessionIds(
overlayLocalIssueState(this.context, [...giteaIssues, ...youtrackList], workspaceRoot),
overlayLocalIssueState(this.context, [...forgeIssues, ...youtrackList], workspaceRoot),
),
)
if (this.activeLoad !== load || load.invalidated)
@@ -1075,7 +1076,12 @@ export class KanbanWebviewPanel {
catch (err) {
if (this.activeLoad !== load || load.invalidated)
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 this.showTokenSettings(host, 'Token 无效或已过期,请重新填写')
return
@@ -1092,6 +1098,7 @@ export class KanbanWebviewPanel {
this.postMessage({
type: 'settings/show',
host,
forgeKind: 'gitea',
...(errorMessage ? { errorMessage } : {}),
tokenSaved: false,
webhookPort: s.webhookPort,
@@ -1164,13 +1171,13 @@ export class KanbanWebviewPanel {
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 模块访问
readIssueState(issueNumber: number): Promise<Record<string, unknown>> {
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 模块访问
mergeIssueState(issueNumber: number, extra: Record<string, unknown>): Promise<void> {
return mergeIssueState(this.context, this.refFor(issueNumber), extra)
+2 -2
View File
@@ -6,14 +6,14 @@ import { logger } from '../../logging/logger'
* 清理一个 feature 分支(远程 + 本地),供「拖到完成」和「手动删除 worktree」两处共用。
*
* - 受保护分支(main/master/devBranch)直接跳过,绝不删。
* - 远程删除走 gitea API,需 remote + token;缺任一则只删本地,不阻断。
* - 远程删除走代码托管接口,需 remote + 凭据;缺任一则只删本地,不阻断。
* - 本地删除是强删(deleteLocalBranch 内部 `git branch -D`):调用方语义是「连分支一起删」,
* 未合并也照删。
* - 全程 best-effort:远程 / 本地各自 try/catch,失败只记日志——分支清理从不回滚已完成的主流程
* (worktree 已删、PR 已合并)。branch 为空直接返回。
*
* 放在 handlers 层而非 `git/branches.ts`:后者刻意不依赖 `vscode`(有单测覆盖
* deleteLocalBranch),而本函数要用 logger(→ vscode)和 gitea API。
* deleteLocalBranch),而本函数要用 logger(→ vscode)和代码托管接口。
*/
export async function cleanupFeatureBranch(opts: {
workspaceRoot: string
+2 -2
View File
@@ -167,7 +167,7 @@ export async function handleHandoffStart(panel: KanbanWebviewPanel, issueNumber:
return { ok: false, message }
}
// 先拉远端 issue:既要兜底「Gitea 已关闭但从没拖进看板」的工单,也要拿到
// 先拉远端 issue:既要兜底「远端已关闭但从没拖进看板」的工单,也要拿到
// assignees 判断「是否我负责」——两者都得在 canStartHandoff 判定前就绪。
const issue = await provider.getIssue(issueNumber)
if (!issue) {
@@ -507,7 +507,7 @@ export async function handleHandoffAccept(panel: KanbanWebviewPanel, issueNumber
say('核对指派')
// 前置复查:webview 按钮的显示条件(assignees 含 me)可能已经过期(工单被
// 重新指派、被删),用刚从 Gitea 拉到的最新 issue 再判一次 canAcceptHandoff。
// 重新指派、被删),用刚从代码托管仓库拉到的最新 issue 再判一次 canAcceptHandoff。
const issue = await provider.getIssue(issueNumber)
if (!issue) {
progress.dismiss()
+11 -11
View File
@@ -195,7 +195,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
return
}
// 3. Look up PR state on gitea.
// 3. Look up PR state on the forge.
let pullRequest: ForgePullRequest
try {
pullRequest = await forge.provider.getPullRequest(prIndex)
@@ -224,7 +224,7 @@ export async function handleColumnChange(panel: KanbanWebviewPanel, issueNumber:
// 合并失败(冲突 / 已关闭 / 权限)回滚拖动到原列。
let outcome: MergeOutcome
try {
outcome = await forge.provider.mergePullRequest(prIndex, 'merge')
outcome = await forge.provider.mergePullRequest(prIndex, forge.provider.capabilities.mergeMethods[0])
}
catch (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,
* and that the file actually exists on disk, then delegates the full
* implementation pipeline to `handleImplement` (which owns issue locking,
* tab reuse, column/branch/worktreePath state writes, gitea webhook
* registration, cc spawn). On any pre-flight failure, rolls the optimistic
* tab reuse, column/branch/worktreePath state writes, webhook
* bookkeeping, cc spawn). On any pre-flight failure, rolls the optimistic
* column move back to the source column.
*/
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 看板立即移除该工单。
*/
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 报错,让用户手动
* 处理。前端在最后一步收到 `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) {
const prIndex = Number.parseInt(prStr, 10)
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,
* 用户可手动关闭工单。
*/
@@ -1295,7 +1295,7 @@ export async function syncCloseIssue(panel: KanbanWebviewPanel, forge: ForgeCont
* 完)时才退回一次全量拉取。
*
* 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.
*/
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
* button.
*/
@@ -1949,7 +1949,7 @@ export async function handleGeneratePrDiffSummary(panel: KanbanWebviewPanel, iss
return
}
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({
prompt,
+6 -7
View File
@@ -61,7 +61,7 @@ function nonEmptyString(value: unknown): string | undefined {
}
/**
* 会话 id 来自 Gitea 共享状态,别的机器上开的会话本机没有会话文件,resume
* 会话 id 来自工单上的共享状态,别的机器上开的会话本机没有会话文件,resume
* 必然报「No conversation found」。各 resume 入口在开新终端前查一次,没有就
* 用这条 toast 拦下(已开着的 tab 直接复用,不受影响)。
*/
@@ -100,7 +100,7 @@ export async function handleResumeSession(panel: KanbanWebviewPanel, sessionId:
try {
// Server-side prerequisite gate. Webview already disables the entry
// 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
// unknown (legacy sessions) so we don't gate flows that never had a
// prerequisite concept.
@@ -572,15 +572,14 @@ async function triggerAutoReviewTabUnlocked(panel: KanbanWebviewPanel, opts: {
* 2. Move the issue to in-progress and stamp branch/worktreePath/status
* into the state-JSON comment.
* 3. Confirm the always-on webhook coordinator is bound to the current
* port, then register a gitea webhook scoped to the feature branch
* and hand the bookkeeping to the coordinator.
* port and hand the bookkeeping to the coordinator.
* 4. Spawn `claude` in an editor-tab terminal with the implement prompt that
* tells cc to create the worktree, implement the plan, and emit
* `<request_review>$pr_no</request_review>` when done.
*
* The webhook coordinator (see src/webhook/coordinator.ts) receives the
* resulting PR event, writes back the PR number, and deletes the gitea
* hook — even if this panel has since been closed.
* resulting PR event and writes back the PR number — even if this panel
* has since been closed.
*/
export async function handleImplement(
panel: KanbanWebviewPanel,
@@ -653,7 +652,7 @@ export async function handleImplement(
// The webhook coordinator is started at extension activation; just
// 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
// hooks, and PR→issue resolution happens via `Closes #N` in the PR body.
try {
+34 -8
View File
@@ -7,9 +7,11 @@ import * as os from 'node:os'
import * as path from 'node:path'
import process from 'node:process'
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 { listClaudeProfiles, setProfilesDirOverride } from '../../cc/profiles'
import { resolveForge } from '../../forge/context'
import { forgeKindForHost } from '../../forge/types'
import { detectRepo } from '../../git/remote'
import { logger } from '../../logging/logger'
import { readProfiles, writeProfiles } from '../../profiles/store'
@@ -100,18 +102,23 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
// profiles 目录:空串有意义(= 自动探测),只 trim。
const trimmedProfilesDirectory = payload.profilesDirectory.trim()
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
// 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.)
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
// one (placeholder semantics in the modal). Skip rewriting and skip the
// kanban refresh since auth didn't change.
const keepExisting = trimmedToken === '' && !!oldToken
if (!trimmedHost || (!trimmedToken && !keepExisting)) {
const tokenChanged = usesToken && !keepExisting
if (usesToken && (!trimmedHost || (!trimmedToken && !keepExisting))) {
panel.postMessage({
type: 'settings/show',
host: trimmedHost,
forgeKind,
errorMessage: 'Host 和 Token 都不能为空',
tokenSaved: !!oldToken,
webhookPort: payload.webhookPort,
@@ -187,14 +194,14 @@ export async function handleSettingsSave(panel: KanbanWebviewPanel, payload: {
youtrackCloseCommand: trimmedYtClose,
})
void handleProfilesList(panel)
if (!keepExisting) {
if (tokenChanged) {
await setToken(panel.context, trimmedHost, trimmedToken)
// 新 token 可能换了人,身份缓存和状态栏都要跟着重解析。
clearIdentityCache(trimmedHost)
void initIdentity(panel.context)
}
// 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)
await setYouTrackToken(panel.context, youtrackHost(trimmedYtBase), trimmedYtToken)
// 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
// user just tweaked prompts or webhook settings. `keepExisting` already
// 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.
const youtrackChanged = prev.youtrackBaseUrl !== trimmedYtBase
|| prev.youtrackProjectShortName !== trimmedYtProject
|| trimmedYtToken !== ''
if ((!keepExisting && oldToken !== trimmedToken) || youtrackChanged)
if ((tokenChanged && oldToken !== trimmedToken) || youtrackChanged)
await panel.loadAndPush()
// 开发分支 / 发布前缀可能变了,刷新工具栏发布按钮。
void panel.handleReleaseStatus()
@@ -247,8 +254,10 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
const s = getSettings(panel.context)
// 打开设置时同步 override,避免未 activate 路径或旧值漂移。
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 ghLogin = forgeKind === 'github' ? await currentGhLogin(panel) : undefined
const ytTok = s.youtrackBaseUrl.trim()
? await getYouTrackToken(panel.context, youtrackHost(s.youtrackBaseUrl.trim()))
: undefined
@@ -259,6 +268,8 @@ export async function handleEditSettingsRequest(panel: KanbanWebviewPanel): Prom
panel.postMessage({
type: 'settings/show',
host,
forgeKind,
...(ghLogin ? { ghLogin } : {}),
canCancel: true,
tokenSaved,
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. */
export function requestEditAuth(panel: KanbanWebviewPanel): void {
void handleEditSettingsRequest(panel)
+1 -1
View File
@@ -202,7 +202,7 @@ export function handleActiveTerminalChanged(panel: KanbanWebviewPanel, terminal:
/**
* 将当前 VS Code 里仍存活的会话终端同步回 issue 列表。
*
* `issues/update` 会用 Gitea 里持久化的 state JSON 整体替换 webview
* `issues/update` 会用代码托管仓库里持久化的 state JSON 整体替换 webview
* 状态;tabOpen 是本地运行态,不在 state JSON 中,因此发送列表前要按
* live terminal 重新补齐,同时登记 terminalOrigin,确保详情面板的关闭
* 按钮能找到对应 terminal。
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* 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
* resolve the issue in YouTrack when its card lands in the 完成 column — the
* only write-back the user asked for.
+3 -3
View File
@@ -8,9 +8,9 @@
*/
export interface IssueOwnershipFacts {
/** Gitea 工单创建者 login;YouTrack 工单没有该字段。 */
/** 代码托管工单的创建者 login;YouTrack 工单没有该字段。 */
poster?: string
/** Gitea assignee login 列表;空/缺省表示未指派。 */
/** 代码托管工单的 assignee login 列表;空/缺省表示未指派。 */
assignees?: string[]
}
@@ -21,7 +21,7 @@ export function isIssueMine(facts: IssueOwnershipFacts, me: string | undefined):
const assignees = facts.assignees ?? []
if (assignees.length > 0)
return assignees.includes(me)
// 无 assignee:创建者说了算;poster 缺省(非 Gitea 来源)不参与判定。
// 无 assignee:创建者说了算;poster 缺省(YouTrack 来源)不参与判定。
return facts.poster === undefined || facts.poster === me
}
+1 -1
View File
@@ -6,7 +6,7 @@ export interface KanbanSnapshot {
issues: Issue[]
scope: 'mine' | 'all'
me?: string
/** Gitea login → 头像 data URI;团队视图下卡片按人贴头像。 */
/** 代码托管 login → 头像 data URI;团队视图下卡片按人贴头像。 */
avatars?: Record<string, string>
globalAutoReview: boolean
youtrackConfigured: boolean
+5
View File
@@ -1,3 +1,4 @@
import type { ForgeKind } from '../forge/types'
import type { ReleaseCommitGroup } from '../git/release'
import type { Issue, IssueColumn } from '../issues/types'
import type { LogEntry } from '../logging/logger'
@@ -68,6 +69,10 @@ export type ExtensionToWebview
| {
type: 'settings/show'
host: string
/** 当前工作区仓库是哪一家,决定设置里显示 Token 输入框还是 gh 登录状态。 */
forgeKind: ForgeKind
/** gh 当前登录的 GitHub 账号;没登录或登录失效时没有。 */
ghLogin?: string
errorMessage?: string
canCancel?: boolean
tokenSaved: boolean
+1 -1
View File
@@ -21,7 +21,7 @@ export const OWNER_ONLY_MESSAGE_TYPES: ReadonlySet<string> = new Set([
'session/close-tab',
'brainstorm/continue',
'issue/implement',
// 工单状态:写回 Gitea
// 工单状态:写回代码托管仓库
'column/change',
'issue/delete',
'issue/close',