This commit is contained in:
2026-06-23 05:02:15 +08:00
commit e6f1776d4f
264 changed files with 54215 additions and 0 deletions
@@ -0,0 +1,178 @@
package com.cruldra.superworkbench.git
import com.cruldra.superworkbench.settings.SettingsService
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.openapi.diagnostic.thisLogger
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.util.concurrent.TimeUnit
/**
* worktree 创建/移除生命周期前后运行用户自定义 shell 脚本,对应源项目
* `src/git/worktreeHooks.ts`。让用户能在不改插件代码的前提下做环境准备
* (复制 `.env`、开 IDE …)与清理(关 IDE、归档日志 …)。
*
* 行为契约(与源一致的 fire-and-forget 语义):
* - 脚本文件不存在 → 静默返回 [HookStatus.SKIPPED](默认状态,不打扰用户)。
* - 脚本退出 0 → [HookStatus.OK]。
* - 非零退出 / `bash` 不在 PATH / 超时 → 返回非 OK 状态;调用方记日志,
* 但绝不中断外层流程(worktree 创建/移除才是事实来源,脚本只是旁路)。
*
* 注入子进程的环境变量(变量名照源 worktreeHooks.ts):
* - `WORKTREE_PATH` — worktree 绝对路径
* - `WORKSPACE_ROOT` — 主工作区绝对路径
* - `BRANCH` — feature 分支名
* - `ISSUE_NUMBER` — gitea 工单号(十进制字符串)
* - `MAIN_BRANCH` — settings.devBranch(默认 `main`
*/
object WorktreeHooks {
private const val DEFAULT_POST_CREATE_REL = ".spx/worktree-post-create.sh"
private const val DEFAULT_PRE_REMOVE_REL = ".spx/worktree-pre-remove.sh"
private const val DEFAULT_IMPL_TAB_PRE_CREATE_REL = ".spx/impl-tab-pre-create.sh"
private const val DEFAULT_IMPL_TAB_POST_CLOSE_REL = ".spx/impl-tab-post-close.sh"
private const val HOOK_TIMEOUT_MS = 30_000L
/** worktree 创建后钩子。脚本路径取 `worktreePostCreateScript`,空则用默认相对路径。 */
fun runPostCreateHook(ctx: WorktreeContext): HookResult =
runHook(ctx, settingsScript { it.worktreePostCreateScript }, DEFAULT_POST_CREATE_REL)
/** worktree 移除前钩子。脚本路径取 `worktreePreRemoveScript`,空则用默认相对路径。 */
fun runPreRemoveHook(ctx: WorktreeContext): HookResult =
runHook(ctx, settingsScript { it.worktreePreRemoveScript }, DEFAULT_PRE_REMOVE_REL)
/** 实施 tab 创建前钩子。脚本路径取 `implTabPreCreateScript`,空则用默认相对路径。 */
fun runImplTabPreCreateHook(ctx: WorktreeContext): HookResult =
runHook(ctx, settingsScript { it.implTabPreCreateScript }, DEFAULT_IMPL_TAB_PRE_CREATE_REL)
/** 实施 tab 关闭后钩子。脚本路径取 `implTabPostCloseScript`,空则用默认相对路径。 */
fun runImplTabPostCloseHook(ctx: WorktreeContext): HookResult =
runHook(ctx, settingsScript { it.implTabPostCloseScript }, DEFAULT_IMPL_TAB_POST_CLOSE_REL)
/** 从 [SettingsService] 状态里取一个自定义脚本路径覆盖(trim 后),可能为空串。 */
private inline fun settingsScript(pick: (com.cruldra.superworkbench.settings.SettingsState) -> String): String =
pick(SettingsService.getInstance().state).trim()
/**
* 解析脚本绝对路径:自定义覆盖非空时优先(相对则拼到 workspaceRoot),否则用默认相对路径。
* 对齐源 `resolveScriptPath`。
*/
private fun resolveScriptPath(ctx: WorktreeContext, customScriptPath: String, defaultRelPath: String): Path {
if (customScriptPath.isNotEmpty()) {
val custom = Paths.get(customScriptPath)
return if (custom.isAbsolute) custom else ctx.workspaceRoot.resolve(customScriptPath)
}
return ctx.workspaceRoot.resolve(defaultRelPath)
}
/**
* 跑一个钩子脚本:`bash <script>`cwd=worktree,注入上下文环境变量,30s 超时。
* 脚本不存在直接 SKIPPED;其余结果映射到 [HookStatus]。任何异常都被捕获成 FAILED,
* 不向上抛(fire-and-forget)。
*/
private fun runHook(ctx: WorktreeContext, customScriptPath: String, defaultRelPath: String): HookResult {
val scriptPath = resolveScriptPath(ctx, customScriptPath, defaultRelPath)
if (!Files.exists(scriptPath)) {
return HookResult(HookStatus.SKIPPED, scriptPath = scriptPath.toString())
}
val cmd = GeneralCommandLine("bash", scriptPath.toString()).apply {
setWorkDirectory(ctx.worktreePath.toString())
charset = StandardCharsets.UTF_8
environment["WORKTREE_PATH"] = ctx.worktreePath.toString()
environment["WORKSPACE_ROOT"] = ctx.workspaceRoot.toString()
environment["BRANCH"] = ctx.branch
environment["ISSUE_NUMBER"] = ctx.issueNumber.toString()
environment["MAIN_BRANCH"] = ctx.mainBranch
}
val process = try {
cmd.createProcess()
} catch (e: Exception) {
return HookResult(
HookStatus.ENOENT,
errorMessage = "bash 不存在(PATH 中找不到):${e.message}",
scriptPath = scriptPath.toString(),
)
}
val stdout = StringBuilder()
val stderr = StringBuilder()
val outThread = Thread {
process.inputStream.bufferedReader(StandardCharsets.UTF_8).forEachLine { stdout.appendLine(it) }
}
val errThread = Thread {
process.errorStream.bufferedReader(StandardCharsets.UTF_8).forEachLine { stderr.appendLine(it) }
}
outThread.start()
errThread.start()
val finished = process.waitFor(HOOK_TIMEOUT_MS, TimeUnit.MILLISECONDS)
if (!finished) {
process.destroyForcibly()
return HookResult(
HookStatus.TIMEOUT,
errorMessage = "脚本超时(${HOOK_TIMEOUT_MS / 1000}s",
stdout = stdout.toString(),
stderr = stderr.toString(),
scriptPath = scriptPath.toString(),
)
}
outThread.join(2_000)
errThread.join(2_000)
val exit = process.exitValue()
return if (exit == 0) {
HookResult(
HookStatus.OK,
exitCode = 0,
stdout = stdout.toString(),
stderr = stderr.toString(),
scriptPath = scriptPath.toString(),
)
} else {
HookResult(
HookStatus.FAILED,
exitCode = exit,
errorMessage = "脚本退出码非零($exit",
stdout = stdout.toString(),
stderr = stderr.toString(),
scriptPath = scriptPath.toString(),
)
}
}
/**
* 调度一个钩子并把结果记到 IDE 日志(fire-and-forget)。SKIPPED 静默;OK info
* 其余 warn。对应源 `dispatchWorktreeHook` 里把结果转成日志/通知的那段(这里只记日志,
* 不弹气泡,避免打扰;详情进日志)。绝不抛异常。
*/
fun dispatch(phase: String, result: HookResult, issueNumber: Int) {
when (result.status) {
HookStatus.SKIPPED -> return
HookStatus.OK -> thisLogger().info(
"worktree $phase 钩子完成 #$issueNumber path=${result.scriptPath}",
)
else -> thisLogger().warn(
"worktree $phase 钩子失败 #$issueNumber: ${result.status} " +
"path=${result.scriptPath ?: "(unresolved)"} " +
"exitCode=${result.exitCode} err=${result.errorMessage} " +
"stderr=${result.stderr}",
)
}
}
}
/** 钩子执行状态,对齐源 `HookStatus` 联合类型。 */
enum class HookStatus { SKIPPED, OK, FAILED, TIMEOUT, ENOENT }
/** 单次钩子执行结果,对齐源 `HookResult`。非 OK 状态时按需带退出码/错误信息。 */
data class HookResult(
val status: HookStatus,
val exitCode: Int? = null,
val stdout: String? = null,
val stderr: String? = null,
val errorMessage: String? = null,
val scriptPath: String? = null,
)
@@ -0,0 +1,183 @@
package com.cruldra.superworkbench.git
import com.cruldra.superworkbench.settings.SettingsService
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.ide.impl.ProjectUtil
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import java.nio.charset.StandardCharsets
import java.nio.file.Path
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
/**
* git worktree 管理服务,对应源项目 `src/git/worktree.ts` + `src/panel/handlers/worktree.ts`
* 的 worktree 创建/移除/打开操作。直接调系统 `git` 二进制(用 [GeneralCommandLine]),
* 不引入 git 库——表面最小,复用用户已配好的 git。
*
* 分支名/路径规则(照源 sessions.ts `handleImplement`):
* - feature hash = sha256(planFile) 的前 8 个 hex 字符;
* - 分支名 = `feature/<hash>`
* - worktree 路径模板 = `$project_root.worktrees/$feature_name`meta.ts 默认值),
* 替换 `$project_root`(=工作区根)/`$project_name`(=根目录名)/`$feature_name`(=hash)。
*
* 生命周期钩子(post-create / pre-remove)由 [WorktreeHooks] 跑用户脚本,fire-and-forget。
*/
object WorktreeService {
private const val GIT_TIMEOUT_MS = 30_000L
/** 默认 worktree 路径模板,对齐源 meta.ts 的 `worktreeDirTemplate` 默认值。 */
private const val DEFAULT_WORKTREE_TEMPLATE = "\$project_root.worktrees/\$feature_name"
/**
* 对 planFile 取 sha256,返回前 8 个 hex 字符作为 feature 标识。
* 对齐源 `createHash('sha256').update(planFile).digest('hex').slice(0, 8)`。
*/
fun computeFeatureHash(planFile: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(planFile.toByteArray(StandardCharsets.UTF_8))
return digest.joinToString("") { "%02x".format(it) }.substring(0, 8)
}
/** feature 分支名 `feature/<hash>`。对齐源 `feature/${feature}`。 */
fun computeBranchName(planFile: String): String = "feature/${computeFeatureHash(planFile)}"
/**
* 按模板算 worktree 绝对路径,替换 `$project_root`/`$project_name`/`$feature_name`。
* 默认模板 `$project_root.worktrees/$feature_name`(如 `/repo.worktrees/abcd1234`)。
*
* @param workspaceRoot 主工作区根目录绝对路径(= `$project_root`)。
* @param featureName feature 标识(= `$feature_name`,通常是 [computeFeatureHash] 结果)。
*/
fun computeWorktreePath(workspaceRoot: Path, featureName: String): Path {
val projectRoot = workspaceRoot.toString()
val projectName = workspaceRoot.fileName?.toString() ?: projectRoot
val resolved = DEFAULT_WORKTREE_TEMPLATE
.replace("\$project_root", projectRoot)
.replace("\$project_name", projectName)
.replace("\$feature_name", featureName)
return Path.of(resolved)
}
/**
* 创建一个基于当前 HEAD 新建分支的 git worktree
* `git -C <workspaceRoot> worktree add <worktreePath> -b <branch>`
* 成功后跑 post-create 钩子(fire-and-forget)。git 非零退出时返回失败 [Result]。
*/
fun createWorktree(workspaceRoot: Path, branch: String, worktreePath: Path, issueNumber: Int): Result<Unit> {
val cmd = GeneralCommandLine(
"git", "-C", workspaceRoot.toString(),
"worktree", "add", worktreePath.toString(), "-b", branch,
).apply { charset = StandardCharsets.UTF_8 }
val outcome = runGit(cmd)
if (outcome.exitCode != 0) {
return Result.failure(
IllegalStateException("git worktree add 失败 (${outcome.exitCode}): ${outcome.stderr.trim()}"),
)
}
val ctx = WorktreeContext(
issueNumber = issueNumber,
branch = branch,
worktreePath = worktreePath,
workspaceRoot = workspaceRoot,
mainBranch = SettingsService.getInstance().state.devBranch.ifBlank { "main" },
)
WorktreeHooks.dispatch("post-create", WorktreeHooks.runPostCreateHook(ctx), issueNumber)
return Result.success(Unit)
}
/**
* 移除一个 worktree:先跑 pre-remove 钩子(fire-and-forget),再
* `git -C <workspaceRoot> worktree remove <worktreePath> --force`
* git 非零退出时返回失败 [Result]。
*/
fun removeWorktree(workspaceRoot: Path, worktreePath: Path, branch: String, issueNumber: Int): Result<Unit> {
val ctx = WorktreeContext(
issueNumber = issueNumber,
branch = branch,
worktreePath = worktreePath,
workspaceRoot = workspaceRoot,
mainBranch = SettingsService.getInstance().state.devBranch.ifBlank { "main" },
)
WorktreeHooks.dispatch("pre-remove", WorktreeHooks.runPreRemoveHook(ctx), issueNumber)
val cmd = GeneralCommandLine(
"git", "-C", workspaceRoot.toString(),
"worktree", "remove", worktreePath.toString(), "--force",
).apply { charset = StandardCharsets.UTF_8 }
val outcome = runGit(cmd)
if (outcome.exitCode != 0) {
return Result.failure(
IllegalStateException("git worktree remove 失败 (${outcome.exitCode}): ${outcome.stderr.trim()}"),
)
}
return Result.success(Unit)
}
/**
* 在新 IDE 窗口打开 worktree 目录,对应源「在新窗口打开 worktree」
* `vscode.openFolder(uri, true)` 的 forceNewWindow)。
* 用 [ProjectUtil.openOrImport] 的 forceOpenInNewFrame=true 在新窗口打开,
* 必须在 EDT 上调,故用 invokeLater 包裹。
*/
fun openWorktree(project: Project, worktreePath: Path) {
ApplicationManager.getApplication().invokeLater {
try {
ProjectUtil.openOrImport(worktreePath, project, true)
} catch (e: Exception) {
thisLogger().warn("打开 worktree 失败 $worktreePath", e)
}
}
}
/** 同步跑一条 git 命令,排空 stdout/stderr,返回退出码与 stderr。启动失败/超时按非零处理。 */
private fun runGit(cmd: GeneralCommandLine): GitOutcome {
val process = try {
cmd.createProcess()
} catch (e: Exception) {
return GitOutcome(exitCode = -1, stderr = "git 进程启动失败(请确认已安装并在 PATH 中):${e.message}")
}
val stdout = StringBuilder()
val stderr = StringBuilder()
val outThread = Thread {
process.inputStream.bufferedReader(StandardCharsets.UTF_8).forEachLine { stdout.appendLine(it) }
}
val errThread = Thread {
process.errorStream.bufferedReader(StandardCharsets.UTF_8).forEachLine { stderr.appendLine(it) }
}
outThread.start()
errThread.start()
val finished = process.waitFor(GIT_TIMEOUT_MS, TimeUnit.MILLISECONDS)
if (!finished) {
process.destroyForcibly()
return GitOutcome(exitCode = -1, stderr = "git 命令超时(${GIT_TIMEOUT_MS / 1000}s")
}
outThread.join(2_000)
errThread.join(2_000)
return GitOutcome(exitCode = process.exitValue(), stderr = stderr.toString())
}
private data class GitOutcome(val exitCode: Int, val stderr: String)
}
/**
* worktree 生命周期上下文,提供给 [WorktreeHooks] 注入环境变量、给 git 命令拼参数。
* 字段对齐源 worktreeHooks.ts 的 `HookContext`。
*
* @property issueNumber gitea 工单号。
* @property branch feature 分支名(`feature/<hash>`)。
* @property worktreePath worktree 绝对路径。
* @property workspaceRoot 主工作区根目录绝对路径。
* @property mainBranch 日常开发分支(= settings.devBranch,默认 `main`)。
*/
data class WorktreeContext(
val issueNumber: Int,
val branch: String,
val worktreePath: Path,
val workspaceRoot: Path,
val mainBranch: String,
)
@@ -0,0 +1,65 @@
package com.cruldra.superworkbench.gitea
import java.nio.file.Path
/**
* 从工作区 git `origin` 远端解析出 Gitea 仓库坐标,对应源项目 `src/git/remote.ts`。
*
* `host` 形如 `gitea.example.com``owner`/`repo` 取自 URL 路径段;`repo` 已去掉
* 末尾的 `.git` 后缀。
*/
data class GiteaRepoRef(
val host: String,
val owner: String,
val repo: String,
)
/**
* 解析 git 远端 URL 为 [GiteaRepoRef],支持 https 与 ssh 两种形态,逻辑/正则照搬
* `src/git/remote.ts`。无法解析时返回 null。
*/
object GitRemote {
// https://gitea.x.cn/owner/repo.git(可带 user@、端口、末尾斜杠)
private val HTTPS_RE =
Regex("""^https?://(?:[^@]+@)?([^/:]+)(?::\d+)?/([^/]+)/([^/]+?)(?:\.git)?/?$""")
// git@gitea.x.cn:owner/repo.git(可带 ssh:// 前缀)
private val SSH_RE =
Regex("""^(?:ssh://)?(?:[^@]+@)?([^/:]+)[:/]([^/]+)/([^/]+?)(?:\.git)?/?$""")
/** 纯字符串解析,便于单测,不触发子进程。无法解析返回 null。 */
fun parse(url: String): GiteaRepoRef? {
val trimmed = url.trim()
if (trimmed.isEmpty()) return null
HTTPS_RE.matchEntire(trimmed)?.let { m ->
return GiteaRepoRef(m.groupValues[1], m.groupValues[2], m.groupValues[3])
}
SSH_RE.matchEntire(trimmed)?.let { m ->
return GiteaRepoRef(m.groupValues[1], m.groupValues[2], m.groupValues[3])
}
return null
}
/**
* 在 [workdir] 下跑 `git remote get-url origin` 解析 origin 远端,git 失败或 URL
* 不可解析时返回 null。对应源项目 `detectRepo`。
*/
fun detect(workdir: Path): GiteaRepoRef? {
val url = runGit(workdir, "remote", "get-url", "origin") ?: return null
return parse(url)
}
/** 在 [workdir] 同步执行 git 命令并返回 stdout(trim 后);非零退出或异常返回 null。 */
private fun runGit(workdir: Path, vararg args: String): String? = try {
val process = ProcessBuilder(listOf("git", *args))
.directory(workdir.toFile())
.redirectErrorStream(false)
.start()
val stdout = process.inputStream.readBytes().toString(Charsets.UTF_8)
val exit = process.waitFor()
if (exit == 0) stdout.trim().ifEmpty { null } else null
} catch (_: Exception) {
null
}
}
@@ -0,0 +1,392 @@
package com.cruldra.superworkbench.gitea
import com.cruldra.superworkbench.settings.GiteaTokenStore
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse
import java.net.http.HttpResponse.BodyHandlers
import java.nio.charset.StandardCharsets
/** 单页拉取上限,与源项目 `PAGE_SIZE` 一致。 */
private const val PAGE_SIZE = 50
/** 共享反序列化器:容忍 Gitea 返回的未知字段。 */
private val GiteaJson = Json { ignoreUnknownKeys = true }
/**
* 非 2xx 响应抛出的异常,携带状态码与响应体,使调用方能区分 401(token 失效)
* 与其它失败。对应源项目 `GiteaApiError`。
*/
class GiteaApiError(val status: Int, message: String) : RuntimeException("HTTP $status: $message")
/**
* 基于 [HttpClient] 同步 `send` 的 Gitea REST 客户端,逐方法对应源项目
* `src/gitea/api.ts`。
*
* 构造时只需 [host](如 `gitea.example.com`),token 在每次请求时从
* [GiteaTokenStore] 按 host 取,缺失即抛 [GiteaApiError]。base URL 固定为
* `https://$host/api/v1`,所有请求带 `Authorization: token <PAT>`。
*/
class GiteaApi(private val host: String) {
private val client: HttpClient = HttpClient.newHttpClient()
private val baseUrl: String = "https://$host/api/v1"
private fun token(): String =
GiteaTokenStore.get(host) ?: throw GiteaApiError(401, "缺少 host=$host 的 Gitea token")
private fun newRequest(uri: URI): HttpRequest.Builder =
HttpRequest.newBuilder(uri)
.header("Authorization", "token ${token()}")
.header("Accept", "application/json")
/** 发请求并对非 2xx 抛 [GiteaApiError],返回响应体字符串。 */
private fun send(request: HttpRequest): String {
val response = client.send(request, BodyHandlers.ofString())
ensureOk(response)
return response.body()
}
private fun ensureOk(response: HttpResponse<String>) {
if (response.statusCode() !in 200..299) {
val body = response.body().orEmpty()
throw GiteaApiError(response.statusCode(), body.ifBlank { "request failed" })
}
}
private fun get(path: String): String =
send(newRequest(URI.create("$baseUrl$path")).GET().build())
private fun get(uri: URI): String =
send(newRequest(uri).GET().build())
private fun postJson(path: String, json: JsonObject): String =
send(
newRequest(URI.create("$baseUrl$path"))
.header("Content-Type", "application/json")
.POST(BodyPublishers.ofString(json.toString()))
.build(),
)
private fun patchJson(path: String, json: JsonObject): String =
send(
newRequest(URI.create("$baseUrl$path"))
.header("Content-Type", "application/json")
.method("PATCH", BodyPublishers.ofString(json.toString()))
.build(),
)
private fun delete(path: String): String =
send(newRequest(URI.create("$baseUrl$path")).DELETE().build())
private fun deleteJson(path: String, json: JsonObject): String =
send(
newRequest(URI.create("$baseUrl$path"))
.header("Content-Type", "application/json")
.method("DELETE", BodyPublishers.ofString(json.toString()))
.build(),
)
private inline fun <reified T> decode(body: String): T = GiteaJson.decodeFromString(body)
private fun encodePath(segment: String): String =
URLEncoder.encode(segment, StandardCharsets.UTF_8).replace("+", "%20")
// --- 用户 ---
/** GET /user:取当前 token 对应用户。 */
fun getCurrentUser(): GiteaUser = decode(get("/user"))
// --- issue ---
/**
* GET /repos/{owner}/{repo}/issues,按 `assigned_by` 或 `created_by` 过滤,
* 翻页直至遇到短页。对应源项目 `listIssuesByFilter`。
*
* @param filter 取 `assigned_by` 或 `created_by`
*/
fun listIssuesByFilter(
owner: String,
repo: String,
filter: String,
user: String,
): List<GiteaIssue> {
val out = mutableListOf<GiteaIssue>()
var page = 1
while (true) {
val query = buildQuery(
"type" to "issues",
"state" to "all",
filter to user,
"limit" to PAGE_SIZE.toString(),
"page" to page.toString(),
)
val uri = URI.create("$baseUrl/repos/$owner/$repo/issues$query")
val batch = decode<List<GiteaIssue>>(get(uri))
if (batch.isEmpty()) break
out += batch
if (batch.size < PAGE_SIZE) break
page += 1
}
return out
}
/** GET /repos/{owner}/{repo}/issues/{index}:取单个 issue404 返回 null。 */
fun getIssue(owner: String, repo: String, index: Long): GiteaIssue? {
val request = newRequest(URI.create("$baseUrl/repos/$owner/$repo/issues/$index")).GET().build()
val response = client.send(request, BodyHandlers.ofString())
if (response.statusCode() == 404) return null
ensureOk(response)
return decode(response.body())
}
/**
* GET /repos/{owner}/{repo}/issues/comments:取全仓库所有 issue 的评论
* (firehose),翻页直至短页。对应源项目 `listAllRepoComments`。
*/
fun listRepoComments(owner: String, repo: String): List<GiteaComment> =
paginateComments("$baseUrl/repos/$owner/$repo/issues/comments")
/**
* GET /repos/{owner}/{repo}/issues/{index}/comments:取单个 issue 的评论,
* 翻页直至短页。对应源项目 `listIssueComments`。
*/
fun listIssueComments(owner: String, repo: String, index: Long): List<GiteaComment> =
paginateComments("$baseUrl/repos/$owner/$repo/issues/$index/comments")
private fun paginateComments(endpoint: String): List<GiteaComment> {
val out = mutableListOf<GiteaComment>()
var page = 1
while (true) {
val query = buildQuery("limit" to PAGE_SIZE.toString(), "page" to page.toString())
val batch = decode<List<GiteaComment>>(get(URI.create("$endpoint$query")))
if (batch.isEmpty()) break
out += batch
if (batch.size < PAGE_SIZE) break
page += 1
}
return out
}
/**
* POST /repos/{owner}/{repo}/issues/{index}/comments:发表评论。
* 对应源项目 `postIssueComment`,返回创建的评论。
*/
fun postIssueComment(owner: String, repo: String, index: Long, body: String): GiteaComment {
val json = buildJsonObject { put("body", body) }
return decode(postJson("/repos/$owner/$repo/issues/$index/comments", json))
}
/**
* PATCH /repos/{owner}/{repo}/issues/{index}:把 issue 状态改为 closed。
* 对应源项目 `closeIssue`。
*/
fun closeIssue(owner: String, repo: String, issueNumber: Long) {
patchJson(
"/repos/$owner/$repo/issues/$issueNumber",
buildJsonObject { put("state", "closed") },
)
}
/**
* PATCH /repos/{owner}/{repo}/issues/{index}:更新 issue 正文。Gitea 用同一
* 端点改 body。
*/
fun updateIssueBody(owner: String, repo: String, issueNumber: Long, body: String): GiteaIssue =
decode(
patchJson(
"/repos/$owner/$repo/issues/$issueNumber",
buildJsonObject { put("body", body) },
),
)
/**
* PATCH /repos/{owner}/{repo}/issues/{index}:更新 issue 状态。state 取
* open/closed。
*/
fun setIssueState(owner: String, repo: String, issueNumber: Long, state: String): GiteaIssue =
decode(
patchJson(
"/repos/$owner/$repo/issues/$issueNumber",
buildJsonObject { put("state", state) },
),
)
/** DELETE /repos/{owner}/{repo}/issues/{index}:删除 issue。对应 `deleteIssue`。 */
fun deleteIssue(owner: String, repo: String, issueNumber: Long) {
delete("/repos/$owner/$repo/issues/$issueNumber")
}
// --- 依赖 ---
/**
* GET /repos/{owner}/{repo}/issues/{index}/dependencies:列出前置任务。
* 对应源项目 `getDependencies`。
*/
fun getDependencies(owner: String, repo: String, index: Long): List<GiteaIssueDependency> =
decode(get("/repos/$owner/$repo/issues/$index/dependencies"))
/**
* POST /repos/{owner}/{repo}/issues/{index}/dependencies:添加前置依赖。
* 对应源项目 `addDependency`。
*/
fun addDependency(owner: String, repo: String, index: Long, dependencyIndex: Long) {
postJson(
"/repos/$owner/$repo/issues/$index/dependencies",
buildJsonObject { put("index", dependencyIndex) },
)
}
/**
* DELETE /repos/{owner}/{repo}/issues/{index}/dependencies:移除前置依赖。
* 对应源项目 `removeDependency`。
*/
fun removeDependency(owner: String, repo: String, index: Long, dependencyIndex: Long) {
deleteJson(
"/repos/$owner/$repo/issues/$index/dependencies",
buildJsonObject { put("index", dependencyIndex) },
)
}
// --- pull request ---
/** GET /repos/{owner}/{repo}/pulls/{index}:取单个 PR。对应 `getPullRequest`。 */
fun getPull(owner: String, repo: String, index: Long): GiteaPull =
decode(get("/repos/$owner/$repo/pulls/$index"))
/**
* POST /repos/{owner}/{repo}/pulls/{index}/merge:合并 PR。默认 merge 策略。
* 对应源项目 `mergePullRequest`。
*
* @param strategy 取 merge / rebase / rebase-merge / squash
*/
fun mergePull(
owner: String,
repo: String,
index: Long,
strategy: String = "merge",
) {
val json = buildJsonObject {
put("Do", strategy)
put("delete_branch_after_merge", false)
put("force_merge", false)
}
postJson("/repos/$owner/$repo/pulls/$index/merge", json)
}
/**
* GET /repos/{owner}/{repo}/pulls/{index}/commits:列出 PR 的提交。关闭
* files/stat/verification 让响应尽量小。对应源项目 `listPullRequestCommits`。
*/
fun listPullCommits(
owner: String,
repo: String,
index: Long,
limit: Int = 50,
): List<GiteaRepoCommit> {
val query = buildQuery(
"page" to "1",
"limit" to limit.toString(),
"files" to "false",
"stat" to "false",
"verification" to "false",
)
return decode(get(URI.create("$baseUrl/repos/$owner/$repo/pulls/$index/commits$query")))
}
// --- commit ---
/**
* GET /repos/{owner}/{repo}/git/commits/{sha}:取单个提交详情(带文件清单)。
* 对应源项目 `getGitCommit`。
*/
fun getCommitFiles(owner: String, repo: String, sha: String): GiteaRepoCommit {
val query = buildQuery(
"files" to "true",
"stat" to "false",
"verification" to "false",
)
return decode(get(URI.create("$baseUrl/repos/$owner/$repo/git/commits/$sha$query")))
}
/**
* GET /repos/{owner}/{repo}/raw/{filepath}?ref=:取某 ref 下文件原始内容,
* 用作 diff 的一侧。404 或其它非 2xx 兜底返回空串(新增/删除文件的预期缺席)。
* 对应源项目 `getRawFile`。
*/
fun getRawFile(owner: String, repo: String, filepath: String, ref: String): String {
val encodedPath = filepath.split("/").joinToString("/") { encodePath(it) }
val query = buildQuery("ref" to ref)
val request = newRequest(
URI.create("$baseUrl/repos/$owner/$repo/raw/$encodedPath$query"),
).GET().build()
val response = client.send(request, BodyHandlers.ofString())
if (response.statusCode() !in 200..299) return ""
return response.body()
}
// --- branch ---
/**
* DELETE /repos/{owner}/{repo}/branches/{branch}:删除分支。
* 对应源项目 `deleteBranch`。
*/
fun deleteBranch(owner: String, repo: String, branch: String) {
delete("/repos/$owner/$repo/branches/${encodePath(branch)}")
}
// --- webhook ---
/**
* POST /repos/{owner}/{repo}/hooks:创建 pull_request webhook。
* 对应源项目 `createWebhook`,返回带 id 的 [GiteaHook]。
*/
fun createHook(
owner: String,
repo: String,
url: String,
branchFilter: String,
): GiteaHook {
val json = buildJsonObject {
put("type", "gitea")
put("active", true)
put("events", JsonArray(listOf(JsonPrimitive("pull_request"))))
put("branch_filter", branchFilter)
put(
"config",
buildJsonObject {
put("url", url)
put("content_type", "json")
},
)
}
return decode(postJson("/repos/$owner/$repo/hooks", json))
}
/**
* DELETE /repos/{owner}/{repo}/hooks/{id}:删除 webhook。404 视为已不存在,
* 静默返回。对应源项目 `deleteWebhook`。
*/
fun deleteHook(owner: String, repo: String, hookId: Long) {
val request = newRequest(URI.create("$baseUrl/repos/$owner/$repo/hooks/$hookId")).DELETE().build()
val response = client.send(request, BodyHandlers.ofString())
if (response.statusCode() == 404) return
ensureOk(response)
}
/** 把键值对拼成 URL query 串(含前导 `?`),逐段 encode。空时返回空串。 */
private fun buildQuery(vararg params: Pair<String, String>): String {
if (params.isEmpty()) return ""
return params.joinToString(prefix = "?", separator = "&") { (key, value) ->
"${encodePath(key)}=${encodePath(value)}"
}
}
}
@@ -0,0 +1,148 @@
package com.cruldra.superworkbench.gitea
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Gitea REST API 原始响应 DTO。
*
* 对应源项目 `src/gitea/api.ts` 里逐个 fetch 调用解出的对象形状,字段名通过
* [SerialName] 对齐 Gitea 蛇形命名。所有反序列化都走 [GiteaJson],开启
* `ignoreUnknownKeys` 以容忍 Gitea 返回的额外字段。
*/
/** Gitea 用户精简视图:state JSON 协调流程只需 login(外加 id)。 */
@Serializable
data class GiteaUser(
val login: String,
val id: Long = 0,
)
/** issue 上的标签:看板渲染用 name + color。 */
@Serializable
data class GiteaLabel(
val name: String = "",
val color: String = "",
)
/**
* Gitea issue 原始响应。`number` 即 tea 的 index 字段;`state` 取 open/closed。
* `user`/`assignees`/`labels` 在 Gitea 缺省时可能为 null。
*/
@Serializable
data class GiteaIssue(
val id: Long = 0,
val number: Long = 0,
val title: String = "",
val state: String = "",
val comments: Long = 0,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
val body: String = "",
@SerialName("html_url") val htmlUrl: String = "",
val user: GiteaUser? = null,
val assignees: List<GiteaUser>? = null,
val labels: List<GiteaLabel>? = null,
)
/** issue/PR 评论。state JSON comment 即承载于此 body。 */
@Serializable
data class GiteaComment(
val id: Long = 0,
val body: String = "",
@SerialName("issue_url") val issueUrl: String = "",
val user: GiteaUser? = null,
@SerialName("created_at") val createdAt: String = "",
@SerialName("updated_at") val updatedAt: String = "",
)
/** PR head/base 引用的分支信息:ref 是分支名,sha 是当前提交。 */
@Serializable
data class GiteaPrBranchInfo(
val ref: String = "",
val sha: String = "",
val label: String = "",
)
/**
* Gitea pull request 原始响应。webhook 协调器用 `body` 反查底层 issue
* (`Closes #N`);看板「完成」落列前用 `merged` 校验已合并。
*/
@Serializable
data class GiteaPull(
val id: Long = 0,
val number: Long = 0,
val title: String = "",
val state: String = "",
val body: String = "",
val merged: Boolean = false,
@SerialName("merged_at") val mergedAt: String? = null,
val mergeable: Boolean = false,
@SerialName("html_url") val htmlUrl: String = "",
val head: GiteaPrBranchInfo? = null,
val base: GiteaPrBranchInfo? = null,
)
/** issue 依赖项(前置任务):只用到 number。 */
@Serializable
data class GiteaIssueDependency(
val number: Long = 0,
)
/** webhook 的 config 子对象:url + content_type。 */
@Serializable
data class GiteaHookConfig(
val url: String = "",
@SerialName("content_type") val contentType: String = "",
)
/** Gitea webhook:创建后只需回 idconfig 保留备查。 */
@Serializable
data class GiteaHook(
val id: Long = 0,
val type: String = "",
val active: Boolean = false,
val config: GiteaHookConfig? = null,
)
/** commit 内嵌的作者署名(name/email/date)。 */
@Serializable
data class GiteaCommitAuthor(
val name: String = "",
val email: String = "",
val date: String = "",
)
/** commit 对象的核心载荷:message + author。 */
@Serializable
data class GiteaCommitInfo(
val message: String = "",
val author: GiteaCommitAuthor? = null,
)
/** commit 的父引用:取第一个父 sha 作 diff 左侧 ref。 */
@Serializable
data class GiteaCommitParent(
val sha: String = "",
)
/** 提交内单个文件改动:filename 给 diff 用,status 决定徽标颜色。 */
@Serializable
data class GiteaCommitFile(
val filename: String = "",
val status: String = "",
)
/**
* Gitea 仓库提交原始响应。`commit` 携带 message/author`parents` 取第一个作
* diff 左侧 ref`files` 在带 `files=true` 时返回改动清单。
*/
@Serializable
data class GiteaRepoCommit(
val sha: String = "",
val created: String = "",
val commit: GiteaCommitInfo? = null,
val author: GiteaUser? = null,
val parents: List<GiteaCommitParent>? = null,
val files: List<GiteaCommitFile>? = null,
)
@@ -0,0 +1,170 @@
package com.cruldra.superworkbench.gitea
import com.cruldra.superworkbench.model.Issue
import com.cruldra.superworkbench.model.IssueColumn
import com.cruldra.superworkbench.model.IssueState
import com.cruldra.superworkbench.model.IssueStateJson
import java.nio.file.Files
import java.nio.file.Path
/**
* 从工作区对应的 Gitea 仓库加载工单,并由「评论里的状态 JSON」恢复每张看板卡的列与
* 运行时元数据。对应源项目 `src/gitea/issueLoader.ts`(仅 giteaYouTrack 逻辑不迁移)。
*
* 流程:
* 1. `getCurrentUser` 取 login。
* 2. 顺序拉 `assigned_by=login` 与 `created_by=login` 两个 issue 列表,按 number 去重合并。
* 3. 拉全仓库评论 firehose,按 issue number 分组。
* 4. 每个 issue:从评论尾部取最近一条 state JSON。解析到 column 就用它;否则按
* issue.stateopen→todo / closed→done)兜底,并回写一条 column 评论持久化。
* 5. 非 done 工单顺序查 dependencies(取首个作前置)与 PR 实时合并状态。
*/
class IssueLoader(
private val api: GiteaApi,
/** 工作区根目录,用来把 worktreePath 解析成磁盘存在性;为 null 时 worktreeExists 留空。 */
private val workspaceRoot: Path? = null,
) {
/** 加载 [repoRef] 仓库内当前用户相关的全部工单。 */
fun loadIssues(repoRef: GiteaRepoRef): List<Issue> {
val owner = repoRef.owner
val repo = repoRef.repo
val user = api.getCurrentUser()
val assigned = api.listIssuesByFilter(owner, repo, "assigned_by", user.login)
val created = api.listIssuesByFilter(owner, repo, "created_by", user.login)
val merged = mergeIssues(assigned, created)
val allComments = api.listRepoComments(owner, repo)
val buckets = groupCommentsByIssue(allComments)
return merged.map { issue ->
val bucket = buckets[issue.number] ?: emptyList()
buildIssue(owner, repo, issue, bucket)
}
}
/**
* 由一个 Gitea issue 加上其评论桶组装出 [Issue]:解析 state JSON、缺 column 时兜底
* 并回写、计算 worktree 存在性、非 done 时查依赖与实时 PR 状态。
*/
private fun buildIssue(
owner: String,
repo: String,
issue: GiteaIssue,
comments: List<GiteaComment>,
): Issue {
val number = issue.number
val id = "$owner/$repo#$number"
val state: IssueState? = IssueStateStore.findLatestState(comments)
val fromComment = state?.column
val column: IssueColumn
if (fromComment != null) {
column = fromComment
} else {
column = defaultColumnForState(issue.state)
// 持久化默认列,后续加载即可直接命中;失败非致命,仍以计算出的列展示。
runCatching {
api.postIssueComment(owner, repo, number, IssueStateJson.encode(IssueState(column = column)))
}
}
// 仅 done 工单跳过 dependencies + 实时 PR 查询(其状态恒定,省往返)。
val isDone = column == IssueColumn.DONE
val prerequisite = if (isDone) null else firstDependency(owner, repo, number)
val live = if (isDone) null else fetchPrStatus(owner, repo, state?.pr)
val prMerged = live?.merged ?: state?.prMerged
val prMergedAt = live?.mergedAt
val worktreeExists = state?.worktreePath?.let { rel ->
workspaceRoot?.let { root ->
runCatching { Files.exists(root.resolve(rel)) }.getOrDefault(false)
}
}
return Issue(
id = id,
number = number.toInt(),
title = issue.title,
column = column,
sessionId = state?.sessionId,
profilePath = state?.profilePath,
testProfilePath = null,
specFile = state?.specFile,
planFile = state?.planFile,
prDiffFile = state?.prDiffFile,
pr = state?.pr,
prMerged = prMerged,
prMergedAt = prMergedAt,
branch = state?.branch,
worktreePath = state?.worktreePath,
worktreeExists = worktreeExists,
implementStatus = state?.implementStatus,
implementSessionId = state?.implementSessionId,
reviewSessionId = state?.reviewSessionId,
testSessionId = state?.testSessionId,
htmlUrl = issue.htmlUrl,
prerequisite = prerequisite,
color = state?.color,
autoReview = state?.autoReview,
)
}
/** 取首个前置依赖的 number;依赖被禁用/网络失败等一律兜底 null。 */
private fun firstDependency(owner: String, repo: String, index: Long): Int? =
runCatching { api.getDependencies(owner, repo, index).firstOrNull()?.number?.toInt() }
.getOrNull()
/**
* 实时查 PR 合并状态:无 pr 或 pr 非正整数返回 null;任何失败(网络/404/解析)兜底 null,
* 调用方退回 state JSON 持久化值。
*/
private fun fetchPrStatus(owner: String, repo: String, pr: String?): PrStatus? {
val index = pr?.toLongOrNull() ?: return null
if (index <= 0) return null
return runCatching {
val data = api.getPull(owner, repo, index)
PrStatus(merged = data.merged, mergedAt = data.mergedAt)
}.getOrNull()
}
private data class PrStatus(val merged: Boolean, val mergedAt: String?)
/** open→todo,其余(closed)→done,对齐源项目 `defaultColumnForState`。 */
private fun defaultColumnForState(state: String): IssueColumn =
if (state == "open") IssueColumn.TODO else IssueColumn.DONE
/** 按 number 去重合并多个 issue 列表,保留首次出现,对齐源项目 `mergeIssues`。 */
private fun mergeIssues(vararg lists: List<GiteaIssue>): List<GiteaIssue> {
val map = LinkedHashMap<Long, GiteaIssue>()
for (list in lists) {
for (issue in list) {
map.putIfAbsent(issue.number, issue)
}
}
return map.values.toList()
}
/**
* 按 issue number 把全仓库评论分组,组内按 created_at 升序。`issue_url` 无法解析出
* number 的评论被丢弃。对齐源项目 `groupCommentsByIssue`。
*/
private fun groupCommentsByIssue(comments: List<GiteaComment>): Map<Long, List<GiteaComment>> {
val buckets = LinkedHashMap<Long, MutableList<GiteaComment>>()
for (c in comments) {
val idx = indexFromIssueUrl(c.issueUrl) ?: continue
buckets.getOrPut(idx) { mutableListOf() }.add(c)
}
for (bucket in buckets.values) {
bucket.sortBy { it.createdAt }
}
return buckets
}
/** 从评论 `issue_url`(形如 `.../issues/42`)抽出 issue number,解析失败返回 null。 */
private fun indexFromIssueUrl(issueUrl: String): Long? {
if (issueUrl.isEmpty()) return null
return issueUrl.substringAfterLast('/').toLongOrNull()
}
}
@@ -0,0 +1,65 @@
package com.cruldra.superworkbench.gitea
import com.cruldra.superworkbench.model.IssueState
import com.cruldra.superworkbench.model.IssueStateJson
/**
* 读写 issue 评论里的工单状态 JSON,对应源项目 `src/gitea/stateJson.ts` 与
* `src/issues/stateRouter.ts` 的 gitea 分支(YouTrack 分支不迁移)。
*
* 约定:issue 的状态 blob 存在某条评论正文里。从评论列表尾部往前扫,第一条能被
* [IssueStateJson.decode] 成功解析的评论即视为当前 state——这样中途插入的普通文本/
* 审查/PR 自动关联评论不会顶替真正的 state 评论。
*/
object IssueStateStore {
/**
* 取 issue 评论列表,从尾往前找第一条能解析为 [IssueState] 的评论并返回。
* 找不到返回 null。对应 `readStateJsonComment` 的 gitea 分支。
*/
fun readIssueState(
api: GiteaApi,
owner: String,
repo: String,
issueNumber: Long,
): IssueState? {
val comments = api.listIssueComments(owner, repo, issueNumber)
return findLatestState(comments)
}
/**
* 读现有 state,用 [patch] 经 [IssueStateJson.merge] 合并后 encode 写回。
*
* 与源项目 `mergeStateJsonComment` 一致:合并结果始终以**新评论**形式 post,由
* loader/读取侧的「从尾往前取最近一条 state 评论」语义保证后写优先;GiteaApi 无需
* 编辑评论方法。无现有 state 时从空 [IssueState] 起步。
*/
fun mergeIssueState(
api: GiteaApi,
owner: String,
repo: String,
issueNumber: Long,
patch: IssueState,
) {
val current = readIssueState(api, owner, repo, issueNumber) ?: IssueState()
val merged = IssueStateJson.merge(current, patch)
api.postIssueComment(owner, repo, issueNumber, IssueStateJson.encode(merged))
}
/**
* 从尾往前找最近一条 state 评论,找不到返回 null。
*
* 仅当解析出的 [IssueState] 至少携带一个非空已知字段时才认定为 state——对齐源项目
* `hasKnownField` / `'column' in candidate` 的判定,避免把 `{}` 或普通 JSON 内容
* 评论(lenient 解码会得到全空 [IssueState])误判为 state 而顶替真正的状态。
*/
internal fun findLatestState(comments: List<GiteaComment>): IssueState? {
val empty = IssueState()
for (i in comments.indices.reversed()) {
val body = comments[i].body.trim()
if (body.isEmpty()) continue
val decoded = IssueStateJson.decode(body) ?: continue
if (decoded != empty) return decoded
}
return null
}
}
@@ -0,0 +1,17 @@
package com.cruldra.superworkbench.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** 实施流程生命周期状态。 */
@Serializable
enum class ImplementStatus {
@SerialName("running")
RUNNING,
@SerialName("done")
DONE,
@SerialName("failed")
FAILED,
}
@@ -0,0 +1,66 @@
package com.cruldra.superworkbench.model
import kotlinx.serialization.Serializable
/**
* 看板上的运行时工单模型,镜像 VSCode 的 `Issue` 接口(仅 Gitea,已删除 YouTrack 字段)。
*
* 运行时模型比持久化的 [IssueState] 多若干字段(标题、列、worktree 探测结果、各 tab 是否打开等)。
*/
@Serializable
data class Issue(
/** 稳定标识:`${owner}/${repo}#${index}`。 */
val id: String,
/** 等于 Gitea issue number / tea 的 `index` 字段。 */
val number: Int,
val title: String,
val column: IssueColumn,
/** 讨论/头脑风暴会话的 Claude Code session id,用于 resume。 */
val sessionId: String? = null,
/** 创建工单时使用的 Claude settings 配置文件绝对路径,resume 时作为 --settings 传入。 */
val profilePath: String? = null,
/** 测试会话专用的 Claude 配置文件,独立于实施会话的 profilePath;留空回退到 profilePath 再回退默认。 */
val testProfilePath: String? = null,
/** spec 文档路径,相对 workspace,位于 docs/superpowers/specs 下的 .md 文件。 */
val specFile: String? = null,
/** plan 文档路径,相对 workspace,位于 docs/superpowers/plans 下的 .md 文件。 */
val planFile: String? = null,
/** PR 变更摘要文件路径,相对 workspace。 */
val prDiffFile: String? = null,
/** 关联的 PR number(字符串形式,webhook 触发后写入)。 */
val pr: String? = null,
/** 关联 PR 是否已合并。 */
val prMerged: Boolean? = null,
/** PR 合并时间(ISO 8601,来自 PR API 的 merged_at)。 */
val prMergedAt: String? = null,
/** 实施分支名,例如 `feature/<hash>`。 */
val branch: String? = null,
/** 实施 worktree 的 workspace 相对路径。 */
val worktreePath: String? = null,
/** worktree 路径是否仍存在于磁盘,由 loader 计算。 */
val worktreeExists: Boolean? = null,
/** 实施流程生命周期状态。 */
val implementStatus: ImplementStatus? = null,
/** 实施会话的 Claude Code session id(独立于讨论会话 sessionId)。 */
val implementSessionId: String? = null,
/** 后端无关的审查会话 idv1 存 codex thread id)。 */
val reviewSessionId: String? = null,
/** 测试会话的 Claude Code session id。 */
val testSessionId: String? = null,
/** Gitea issue 页面的浏览器 URL。 */
val htmlUrl: String,
/** 前置任务 issue numberGitea dependencies 取第一个)。 */
val prerequisite: Int? = null,
/** Terminal/tab 配色(一个 terminal.ansi* ThemeColor key),首次开会话时固化。 */
val color: String? = null,
/** 本工单是否启用自动审查(覆盖全局 autoReview 设置)。 */
val autoReview: Boolean? = null,
/** 头脑风暴终端 tab 是否在当前窗口存活。 */
val brainstormTabOpen: Boolean? = null,
/** 实施终端 tab 是否在当前窗口存活。 */
val implementTabOpen: Boolean? = null,
/** 审查终端 tab 是否在当前窗口存活。 */
val reviewTabOpen: Boolean? = null,
/** 测试终端 tab 是否在当前窗口存活。 */
val testTabOpen: Boolean? = null,
)
@@ -0,0 +1,20 @@
package com.cruldra.superworkbench.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** 看板列。对齐 VSCode 的 `IssueColumn` 联合类型。 */
@Serializable
enum class IssueColumn {
@SerialName("todo")
TODO,
@SerialName("in-progress")
IN_PROGRESS,
@SerialName("review")
REVIEW,
@SerialName("done")
DONE,
}
@@ -0,0 +1,45 @@
package com.cruldra.superworkbench.model
import kotlinx.serialization.Serializable
/**
* 持久化在 issue 最后一条 comment 里的 JSON blob,用来恢复工单在看板上的列、关联的
* spec/plan/PR/branch/worktree、各会话 id 等运行时状态。
*
* 对齐 `schemas/state-json.schema.json`,所有字段均可空,缺失字段由 loader 兜底。
*/
@Serializable
data class IssueState(
/** 看板列 id;缺失时按 issue.state 兜底为 todo/done。 */
val column: IssueColumn? = null,
/** 讨论/头脑风暴会话的 Claude Code session id。 */
val sessionId: String? = null,
/** 实施阶段的 Claude Code session id,与讨论 sessionId 隔离。 */
val implementSessionId: String? = null,
/** 审查阶段的会话 idv1 存 codex thread id)。 */
val reviewSessionId: String? = null,
/** 测试阶段的 Claude Code session id。 */
val testSessionId: String? = null,
/** 创建工单时使用的 Claude settings 配置文件绝对路径。 */
val profilePath: String? = null,
/** spec 文档路径,相对 workspace。 */
val specFile: String? = null,
/** plan 文档路径,相对 workspace。 */
val planFile: String? = null,
/** PR 变更摘要文件路径,相对 workspace。 */
val prDiffFile: String? = null,
/** 关联的 PR number(字符串形式)。 */
val pr: String? = null,
/** 关联 PR 是否已合并。 */
val prMerged: Boolean? = null,
/** 实施分支名。 */
val branch: String? = null,
/** 实施 worktree 的 workspace 相对路径。 */
val worktreePath: String? = null,
/** 实施流程生命周期状态。 */
val implementStatus: ImplementStatus? = null,
/** Terminal/tab 配色(一个 terminal.ansi* ThemeColor key)。 */
val color: String? = null,
/** 本工单是否启用自动审查。 */
val autoReview: Boolean? = null,
)
@@ -0,0 +1,44 @@
package com.cruldra.superworkbench.model
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* 工单状态 JSON 的编解码器:从 issue comment 正文解析 [IssueState]、序列化回字符串、
* 以及把 patch 合并进 base。
*/
object IssueStateJson {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
explicitNulls = false
isLenient = true
}
/** 解析 comment 正文为 [IssueState],解析失败返回 null。 */
fun decode(commentBody: String): IssueState? =
runCatching { json.decodeFromString<IssueState>(commentBody) }.getOrNull()
/** 把 [state] 序列化为 JSON 字符串(省略默认/空字段)。 */
fun encode(state: IssueState): String = json.encodeToString(state)
/** 用 [patch] 的非空字段覆盖 [base],返回合并后的新 state。 */
fun merge(base: IssueState, patch: IssueState): IssueState = base.copy(
column = patch.column ?: base.column,
sessionId = patch.sessionId ?: base.sessionId,
implementSessionId = patch.implementSessionId ?: base.implementSessionId,
reviewSessionId = patch.reviewSessionId ?: base.reviewSessionId,
testSessionId = patch.testSessionId ?: base.testSessionId,
profilePath = patch.profilePath ?: base.profilePath,
specFile = patch.specFile ?: base.specFile,
planFile = patch.planFile ?: base.planFile,
prDiffFile = patch.prDiffFile ?: base.prDiffFile,
pr = patch.pr ?: base.pr,
prMerged = patch.prMerged ?: base.prMerged,
branch = patch.branch ?: base.branch,
worktreePath = patch.worktreePath ?: base.worktreePath,
implementStatus = patch.implementStatus ?: base.implementStatus,
color = patch.color ?: base.color,
autoReview = patch.autoReview ?: base.autoReview,
)
}
@@ -0,0 +1,19 @@
package com.cruldra.superworkbench.model
import kotlinx.serialization.Serializable
/** PR 提交的精简视图(列表渲染用)。 */
@Serializable
data class PrCommit(
val sha: String,
val message: String,
val authorName: String,
val date: String,
)
/** 提交内单个文件改动;status 取 added/modified/deleted/renamed/copied 等。 */
@Serializable
data class PrCommitFile(
val path: String,
val status: String,
)
@@ -0,0 +1,57 @@
package com.cruldra.superworkbench.notifications
import com.intellij.ide.BrowserUtil
import com.intellij.notification.NotificationAction
import com.intellij.notification.NotificationGroupManager
import com.intellij.notification.NotificationType
import com.intellij.openapi.project.Project
/**
* 统一通知系统。
*
* 源 VSCode 项目里存在两套通知:
* - React webview 内置的 ToastStack (toast/show、toast/dismiss);
* - VSCode 原生通知 (window.showInformationMessage 等)。
*
* 移植后全部统一走 JetBrains IDE 通知系统 (NotificationGroup 气泡 + Event Log),
* 不再有 webview 内置 toast。所有气泡挂在 plugin.xml 已注册的
* notificationGroup id="Superworkbench" (displayType=BALLOON) 上。
*
* level 到 NotificationType 的映射:
* - info、success 映射为 NotificationType.INFORMATION;
* - error 映射为 NotificationType.ERROR。
*
* 备注:源里的 spinner (进行中 toast) 和 dismissOnTimer 在 IDE 气泡里不直接对应。
* spinner 类需求未来用 ProgressIndicator 或后台任务处理,本模块不实现 spinner,
* 只做即时气泡。
*/
object Notifications {
private const val GROUP_ID = "Superworkbench"
private fun group() =
NotificationGroupManager.getInstance().getNotificationGroup(GROUP_ID)
private fun show(project: Project?, title: String, message: String, type: NotificationType) {
group().createNotification(title, message, type).notify(project)
}
fun info(project: Project?, message: String, title: String = "") {
show(project, title, message, NotificationType.INFORMATION)
}
fun success(project: Project?, message: String, title: String = "") {
show(project, title, message, NotificationType.INFORMATION)
}
fun error(project: Project?, message: String, title: String = "") {
show(project, title, message, NotificationType.ERROR)
}
/** 带链接的即时通知,点击 linkLabel 在外部浏览器打开 url。 */
fun info(project: Project?, message: String, linkLabel: String, url: String, title: String = "") {
group().createNotification(title, message, NotificationType.INFORMATION)
.addAction(NotificationAction.createSimple(linkLabel) { BrowserUtil.browse(url) })
.notify(project)
}
}
@@ -0,0 +1,173 @@
package com.cruldra.superworkbench.session
import com.intellij.execution.configurations.GeneralCommandLine
import com.intellij.openapi.diagnostic.thisLogger
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.nio.charset.StandardCharsets
import java.util.concurrent.TimeUnit
/**
* 对一次性(headless`claude -p` 调用的薄封装,对应源项目 `src/cc/spawnClaude.ts`。
*
* 用 [GeneralCommandLine] 起一个 `claude -p "<prompt>" --output-format json` 进程,
* 5 分钟超时,解析 stdout 里的 JSON 取 `session_id`/`result`。返回 [SpawnResult]。
*
* 与源项目一致地剔除会导致 headless 鉴权失败或嵌套会话静默退出的环境变量:
* - 全部 `ANTHROPIC_*`:让 `--settings` profile(或 ~/.claude 订阅 OAuth)成为
* provider/鉴权的唯一来源,避免宿主 env 残留的 BASE_URL/TOKEN 覆盖 profile 导致 403。
* - `CLAUDECODE` / `CLAUDE_CODE_ENTRYPOINT` / `CLAUDE_CODE_SESSION_ID` /
* `CLAUDE_CODE_SESSION`:从父 Claude 会话继承会让子进程当成嵌套会话直接静默退出。
*
* 文本场景已实现;图片场景(stream-json + stdin NDJSON)见 [spawnWithImages] 的 TODO。
*/
object ClaudeSpawner {
private const val DEFAULT_TIMEOUT_MS = 300_000L
private val json = Json { ignoreUnknownKeys = true }
private val NESTED_GUARDS = setOf(
"CLAUDECODE",
"CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_SESSION_ID",
"CLAUDE_CODE_SESSION",
)
/**
* 一次性调用 `claude -p`。文本场景必须用本方法。
*
* @param prompt 一次性提示词。
* @param cwd 工作目录绝对路径。
* @param profilePath 可空,存在则作为 `--settings <path>` 传入。
* @param bare 是否加 `--bare`。
* @param timeoutMs 超时毫秒,默认 5 分钟。
* @return [SpawnResult],失败或超时时 sessionId/result 为 null。
*/
fun spawn(
prompt: String,
cwd: String,
profilePath: String? = null,
bare: Boolean = false,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
): SpawnResult {
val cmd = GeneralCommandLine("claude").apply {
if (bare) addParameter("--bare")
addParameter("--dangerously-skip-permissions")
if (!profilePath.isNullOrBlank()) {
addParameter("--settings")
addParameter(profilePath)
}
addParameter("-p")
addParameter(prompt)
addParameter("--output-format")
addParameter("json")
setWorkDirectory(cwd)
charset = StandardCharsets.UTF_8
applyChildEnv(this)
}
return run(cmd, timeoutMs)
}
/**
* 图片场景:`claude -p --input-format stream-json --output-format stream-json`
* 把含 text + image 内容块的单行 NDJSON 喂给 stdin。
*
* TODO(images): 本模块当前只迁移文本路径。图片路径需要往子进程 stdin 写一行
* Anthropic Messages API 形态的 NDJSON(参见源 `buildStreamJsonLine`),再从
* NDJSON 输出尾部回溯解析 result。看板首版工作流(brainstorm/implement/test/review
* 不依赖图片输入,故此处留空抛异常,待图片需求落地时实现。
*/
@Suppress("UNUSED_PARAMETER")
fun spawnWithImages(
prompt: String,
cwd: String,
images: List<ClaudeImage>,
profilePath: String? = null,
bare: Boolean = false,
timeoutMs: Long = DEFAULT_TIMEOUT_MS,
): SpawnResult {
throw UnsupportedOperationException("图片 stream-json 路径尚未迁移(见 ClaudeSpawner.spawnWithImages 的 TODO")
}
private fun applyChildEnv(cmd: GeneralCommandLine) {
cmd.environment.clear()
for ((k, v) in System.getenv()) {
if (k.startsWith("ANTHROPIC_")) continue
if (k in NESTED_GUARDS) continue
cmd.environment[k] = v
}
}
private fun run(cmd: GeneralCommandLine, timeoutMs: Long): SpawnResult {
val process = try {
cmd.createProcess()
} catch (e: Exception) {
thisLogger().warn("claude 进程启动失败(请确认已安装并在 PATH 中)", e)
return SpawnResult(null, null)
}
val stdout = StringBuilder()
val outThread = Thread {
process.inputStream.bufferedReader(StandardCharsets.UTF_8).forEachLine { stdout.appendLine(it) }
}
// stderr 单独排空,避免缓冲区写满导致子进程阻塞。
val errThread = Thread { process.errorStream.bufferedReader(StandardCharsets.UTF_8).readText() }
outThread.start()
errThread.start()
val finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS)
if (!finished) {
process.destroyForcibly()
thisLogger().warn("claude 调用超时(${timeoutMs / 1000}s")
return SpawnResult(null, null)
}
outThread.join(2_000)
errThread.join(2_000)
if (process.exitValue() != 0) {
thisLogger().warn("claude 退出码非零 (${process.exitValue()})")
return SpawnResult(null, null)
}
return parsePayload(stdout.toString())
}
/**
* 从 stdout 解析 `{ session_id, result }`。先整体解析;失败则按 NDJSON 从末尾
* 往前找第一条形态匹配的行(对齐源 `extractClaudePayload`)。
*/
private fun parsePayload(stdout: String): SpawnResult {
val trimmed = stdout.trim()
if (trimmed.isEmpty()) return SpawnResult(null, null)
tryParse(trimmed)?.let { return it }
val lines = trimmed.lines()
for (i in lines.indices.reversed()) {
val line = lines[i].trim()
if (line.isEmpty()) continue
tryParse(line)?.let { return it }
}
return SpawnResult(null, null)
}
private fun tryParse(text: String): SpawnResult? = try {
val obj = json.parseToJsonElement(text) as? JsonObject ?: return null
val sid = obj["session_id"]?.jsonPrimitive?.content
?: obj["sessionId"]?.jsonPrimitive?.content
val result = obj["result"]?.jsonPrimitive?.content
if (sid != null) SpawnResult(sid, result) else null
} catch (_: Exception) {
null
}
}
/** 一张待随 prompt 发送的图片(原始 base64,无 data URI 前缀)。 */
data class ClaudeImage(
/** 如 "image/png" / "image/jpeg" / "image/webp" / "image/gif"。 */
val mediaType: String,
/** 原始 base64(不含 `data:...;base64,` 前缀)。 */
val base64: String,
)
/** 一次性 `claude -p` 调用的解析结果;失败/超时时字段为 null。 */
data class SpawnResult(
val sessionId: String?,
val result: String?,
)
@@ -0,0 +1,48 @@
package com.cruldra.superworkbench.session
import com.cruldra.superworkbench.settings.SettingsService
/**
* 从 [SettingsService] 的「有效 prompt」取模板并做占位符替换,对应源项目
* `src/cc/prompts.ts`。
*
* 替换是纯字符串全局替换(无模板引擎):用户若删掉占位符,替换即 no-op,那是用户的事。
*/
object SessionPrompts {
/**
* brainstorm 提示词,替换 `{userRequest}` `{nonce}`。给定图片路径时追加一段
* 「参考图片」清单(对齐源 `getBrainstormPrompt`)。
*/
fun brainstorm(userRequest: String, nonce: String, imagePaths: List<String> = emptyList()): String {
var out = SettingsService.getInstance().effectiveBrainstormPrompt
.replace("{userRequest}", userRequest)
.replace("{nonce}", nonce)
if (imagePaths.isNotEmpty()) {
val lines = imagePaths.joinToString("\n") { "- $it" }
out += "\n\n参考图片(请用 Read 工具查看):\n$lines"
}
return out
}
/**
* 续聊已有工单的 brainstorm 提示词,替换 `{issueNumber}`(对齐源
* `getBrainstormContinuePrompt`)。
*/
fun brainstormContinue(issueNumber: Int): String =
SettingsService.getInstance().effectiveBrainstormContinuePrompt
.replace("{issueNumber}", issueNumber.toString())
/**
* implement-plan 提示词,替换 `{planFile}` `{issueNumber}`(对齐源
* `getImplementPlanPrompt`)。
*/
fun implementPlan(planFile: String, issueNumber: Int): String =
SettingsService.getInstance().effectiveImplementPlanPrompt
.replace("{planFile}", planFile)
.replace("{issueNumber}", issueNumber.toString())
/** review 提示词,替换 `{prNumber}`(对齐源 `getReviewPrompt`)。 */
fun review(prNumber: String): String =
SettingsService.getInstance().effectiveReviewPrompt
.replace("{prNumber}", prNumber)
}
@@ -0,0 +1,357 @@
package com.cruldra.superworkbench.session
import com.cruldra.superworkbench.git.WorktreeService
import com.cruldra.superworkbench.gitea.GiteaApi
import com.cruldra.superworkbench.gitea.GiteaRepoRef
import com.cruldra.superworkbench.gitea.GitRemote
import com.cruldra.superworkbench.gitea.IssueStateStore
import com.cruldra.superworkbench.model.ImplementStatus
import com.cruldra.superworkbench.model.Issue
import com.cruldra.superworkbench.model.IssueColumn
import com.cruldra.superworkbench.model.IssueState
import com.cruldra.superworkbench.notifications.Notifications
import com.cruldra.superworkbench.settings.GiteaTokenStore
import com.cruldra.superworkbench.settings.SettingsService
import com.cruldra.superworkbench.webhook.WebhookCoordinator
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
/**
* 会话编排高层服务,对应源项目 `src/panel/handlers/sessions.ts` 的高层流程(务实精简版)。
*
* 组合各底层模块完成一次会话生命周期:
* 读 profile/prompt → [ClaudeSpawner] 起一次性会话拿 sessionId → [TerminalManager]
* 开/复用终端跑 `claude --resume <sid>`(审查用 codex)→ [SessionWatcher] 监听 jsonl
* → 经 [IssueStateStore.mergeIssueState] 把 sessionId 写回工单状态。
*
* in-flight 锁 [resumeInFlight] 模拟源里的 `resumeInFlight`,防快速重复点击重复建终端。
*
* 边界(重要):完整 implement 流程涉及 git worktree 创建与 webhook 注册——这两块属于
* 后续模块(worktree/webhook 尚未迁移)。本模块把它们留成清晰的 TODO 扩展点
* [createWorktree] / [registerImplementWebhook]),保证独立编译通过,不 import 不存在的类。
*/
@Service(Service.Level.PROJECT)
class SessionService(private val project: Project) {
private val terminals get() = TerminalManager.getInstance(project)
/** in-flight 锁,key=`${issueNumber}:${kind.name}`,对齐源 resumeInFlight。 */
private val resumeInFlight = mutableSetOf<String>()
// ---- 公开高层入口 ----------------------------------------------------
/**
* 为「外部创建、尚无 sessionId」的工单启动一个全新规划(brainstorm 续聊)会话。
* cwd = 工作区根目录(无 worktree)。对齐源 `handleStartBrainstormSession`。
*/
fun startBrainstorm(issue: Issue) {
runLocked(issue.number, SessionKind.BRAINSTORM) {
val ctx = resolveRepo() ?: return@runLocked
val cwd = ctx.workspaceRoot.toString()
val profile = issue.profilePath
val prompt = SessionPrompts.brainstormContinue(issue.number)
val term = terminals.openOrReuse(issue.number, SessionKind.BRAINSTORM, cwd)
terminals.runCommand(term, buildClaudeStartCommand(prompt, profile, effortHigh = false))
watchAndPersist(ctx, issue.number, cwd) { sid -> IssueState(sessionId = sid) }
Notifications.info(project, "已启动 #${issue.number} 规划会话")
}
}
/**
* 启动一个测试会话:PR 合并后让 cc 先了解代码再告诉用户怎么测。cwd 优先用 worktree
* (存在时),否则退回工作区根目录。对齐源 `handleStartTestSession`(精简)。
*/
fun startTest(issue: Issue) {
runLocked(issue.number, SessionKind.TEST) {
val ctx = resolveRepo() ?: return@runLocked
val cwd = resolveTestCwd(ctx.workspaceRoot, issue.worktreePath)
val inWorktree = cwd != ctx.workspaceRoot.toString()
if (!inWorktree && issue.pr.isNullOrEmpty()) {
Notifications.error(project, "该工单无已合并 PR,无法在主分支启动测试会话")
return@runLocked
}
val profile = issue.testProfilePath ?: issue.profilePath
val prompt = if (inWorktree) {
"当前工单的改动代码就在这个 worktree 工作目录里,无需用 tea 拉取,直接开始:怎么在本地测试以确保本次改动符合预期?"
} else {
"我已经合并了#${issue.pr}号pr,你先通过tea熟悉本次pr改动的代码,然后,怎么在本地测试以确保改动符合预期?"
}
val term = terminals.openOrReuse(issue.number, SessionKind.TEST, cwd)
terminals.runCommand(term, buildClaudeStartCommand(prompt, profile, effortHigh = false))
watchAndPersist(ctx, issue.number, cwd) { sid -> IssueState(testSessionId = sid) }
Notifications.info(project, "已启动 #${issue.number} 测试会话")
}
}
/**
* 恢复一个已有会话:开/复用对应终端跑 `claude --resume <sid>`(审查用 codex resume)。
* implement/test 会话在 worktree 里跑(worktree 已清理则退回根目录)。
* 对齐源 `handleResumeSession` / `handleResumeTestSession` / `handleResumeReviewSession`。
*
* @param issue 目标工单。
* @param kind 要恢复的会话类型,决定 sessionId 来源与 cwd 解析。
*/
fun resumeSession(issue: Issue, kind: SessionKind) {
if (kind == SessionKind.REVIEW) {
resumeReview(issue)
return
}
runLocked(issue.number, kind) {
val ctx = resolveRepo() ?: return@runLocked
val sessionId = sessionIdFor(issue, kind)
if (sessionId.isNullOrEmpty()) {
Notifications.error(project, "工单 #${issue.number} 没有可恢复的${kind.displayName}会话 id")
return@runLocked
}
val cwd = when (kind) {
SessionKind.BRAINSTORM -> ctx.workspaceRoot.toString()
else -> resolveTestCwd(ctx.workspaceRoot, issue.worktreePath)
}
val profile = issue.profilePath
val effortHigh = kind == SessionKind.IMPLEMENT
val term = terminals.openOrReuse(issue.number, kind, cwd)
terminals.runCommand(term, buildClaudeResumeCommand(sessionId, profile, effortHigh))
}
}
/**
* 恢复审查会话:审查用 codex(不是 claude),`codex resume <threadId>`。
* 必须在 worktree 里跑;worktree 不存在则拒绝。对齐源 `handleResumeReviewSession`。
*/
fun resumeReview(issue: Issue) {
runLocked(issue.number, SessionKind.REVIEW) {
val ctx = resolveRepo() ?: return@runLocked
val sessionId = issue.reviewSessionId
if (sessionId.isNullOrEmpty()) {
Notifications.error(project, "工单 #${issue.number} 没有可恢复的审查会话 id")
return@runLocked
}
val rel = issue.worktreePath
if (rel.isNullOrEmpty()) {
Notifications.error(project, "审查会话无法恢复 #${issue.number}worktree 路径未记录")
return@runLocked
}
val worktreeAbs = ctx.workspaceRoot.resolve(rel)
if (!Files.exists(worktreeAbs)) {
Notifications.error(project, "审查会话无法恢复 #${issue.number}worktree 不存在 $worktreeAbs")
return@runLocked
}
val term = terminals.openOrReuse(issue.number, SessionKind.REVIEW, worktreeAbs.toString())
terminals.runCommand(term, buildCodexResumeCommand(sessionId))
}
}
/**
* 启动完整实施流程的入口。先建 feature 分支 + git worktree[createWorktree]),失败/冲突
* 则中止;worktree 就绪后注册 webhook[registerImplementWebhook] 仍为占位,待 webhook 模块),
* 再在 worktree 里起 claude 实施会话并监听捕获 implementSessionId。
*
* webhook 那一步当前为 no-opworktree 已真正接入(见 [WorktreeService])。
*/
fun startImplement(issue: Issue, planFile: String) {
val ctx = resolveRepo() ?: return
val worktreePath = createWorktree(ctx, issue.number, planFile) ?: return
registerImplementWebhook(ctx, issue.number, WorktreeService.computeBranchName(planFile))
val cwd = worktreePath.toString()
val prompt = SessionPrompts.implementPlan(planFile, issue.number)
val term = terminals.openOrReuse(issue.number, SessionKind.IMPLEMENT, cwd)
terminals.runCommand(term, buildClaudeStartCommand(prompt, issue.profilePath, effortHigh = true))
watchAndPersist(ctx, issue.number, cwd) { sid -> IssueState(implementSessionId = sid) }
Notifications.info(project, "已开始实施 #${issue.number}")
}
// ---- worktree / webhook 接入 -----------------------------------------
/**
* 创建 feature 分支 + git worktree,返回 worktree 绝对路径;失败/冲突时通知并返回 null。
* 对齐源 `handleImplement` 里的 feature-hash 预检(worktree 目录已存在则拒绝)+ `createWorktree`
* + 成功后把 branch/worktreePath/列/状态 落地到工单 state JSON。
*
* branch=`feature/<sha256(planFile)[:8]>`worktree 路径走 [WorktreeService] 默认模板。
*/
private fun createWorktree(ctx: RepoContext, issueNumber: Int, planFile: String): Path? {
val feature = WorktreeService.computeFeatureHash(planFile)
val branch = WorktreeService.computeBranchName(planFile)
val worktreePath = WorktreeService.computeWorktreePath(ctx.workspaceRoot, feature)
if (Files.exists(worktreePath)) {
Notifications.error(project, "feature $feature 的 worktree 已存在,请先清理:$worktreePath")
return null
}
val result = WorktreeService.createWorktree(ctx.workspaceRoot, branch, worktreePath, issueNumber)
if (result.isFailure) {
Notifications.error(project, result.exceptionOrNull()?.message ?: "git worktree add 失败 #$issueNumber")
return null
}
// 把 branch / worktreePathworkspace 相对)/ 列 / 状态写回工单 state JSON。对齐源 mergeIssueState。
val relativeWorktreePath = runCatching { ctx.workspaceRoot.relativize(worktreePath).toString() }
.getOrDefault(worktreePath.toString())
runCatching {
IssueStateStore.mergeIssueState(
ctx.api, ctx.repoRef.owner, ctx.repoRef.repo, issueNumber.toLong(),
IssueState(
column = IssueColumn.IN_PROGRESS,
branch = branch,
worktreePath = relativeWorktreePath,
implementStatus = ImplementStatus.RUNNING,
),
)
}.onFailure { thisLogger().warn("写回 worktree state 失败 (#$issueNumber)", it) }
return worktreePath
}
/**
* 启动 webhook 协调器并为本次实施的 feature 分支注册一个 PR→issue 回写钩子。
* 对齐源 `panel/handlers/sessions.ts` 的 handleImplement:确认协调器在设置端口监听
* [WebhookCoordinator.ensureStarted]),再调 [GiteaApi.createHook] 注册指向
* `http://<可达地址>:<webhookPort>/webhook` 的 webhookcontent_type=json、
* events=pull_request、branch_filter=branch),hookId 交协调器持久化以便后续按分支去重清理。
*
* 任一步失败仅通知并放行——webhook 不是实施流程的硬前置(PR 落地后用户仍可手动恢复)。
*/
private fun registerImplementWebhook(ctx: RepoContext, issueNumber: Int, branch: String) {
val coordinator = WebhookCoordinator.getInstance(project)
runCatching {
coordinator.ensureStarted()
val port = SettingsService.getInstance().state.webhookPort
val url = "http://${reachableHost()}:$port/webhook"
val hook = ctx.api.createHook(ctx.repoRef.owner, ctx.repoRef.repo, url, branch)
coordinator.rememberHook(branch, hook.id)
thisLogger().info("已注册 webhook hookId=${hook.id} url=$url branch=$branch (#$issueNumber)")
}.onFailure {
thisLogger().warn("注册 webhook 失败 (#$issueNumber)", it)
Notifications.error(project, "webhook 注册失败(不影响实施):${it.message}")
}
}
/**
* 解析 Gitea 可回调到本机的地址:优先取首个非回环 IPv4 LAN 地址,取不到回退
* `localhost`。webhook 端口的 HTTP 服务监听全网卡([WebhookServer] 绑 0.0.0.0)。
*/
private fun reachableHost(): String =
runCatching {
java.net.NetworkInterface.getNetworkInterfaces().asSequence()
.filter { it.isUp && !it.isLoopback }
.flatMap { it.inetAddresses.asSequence() }
.filterIsInstance<java.net.Inet4Address>()
.firstOrNull { !it.isLoopbackAddress && it.isSiteLocalAddress }
?.hostAddress
}.getOrNull() ?: "localhost"
// ---- 命令构造 --------------------------------------------------------
/** 起新 claude 会话的命令(首条 prompt 模式)。 */
private fun buildClaudeStartCommand(prompt: String, profilePath: String?, effortHigh: Boolean): String {
val effort = if (effortHigh) " --effort high" else ""
val settings = if (!profilePath.isNullOrBlank()) " --settings '$profilePath'" else ""
return "claude$effort --dangerously-skip-permissions$settings " +
"--system-prompt=\"\$(serena prompts print-cc-system-prompt-override)\" '$prompt'"
}
/** `claude --resume <sid>` 命令。 */
private fun buildClaudeResumeCommand(sessionId: String, profilePath: String?, effortHigh: Boolean): String {
val effort = if (effortHigh) " --effort high" else ""
val settings = if (!profilePath.isNullOrBlank()) " --settings '$profilePath'" else ""
return "claude$effort --dangerously-skip-permissions$settings " +
"--system-prompt=\"\$(serena prompts print-cc-system-prompt-override)\" --resume $sessionId"
}
/** `codex resume <threadId>` 命令(审查后端用 codex)。 */
private fun buildCodexResumeCommand(threadId: String): String =
"codex resume -c model_reasoning_effort=xhigh --dangerously-bypass-approvals-and-sandbox $threadId"
// ---- 内部辅助 --------------------------------------------------------
private fun sessionIdFor(issue: Issue, kind: SessionKind): String? = when (kind) {
SessionKind.BRAINSTORM -> issue.sessionId
SessionKind.IMPLEMENT -> issue.implementSessionId
SessionKind.TEST -> issue.testSessionId
SessionKind.REVIEW -> issue.reviewSessionId
}
/** worktree 还在就用 worktree,已清理则回退主工作区。对齐源 resolveTestSessionCwd。 */
private fun resolveTestCwd(workspaceRoot: Path, worktreePathRel: String?): String {
if (!worktreePathRel.isNullOrEmpty()) {
val abs = workspaceRoot.resolve(worktreePathRel)
if (Files.exists(abs)) return abs.toString()
}
return workspaceRoot.toString()
}
/**
* 后台监听 cwd 派生的 projects 目录捕获新 session jsonl,捕到后用 [patchFor] 构造
* patch 经 [IssueStateStore.mergeIssueState] 写回工单状态。监听阻塞,放线程池跑。
*/
private fun watchAndPersist(ctx: RepoContext, issueNumber: Int, cwd: String, patchFor: (String) -> IssueState) {
val projDir = SessionWatcher.projectsDirFor(cwd)
runCatching { Files.createDirectories(projDir) }
ApplicationManager.getApplication().executeOnPooledThread {
val sid = SessionWatcher.pollForNewSession(projDir)
if (sid == null) {
thisLogger().warn("会话监听超时 (#$issueNumber)")
return@executeOnPooledThread
}
thisLogger().info("已捕获会话 $sid (#$issueNumber)")
runCatching {
IssueStateStore.mergeIssueState(ctx.api, ctx.repoRef.owner, ctx.repoRef.repo, issueNumber.toLong(), patchFor(sid))
}.onFailure { thisLogger().warn("写回 sessionId 失败 (#$issueNumber)", it) }
}
}
/** in-flight 锁包装:同一 (issueNumber, kind) 正在进行时忽略重入。 */
private fun runLocked(issueNumber: Int, kind: SessionKind, block: () -> Unit) {
val key = "$issueNumber:${kind.name}"
synchronized(resumeInFlight) {
if (key in resumeInFlight) {
thisLogger().info("$key 已在进行中,忽略重入")
return
}
resumeInFlight.add(key)
}
try {
block()
} finally {
synchronized(resumeInFlight) { resumeInFlight.remove(key) }
}
}
/** 解析当前工作区对应的 Gitea 仓库 + token,构造 [GiteaApi]。失败时通知并返回 null。 */
private fun resolveRepo(): RepoContext? {
val root = project.basePath
if (root == null) {
Notifications.error(project, "请先打开一个工作区文件夹")
return null
}
val workspaceRoot = Paths.get(root)
val repoRef = GitRemote.detect(workspaceRoot)
if (repoRef == null) {
Notifications.error(project, "当前工作区没有 Gitea 远程仓库")
return null
}
val token = GiteaTokenStore.get(repoRef.host)
if (token.isNullOrEmpty()) {
Notifications.error(project, "请先完成 Gitea 配置")
return null
}
return RepoContext(workspaceRoot, repoRef, GiteaApi(repoRef.host))
}
private data class RepoContext(
val workspaceRoot: Path,
val repoRef: GiteaRepoRef,
val api: GiteaApi,
)
companion object {
fun getInstance(project: Project): SessionService =
project.getService(SessionService::class.java)
}
}
@@ -0,0 +1,91 @@
package com.cruldra.superworkbench.session
import com.intellij.openapi.diagnostic.thisLogger
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
/**
* 监听某个 claude projects 目录,等待下一个新出现的 `.jsonl` 文件,返回其 basename
* (去掉 `.jsonl`)作为 session id。对应源项目 `src/cc/sessionWatcher.ts`。
*
* 用法:cc 在工作目录里跑起来后,CLI 会把会话 transcript 写到
* `~/.claude/projects/<encoded-cwd>/<sid>.jsonl`。先对目录做快照,再轮询 diff,
* 第一个不在快照里的新文件 basename 即实现/讨论会话的 session id。
*
* 这里采用轮询(snapshot + 周期 readdir diff)而非 WatchService:在繁忙/共享的
* projects 目录里,文件系统事件容易漏报,轮询更可靠(对齐源 `pollForNewSession`)。
*/
object SessionWatcher {
/**
* 把绝对路径转成 claude 的 projects 目录编码(`/` 与 `.` 都替换成 `-`)。
* 对齐源 `encodeCwdForProjectsDir`。
*/
fun encodeCwd(absPath: String): String =
absPath.replace(Regex("[/.]"), "-")
/** 给定 cwd 绝对路径,返回其对应的 claude projects 子目录绝对路径。 */
fun projectsDirFor(absCwd: String): Path {
val home = System.getProperty("user.home")
return Paths.get(home, ".claude", "projects", encodeCwd(absCwd))
}
/**
* 阻塞轮询 [projectsDir],返回首个新出现的 `.jsonl` 的 session id;超时返回 null。
*
* 调用方负责保证目录已存在(先 `Files.createDirectories`)。本方法会阻塞当前线程,
* 上层应放到后台线程(如 `Application.executeOnPooledThread`)里跑。
*
* @param projectsDir 监听目录。
* @param timeoutMs 超时毫秒,默认 120s。
* @param intervalMs 轮询间隔毫秒,默认 1s。
*/
fun pollForNewSession(
projectsDir: Path,
timeoutMs: Long = 120_000,
intervalMs: Long = 1_000,
): String? {
val snapshot = listJsonl(projectsDir) ?: run {
thisLogger().warn("sessionWatcher: 初次 readdir 失败 $projectsDir")
return null
}
val deadline = System.currentTimeMillis() + timeoutMs
while (System.currentTimeMillis() < deadline) {
try {
Thread.sleep(intervalMs)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
return null
}
val current = listJsonl(projectsDir) ?: continue
val fresh = current.filterNot { it in snapshot }
if (fresh.isEmpty()) continue
// 多个新文件时取 mtime 最新的那个。
val newest = fresh.maxByOrNull { mtimeOf(projectsDir, it) } ?: continue
return newest.removeSuffix(".jsonl")
}
return null
}
/** 列出目录下全部 `.jsonl` 文件名;目录不存在/读失败返回 null。 */
private fun listJsonl(dir: Path): Set<String>? = try {
if (!Files.isDirectory(dir)) {
emptySet()
} else {
Files.list(dir).use { stream ->
stream.map { it.fileName.toString() }
.filter { it.endsWith(".jsonl") }
.toList()
.toSet()
}
}
} catch (_: Exception) {
null
}
private fun mtimeOf(dir: Path, name: String): Long = try {
Files.getLastModifiedTime(dir.resolve(name)).toMillis()
} catch (_: Exception) {
-1
}
}
@@ -0,0 +1,144 @@
package com.cruldra.superworkbench.session
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import com.intellij.terminal.ui.TerminalWidget
import org.jetbrains.plugins.terminal.ShellTerminalWidget
import org.jetbrains.plugins.terminal.TerminalToolWindowManager
/**
* 会话类型,对应源项目散落的 `'brainstorm' | 'implement' | 'review' | 'test'` 字面量。
* [displayName] 是终端标签里用的中文名(与源 `issue-${N}-规划/实施/审查/测试` 一致)。
*/
enum class SessionKind(val displayName: String) {
BRAINSTORM("规划"),
IMPLEMENT("实施"),
REVIEW("审查"),
TEST("测试"),
}
/**
* 终端标签编排,对应源项目 `src/panel/handlers/terminals.ts` + `sessions.ts` 里的
* 终端复用逻辑。封装 [TerminalToolWindowManager]
*
* - 在指定工作目录创建命名标签(`issue-{N}-{规划|实施|审查|测试}`)。
* - 维护 keyissueNumber + kind)→ widget 映射用于复用;map miss 时再按标签名扫描
* 现存 widget,对齐源里「findExistingTerminal 扫 live terminals 以扛 reload」的语义。
* - 通过 [ShellTerminalWidget.executeCommand] 向标签发命令。
* - 提供 close / focus。
*
* IDE 原生重写:VSCode 的 createTerminal 在这里映射为 `createLocalShellWidget(cwd, tabName)`
* 该调用本身已把 tabName 设进标签标题;命令发送用 `executeCommand`(≈ VSCode sendText)。
*/
@Service(Service.Level.PROJECT)
class TerminalManager(private val project: Project) {
/** key=`${issueNumber}:${kind}` → widget,用于复用。widget 被关闭后由 [pruneDead] 清理。 */
private val widgets = mutableMapOf<String, ShellTerminalWidget>()
private fun keyOf(issueNumber: Int, kind: SessionKind) = "$issueNumber:${kind.name}"
/** 终端标签名,对齐源 `issue-${N}-{role}` 约定。 */
fun terminalName(issueNumber: Int, kind: SessionKind): String =
"issue-$issueNumber-${kind.displayName}"
private fun manager(): TerminalToolWindowManager =
TerminalToolWindowManager.getInstance(project)
/**
* 复用或新建一个会话终端:先查 key→widget 映射,命中且存活则聚焦返回;否则按标签名
* 模糊匹配现存 widget(扛 IDE 重启/工具窗重建导致映射丢失,对齐源 findExistingTerminal);
* 仍无则在 [cwd] 下新建命名标签。返回的 widget 可直接 [runCommand]。
*
* @param issueNumber 工单号。
* @param kind 会话类型。
* @param cwd 工作目录绝对路径。
*/
fun openOrReuse(issueNumber: Int, kind: SessionKind, cwd: String): ShellTerminalWidget {
pruneDead()
val key = keyOf(issueNumber, kind)
val name = terminalName(issueNumber, kind)
widgets[key]?.let { existing ->
existing.requestFocus()
return existing
}
findExistingByName(name)?.let { found ->
widgets[key] = found
found.requestFocus()
return found
}
// 261 SDK 里 createShellWidget / createLocalShellWidget 全标 @Deprecated 且无
// 非废弃替代;createLocalShellWidget 直接返回非空 ShellTerminalWidget(无需可空转换),
// 故取它作为唯一路径。@Suppress 仅压这条无可避免的废弃告警。
@Suppress("DEPRECATION")
val widget = manager().createLocalShellWidget(cwd, name)
widgets[key] = widget
thisLogger().info("已创建终端 \"$name\" cwd=$cwd")
return widget
}
/**
* 向 widget 发一条命令执行(≈ VSCode terminal.sendText + 回车)。
* `executeCommand` 抛 IOException 时降级为日志,不让上层流程崩。
*/
fun runCommand(widget: ShellTerminalWidget, command: String) {
try {
widget.executeCommand(command)
} catch (e: Exception) {
thisLogger().warn("向终端发送命令失败: $command", e)
}
}
/** 聚焦已存在的会话标签;不存在则 no-op(对齐源 handleSessionFocus 的静默 return)。 */
fun focus(issueNumber: Int, kind: SessionKind) {
pruneDead()
val widget = widgets[keyOf(issueNumber, kind)]
?: findExistingByName(terminalName(issueNumber, kind))
?: return
widget.requestFocus()
}
/** 关闭某会话标签并从映射移除。 */
fun close(issueNumber: Int, kind: SessionKind) {
val key = keyOf(issueNumber, kind)
val widget = widgets.remove(key) ?: findExistingByName(terminalName(issueNumber, kind))
widget?.close()
}
/**
* 在 [TerminalToolWindowManager.getTerminalWidgets] 的存活集合里按标签名模糊匹配:
* 标签名等于 [name] 或以 `"$name "` 开头(shell 改写标题时可能追加分支后缀)即命中。
* 对齐源 findExistingTerminal 的前缀匹配 + 跳过已退出标签。
*/
private fun findExistingByName(name: String): ShellTerminalWidget? {
for (w in manager().terminalWidgets) {
val title = titleOf(w) ?: continue
if (title == name || title.startsWith("$name ")) {
return ShellTerminalWidget.asShellJediTermWidget(w)
}
}
return null
}
private fun titleOf(widget: TerminalWidget): String? = try {
val t = widget.terminalTitle
t.userDefinedTitle ?: t.buildTitle()
} catch (_: Exception) {
null
}
/** 丢弃映射里已被关闭的 widget(widget 不在工具窗存活集合里即视为已关闭)。 */
private fun pruneDead() {
if (widgets.isEmpty()) return
val live = manager().terminalWidgets
.mapNotNull { runCatching { ShellTerminalWidget.asShellJediTermWidget(it) }.getOrNull() }
.toSet()
widgets.entries.removeIf { it.value !in live }
}
companion object {
fun getInstance(project: Project): TerminalManager =
project.getService(TerminalManager::class.java)
}
}
@@ -0,0 +1,38 @@
package com.cruldra.superworkbench.settings
/**
* 默认 prompt 模板读取器。
*
* 模板原文打包在 classpath `/prompts/` 下的 md 文件(见 `src/main/resources/prompts/`)。
* 当 [SettingsState] 中对应 prompt 字段为空串时,从 classpath 读默认值返回,
* 对应源项目 `src/settings/store.ts` 的 `readDefaultPrompt`。
*
* 资源缺失/读取失败时回退到内联短串,保证插件始终可用。
*/
object DefaultPrompts {
val brainstorm: String by lazy { read("brainstorm", FALLBACK_BRAINSTORM) }
val brainstormContinue: String by lazy { read("brainstorm-continue", FALLBACK_BRAINSTORM_CONTINUE) }
val implementPlan: String by lazy { read("implement-plan", FALLBACK_IMPLEMENT_PLAN) }
val review: String by lazy { read("review", FALLBACK_REVIEW) }
private fun read(name: String, fallback: String): String {
val stream = this::class.java.getResourceAsStream("/prompts/$name.md")
?: return fallback
return stream.use { it.readBytes().toString(Charsets.UTF_8) }
}
private const val FALLBACK_BRAINSTORM =
"/goal 我现在有这样一个需求 {userRequest},你用 spx 命令创建 gitea 工单。" +
"工单 body 末尾必须包含 <!-- spx:nonce={nonce} -->。创建完工单立即停下汇报,不要擅自实施。"
private const val FALLBACK_BRAINSTORM_CONTINUE =
"/superpowers:brainstorming 讨论下 {issueNumber} 号工单。注意:不要 git checkout / 不要写代码 / 不要建 PR," +
"只讨论需求与 spec/plan,用 spx issue marker 更新 marker。"
private const val FALLBACK_IMPLEMENT_PLAN =
"/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时在 body 中包含 \"Closes #{issueNumber}\"。严禁合并 PR。"
private const val FALLBACK_REVIEW =
"/review 用 tea 拿到 #{prNumber} PR 审查。审查意见用 spx pr review-comment 发评论," +
"body 第一行写 <!-- spx:review=1 -->。"
}
@@ -0,0 +1,28 @@
package com.cruldra.superworkbench.settings
import com.intellij.credentialStore.CredentialAttributes
import com.intellij.credentialStore.generateServiceName
import com.intellij.ide.passwordSafe.PasswordSafe
/**
* 按 host 存取 Gitea personal access token,对应源项目 `src/auth/secrets.ts`
* 中 `gitea-token:{host}` 的密钥约定(YouTrack 部分忽略)。
*
* 用 IntelliJ [PasswordSafe] 存储——平台会落到系统钥匙串/加密文件,不进
* `superworkbench.xml`。token 按 host 区分,支持连多个 Gitea 实例不串号。
*/
object GiteaTokenStore {
private fun attributes(host: String): CredentialAttributes =
CredentialAttributes(generateServiceName("Superworkbench", "gitea-token:$host"))
fun get(host: String): String? =
PasswordSafe.instance.getPassword(attributes(host))?.takeIf { it.isNotEmpty() }
fun set(host: String, token: String) {
PasswordSafe.instance.setPassword(attributes(host), token)
}
fun clear(host: String) {
PasswordSafe.instance.setPassword(attributes(host), null)
}
}
@@ -0,0 +1,45 @@
package com.cruldra.superworkbench.settings
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
/**
* 应用级设置持久化服务,存储到 `superworkbench.xml`。
*
* 对应源项目 `src/settings/store.ts` 的 globalState JSON blob,区别是 IntelliJ
* 用 [PersistentStateComponent] 把 [SettingsState] 自动序列化到 xml。
*
* 「有效 prompt」读取(`effective*Prompt`):字段为空串时回退到 [DefaultPrompts]
* 的打包默认模板,对应源项目的 `readDefaultPrompt` 回退逻辑。
*/
@Service(Service.Level.APP)
@State(name = "Superworkbench", storages = [Storage("superworkbench.xml")])
class SettingsService : PersistentStateComponent<SettingsState> {
private var state = SettingsState()
override fun getState(): SettingsState = state
override fun loadState(state: SettingsState) {
this.state = state
}
val effectiveBrainstormPrompt: String
get() = state.brainstormPrompt.ifEmpty { DefaultPrompts.brainstorm }
val effectiveBrainstormContinuePrompt: String
get() = state.brainstormContinuePrompt.ifEmpty { DefaultPrompts.brainstormContinue }
val effectiveImplementPlanPrompt: String
get() = state.implementPlanPrompt.ifEmpty { DefaultPrompts.implementPlan }
val effectiveReviewPrompt: String
get() = state.reviewPrompt.ifEmpty { DefaultPrompts.review }
companion object {
fun getInstance(): SettingsService =
ApplicationManager.getApplication().getService(SettingsService::class.java)
}
}
@@ -0,0 +1,53 @@
package com.cruldra.superworkbench.settings
/**
* 可持久化的非密设置(对应源项目 `src/settings/store.ts` 的 `Settings`
* 已删除全部 youtrack 字段)。
*
* 4 个 prompt 字段默认空串,表示「使用打包默认模板」——读取有效值时由
* [SettingsService] 回退到 [DefaultPrompts]。token 不存这里,存 PasswordSafe
* (见 `GiteaTokenStore`)。
*
* 必须是带无参构造、可变属性的普通类,[PersistentStateComponent] 才能序列化。
*/
class SettingsState {
/** 接收 gitea webhook 回调的本地 HTTP 端口。 */
var webhookPort: Int = DEFAULT_WEBHOOK_PORT
/** brainstorm 流程 prompt 模板,空串=用打包默认。`{userRequest}` `{nonce}` 占位符。 */
var brainstormPrompt: String = ""
/** 续聊已有工单的 brainstorm prompt 模板,空串=用打包默认。`{issueNumber}` 占位符。 */
var brainstormContinuePrompt: String = ""
/** implement-plan 流程 prompt 模板,空串=用打包默认。`{planFile}` `{issueNumber}` 占位符。 */
var implementPlanPrompt: String = ""
/** auto-review 流程 prompt 模板,空串=用打包默认。`{prNumber}` 占位符。 */
var reviewPrompt: String = ""
/** PR 打开时是否自动触发 review。 */
var autoReview: Boolean = true
/** 日常开发分支(如 `main`),Jenkins 不监听它。 */
var devBranch: String = "main"
/** gitea webhook → Jenkins 监听的分支。空串=「跟随 devBranch」,分支同步按钮禁用。 */
var autoBuildBranch: String = ""
/** worktree 创建后运行的用户脚本路径,空串=用默认 `.spx/worktree-post-create.sh`。 */
var worktreePostCreateScript: String = ""
/** worktree 移除前运行的用户脚本路径,空串=用默认 `.spx/worktree-pre-remove.sh`。 */
var worktreePreRemoveScript: String = ""
/** 实施 tab 创建前运行的用户脚本路径,空串=用默认 `.spx/impl-tab-pre-create.sh`。 */
var implTabPreCreateScript: String = ""
/** 实施 tab 关闭后运行的用户脚本路径,空串=用默认 `.spx/impl-tab-post-close.sh`。 */
var implTabPostCloseScript: String = ""
companion object {
const val DEFAULT_WEBHOOK_PORT: Int = 17421
}
}
@@ -0,0 +1,198 @@
package com.cruldra.superworkbench.settings
import com.cruldra.superworkbench.gitea.GitRemote
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
import com.intellij.openapi.project.ProjectManager
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBPasswordField
import com.intellij.ui.components.JBTextArea
import com.intellij.ui.components.JBTextField
import com.intellij.ui.dsl.builder.AlignX
import com.intellij.ui.dsl.builder.columns
import com.intellij.ui.dsl.builder.panel
import com.intellij.util.ui.JBUI
import java.nio.file.Path
import javax.swing.JComponent
import javax.swing.JScrollPane
/**
* Superworkbench 的原生 IntelliJ 设置页(应用级 `<applicationConfigurable>`)。
*
* 对应源项目 `webview-ui/src/components/SettingsModal.tsx` + `src/panel/handlers/settings.ts`
* 的设置语义,但**跳过所有 youtrack 字段**。布局用 Kotlin UI DSL `panel { }` 手搭,
* 字段绑定/读写不走 DSL 的 bind(token 字段有特殊语义,统一在 [apply]/[reset]/[isModified]
* 里手动控制)。
*
* token 语义对齐源 `handleSettingsSave`:密码框**不回显已存值**,留空=不修改;非空=
* 写入 [GiteaTokenStore]。因此 token 字段不参与 [isModified] 的回显比较,按「非空即改」
* 单独判定。
*/
class SuperworkbenchConfigurable : Configurable {
private val settings get() = SettingsService.getInstance().state
// host 默认值:从打开的项目 git origin 远端预填,拿不到留空。
private val detectedHost: String = detectHost()
// --- 控件(createComponent 时实例化,reset 填值,apply 读回) ---
private val hostField = JBTextField()
private val tokenField = JBPasswordField()
private val webhookPortField = JBTextField()
private val autoReviewCheck = JBCheckBox("PR 打开时自动触发 review")
private val devBranchField = JBTextField()
private val autoBuildBranchField = JBTextField()
private val brainstormPromptArea = multilineArea()
private val brainstormContinuePromptArea = multilineArea()
private val implementPlanPromptArea = multilineArea()
private val reviewPromptArea = multilineArea()
private val worktreePostCreateScriptField = JBTextField()
private val worktreePreRemoveScriptField = JBTextField()
private val implTabPreCreateScriptField = JBTextField()
private val implTabPostCloseScriptField = JBTextField()
override fun getDisplayName(): String = "Superworkbench"
override fun createComponent(): JComponent {
val content = panel {
group("Gitea 认证") {
row("Host:") {
cell(hostField).align(AlignX.FILL).comment("如 gitea.example.com,留空则从 git origin 远端自动解析")
}
row("Token:") {
cell(tokenField).align(AlignX.FILL).comment("Gitea personal access token;不回显已存值,留空=不修改")
}
}
group("网络") {
row("Webhook 端口:") {
cell(webhookPortField).columns(8)
}
}
group("Review") {
row {
cell(autoReviewCheck)
}
}
group("分支") {
row("开发分支:") {
cell(devBranchField).align(AlignX.FILL).comment("如 mainJenkins 不监听它")
}
row("自动构建分支:") {
cell(autoBuildBranchField).align(AlignX.FILL).comment("空=跟随开发分支")
}
}
group("Prompt 模板(空=用打包默认)") {
promptRow("Brainstorm:", brainstormPromptArea)
promptRow("Brainstorm 续聊:", brainstormContinuePromptArea)
promptRow("Implement plan:", implementPlanPromptArea)
promptRow("Review:", reviewPromptArea)
}
group("钩子脚本路径(空=用默认 .spx/*.sh)") {
row("worktree 创建后:") {
cell(worktreePostCreateScriptField).align(AlignX.FILL)
}
row("worktree 移除前:") {
cell(worktreePreRemoveScriptField).align(AlignX.FILL)
}
row("实施 tab 创建前:") {
cell(implTabPreCreateScriptField).align(AlignX.FILL)
}
row("实施 tab 关闭后:") {
cell(implTabPostCloseScriptField).align(AlignX.FILL)
}
}
}
reset()
// 外层套滚动,字段多时不至于撑爆窗口。
return JScrollPane(content).apply { border = JBUI.Borders.empty() }
}
override fun isModified(): Boolean {
val s = settings
// token 单独判定:密码框非空即视为「将要修改」。
if (tokenText().isNotEmpty()) return true
return hostText() != effectiveHost() ||
webhookPortText().toIntOrNull() != s.webhookPort ||
autoReviewCheck.isSelected != s.autoReview ||
devBranchField.text.trim() != s.devBranch ||
autoBuildBranchField.text.trim() != s.autoBuildBranch ||
brainstormPromptArea.text != s.brainstormPrompt ||
brainstormContinuePromptArea.text != s.brainstormContinuePrompt ||
implementPlanPromptArea.text != s.implementPlanPrompt ||
reviewPromptArea.text != s.reviewPrompt ||
worktreePostCreateScriptField.text.trim() != s.worktreePostCreateScript ||
worktreePreRemoveScriptField.text.trim() != s.worktreePreRemoveScript ||
implTabPreCreateScriptField.text.trim() != s.implTabPreCreateScript ||
implTabPostCloseScriptField.text.trim() != s.implTabPostCloseScript
}
override fun apply() {
val s = settings
// 非 token 字段写回 state。prompt/autoBuildBranch 的空串语义保留(读取时回退默认)。
s.webhookPort = webhookPortText().toIntOrNull() ?: SettingsState.DEFAULT_WEBHOOK_PORT
s.autoReview = autoReviewCheck.isSelected
s.devBranch = devBranchField.text.trim()
s.autoBuildBranch = autoBuildBranchField.text.trim()
s.brainstormPrompt = brainstormPromptArea.text
s.brainstormContinuePrompt = brainstormContinuePromptArea.text
s.implementPlanPrompt = implementPlanPromptArea.text
s.reviewPrompt = reviewPromptArea.text
s.worktreePostCreateScript = worktreePostCreateScriptField.text.trim()
s.worktreePreRemoveScript = worktreePreRemoveScriptField.text.trim()
s.implTabPreCreateScript = implTabPreCreateScriptField.text.trim()
s.implTabPostCloseScript = implTabPostCloseScriptField.text.trim()
// token:非空才写入,按 host 区分;host 空时无处可存,跳过。
val host = hostText()
val token = tokenText()
if (host.isNotEmpty() && token.isNotEmpty()) {
GiteaTokenStore.set(host, token)
}
// 写完清空密码框,避免下次打开仍显示刚输入的明文。
tokenField.text = ""
}
override fun reset() {
val s = settings
hostField.text = effectiveHost()
tokenField.text = "" // 永不回显已存 token
webhookPortField.text = s.webhookPort.toString()
autoReviewCheck.isSelected = s.autoReview
devBranchField.text = s.devBranch
autoBuildBranchField.text = s.autoBuildBranch
brainstormPromptArea.text = s.brainstormPrompt
brainstormContinuePromptArea.text = s.brainstormContinuePrompt
implementPlanPromptArea.text = s.implementPlanPrompt
reviewPromptArea.text = s.reviewPrompt
worktreePostCreateScriptField.text = s.worktreePostCreateScript
worktreePreRemoveScriptField.text = s.worktreePreRemoveScript
implTabPreCreateScriptField.text = s.implTabPreCreateScript
implTabPostCloseScriptField.text = s.implTabPostCloseScript
}
/** host 留空时用自动解析的远端 host 兜底(仅用于 reset 显示与 isModified 比较基准)。 */
private fun effectiveHost(): String = detectedHost
private fun hostText() = hostField.text.trim()
private fun tokenText() = String(tokenField.password).trim()
private fun webhookPortText() = webhookPortField.text.trim()
private fun detectHost(): String {
val project = ProjectManager.getInstance().openProjects.firstOrNull() ?: return ""
val basePath = project.basePath ?: return ""
return runCatching { GitRemote.detect(Path.of(basePath))?.host }.getOrNull().orEmpty()
}
private fun multilineArea(): JBTextArea = JBTextArea(4, 60).apply {
lineWrap = true
wrapStyleWord = true
}
private fun com.intellij.ui.dsl.builder.Panel.promptRow(label: String, area: JBTextArea) {
row(label) {
cell(JScrollPane(area)).align(AlignX.FILL)
}.layout(com.intellij.ui.dsl.builder.RowLayout.LABEL_ALIGNED)
}
}
@@ -0,0 +1,110 @@
package com.cruldra.superworkbench.toolwindow
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.cruldra.superworkbench.settings.SuperworkbenchConfigurable
import com.cruldra.superworkbench.ui.IssueDetailPanel
import com.cruldra.superworkbench.ui.KanbanBoard
import com.cruldra.superworkbench.ui.KanbanViewModel
import com.cruldra.superworkbench.ui.LoadState
import com.intellij.icons.AllIcons
import com.intellij.openapi.actionSystem.AnAction
import com.intellij.openapi.actionSystem.AnActionEvent
import com.intellij.openapi.options.ShowSettingsUtil
import com.intellij.openapi.project.Project
import com.intellij.openapi.wm.ToolWindow
import com.intellij.openapi.wm.ToolWindowFactory
import org.jetbrains.jewel.bridge.addComposeTab
import org.jetbrains.jewel.ui.component.OutlinedButton
import org.jetbrains.jewel.ui.component.Text
class KanbanToolWindowFactory : ToolWindowFactory {
override fun createToolWindowContent(project: Project, toolWindow: ToolWindow) {
// vm 上提到工厂层,工具窗口存活期间单实例,标题栏动作与 Compose tab 共用。
val vm = KanbanViewModel(project)
toolWindow.setTitleActions(
listOf(
object : AnAction("刷新", "刷新工单看板", AllIcons.Actions.Refresh) {
override fun actionPerformed(e: AnActionEvent) = vm.refresh()
},
object : AnAction("设置", "打开 Superworkbench 设置", AllIcons.General.Settings) {
override fun actionPerformed(e: AnActionEvent) {
ShowSettingsUtil.getInstance()
.showSettingsDialog(project, SuperworkbenchConfigurable::class.java)
}
},
),
)
toolWindow.addComposeTab("看板") {
// 首次组合触发加载;vm 是稳定 key,工具窗口存活期间只触发一次。
LaunchedEffect(vm) { vm.load() }
KanbanRoot(vm)
}
}
}
/** 工具窗口根:顶部刷新按钮 + 按加载状态渲染看板 / 加载中 / 错误。 */
@Composable
private fun KanbanRoot(vm: KanbanViewModel) {
Column(modifier = Modifier.fillMaxSize().padding(8.dp)) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Superworkbench 看板")
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = { vm.refresh() }) { Text("刷新") }
}
Box(modifier = Modifier.fillMaxSize()) {
when (val state = vm.state) {
LoadState.Loading -> Centered("加载工单中…")
is LoadState.Error -> Centered(state.message)
LoadState.Ready ->
if (vm.issues.isEmpty()) Centered("没有与当前用户相关的工单") else BoardWithDetail(vm)
}
}
}
}
/** 看板 + 详情面板:选中工单时右侧滑出详情,否则只显示看板。 */
@Composable
private fun BoardWithDetail(vm: KanbanViewModel) {
val selected = vm.selectedIssueNumber?.let { num -> vm.issues.firstOrNull { it.number == num } }
Row(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.weight(1f).fillMaxHeight()) {
KanbanBoard(vm)
}
if (selected != null) {
Box(modifier = Modifier.width(360.dp).fillMaxHeight().padding(start = 8.dp)) {
IssueDetailPanel(vm, selected)
}
}
}
}
@Composable
private fun Centered(message: String) {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(message)
}
}
@@ -0,0 +1,121 @@
package com.cruldra.superworkbench.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.cruldra.superworkbench.model.Issue
import com.cruldra.superworkbench.model.IssueColumn
import org.jetbrains.jewel.ui.component.OutlinedButton
import org.jetbrains.jewel.ui.component.Text
private val CARD_ACCENT = Color(0xFF3574F0)
private val CARD_BG = Color(0x22FFFFFF)
private val CARD_BORDER = Color(0x33808080)
private val BADGE_BG = Color(0x223574F0)
/**
* 单张工单卡。展示工单号 `#N`、标题、状态徽标(有 PR 显示 PR 号、worktree 存在标记、
* 有未完成前置时显示锁标记),点击触发 [onClick] 选中。选中态加蓝色高亮边框。
*
* 卡片右侧另放一个「移动到…」下拉作为换列保底交互(拖拽不便时仍可换列)。
*
* @param dragging 是否正被拖动,拖动时半透明以示反馈。
* @param onMoveTo 选择目标列时回调,对应 [KanbanViewModel.changeColumn]。
*/
@Composable
fun IssueCard(
issue: Issue,
selected: Boolean,
dragging: Boolean,
onClick: () -> Unit,
onMoveTo: (IssueColumn) -> Unit,
) {
val borderColor = if (selected) CARD_ACCENT else CARD_BORDER
val borderWidth = if (selected) 2.dp else 1.dp
Column(
modifier = Modifier
.fillMaxWidth()
.alpha(if (dragging) 0.4f else 1f)
.clip(RoundedCornerShape(6.dp))
.background(CARD_BG)
.border(borderWidth, borderColor, RoundedCornerShape(6.dp))
.clickable(onClick = onClick)
.padding(8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("#${issue.number}")
Spacer(Modifier.width(8.dp))
Text(issue.title)
}
BadgeRow(issue)
MoveMenu(issue, onMoveTo)
}
}
/** 状态徽标行:PR 号、worktree 存在、未完成前置锁标。无任何徽标时整行省略。 */
@Composable
private fun BadgeRow(issue: Issue) {
val badges = buildList {
issue.pr?.let { add(if (issue.prMerged == true) "PR #$it 已合并" else "PR #$it") }
if (issue.worktreeExists == true) add("worktree")
issue.prerequisite?.let { add("🔒 #$it") } // 🔒 等待前置
}
if (badges.isEmpty()) return
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
badges.forEach { BadgeChip(it) }
}
}
@Composable
private fun BadgeChip(label: String) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.background(BADGE_BG)
.padding(horizontal = 6.dp, vertical = 2.dp),
) {
Text(label)
}
}
/** 换列保底交互:除当前列外的三列各一个小按钮,点击即调 [onMoveTo](拖拽不便时仍可换列)。 */
@Composable
private fun MoveMenu(issue: Issue, onMoveTo: (IssueColumn) -> Unit) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
COLUMN_TARGETS.filter { it != issue.column }.forEach { target ->
OutlinedButton(onClick = { onMoveTo(target) }) {
Text("${moveLabel(target)}")
}
}
}
}
private val COLUMN_TARGETS = listOf(
IssueColumn.TODO,
IssueColumn.IN_PROGRESS,
IssueColumn.REVIEW,
IssueColumn.DONE,
)
private fun moveLabel(column: IssueColumn): String = when (column) {
IssueColumn.TODO -> "待办"
IssueColumn.IN_PROGRESS -> "进行中"
IssueColumn.REVIEW -> "审查"
IssueColumn.DONE -> "完成"
}
@@ -0,0 +1,303 @@
package com.cruldra.superworkbench.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.cruldra.superworkbench.model.Issue
import com.cruldra.superworkbench.model.IssueColumn
import com.cruldra.superworkbench.session.SessionKind
import org.jetbrains.jewel.ui.component.OutlinedButton
import org.jetbrains.jewel.ui.component.Text
private val SECTION_TITLE = Color(0xAAFFFFFF)
private val KEY_COLOR = Color(0x99FFFFFF)
private val ROW_BG = Color(0x14808080)
private val DIVIDER = Color(0x33808080)
/**
* 工单详情面板,对齐源 `webview-ui/src/components/IssueDetailPanel.tsx`(剔除 YouTrack 分支)。
*
* 整体结构(自上而下,整列 [verticalScroll] 防溢出):
* - 顶部标题行:`#N 标题` + 关闭(返回看板) / 在浏览器打开 / 关闭工单 三个按钮。
* - 四种会话行(规划/实施/审查/测试):各自显示截断 sessionId + 复制 + 打开(focus 终端) +
* tab 开着时)关闭;无 sessionId 时显示对应「启动」入口。
* - 属性区:spec/plan/prDiff 文件、branch、worktreePath(+exists)、pr(+merged)、autoReview、color
* 等键值只读展示。
* - worktree 区:有 worktreePath 时给「打开」/「删除」按钮。
* - 操作区:按工单状态条件展示启动/继续各会话、生成 PR 摘要(TODO)、提交代码(TODO)、关闭工单。
*
* 所有按钮回调到 [KanbanViewModel] 的详情动作方法(后台执行 + 自带通知/刷新)。
*
* @param vm 看板状态持有者,提供动作方法。
* @param issue 当前选中的工单。
*/
@Composable
fun IssueDetailPanel(vm: KanbanViewModel, issue: Issue) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
HeaderRow(vm, issue)
Divider()
SessionSection(vm, issue)
Divider()
PropertySection(issue)
if (!issue.worktreePath.isNullOrEmpty()) {
Divider()
WorktreeSection(vm, issue)
}
Divider()
ActionSection(vm, issue)
}
}
/** 顶部标题行:`#N 标题` + 关闭(返回看板) / 在浏览器打开 / 关闭工单。 */
@Composable
private fun HeaderRow(vm: KanbanViewModel, issue: Issue) {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("#${issue.number}")
Spacer(Modifier.width(8.dp))
Text(issue.title)
}
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(onClick = { vm.select(null) }) { Text("返回看板") }
OutlinedButton(onClick = { vm.openInBrowser(issue) }) { Text("在浏览器打开") }
}
}
}
/** 四种会话行(规划/实施/审查/测试)。 */
@Composable
private fun SessionSection(vm: KanbanViewModel, issue: Issue) {
SectionTitle("会话")
SessionRow(
label = "规划",
sessionId = issue.sessionId,
tabOpen = issue.brainstormTabOpen == true,
canStart = issue.column != IssueColumn.DONE,
startLabel = "启动规划",
onCopy = { vm.copyToClipboard(it) },
onOpen = { vm.focusSession(issue, SessionKind.BRAINSTORM) },
onClose = { vm.closeSessionTab(issue, SessionKind.BRAINSTORM) },
onStart = { vm.startOrContinueBrainstorm(issue) },
)
SessionRow(
label = "实施",
sessionId = issue.implementSessionId,
tabOpen = issue.implementTabOpen == true,
canStart = !issue.planFile.isNullOrEmpty(),
startLabel = "开始实施",
onCopy = { vm.copyToClipboard(it) },
onOpen = { vm.focusSession(issue, SessionKind.IMPLEMENT) },
onClose = { vm.closeSessionTab(issue, SessionKind.IMPLEMENT) },
onStart = { vm.implement(issue) },
)
SessionRow(
label = "审查",
sessionId = issue.reviewSessionId,
tabOpen = issue.reviewTabOpen == true,
// 审查会话只能恢复(codex resume),没有「启动」入口;故 canStart 仅在已有 id 时无意义。
canStart = false,
startLabel = "审查",
onCopy = { vm.copyToClipboard(it) },
onOpen = { vm.review(issue) },
onClose = { vm.closeSessionTab(issue, SessionKind.REVIEW) },
onStart = { vm.review(issue) },
)
SessionRow(
label = "测试",
sessionId = issue.testSessionId,
tabOpen = issue.testTabOpen == true,
// 测试会话依赖已合并 PR 的提示词,无 PR 不给启动入口。
canStart = !issue.pr.isNullOrEmpty(),
startLabel = "启动测试",
onCopy = { vm.copyToClipboard(it) },
onOpen = { vm.focusSession(issue, SessionKind.TEST) },
onClose = { vm.closeSessionTab(issue, SessionKind.TEST) },
onStart = { vm.test(issue) },
)
}
/**
* 单条会话行:有 sessionId 时显示截断 id + 复制 + 打开 +(tab 开着)关闭;
* 无 sessionId 且 [canStart] 时显示「启动」入口;都不满足显示占位。
*/
@Composable
private fun SessionRow(
label: String,
sessionId: String?,
tabOpen: Boolean,
canStart: Boolean,
startLabel: String,
onCopy: (String) -> Unit,
onOpen: () -> Unit,
onClose: () -> Unit,
onStart: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(4.dp))
.background(ROW_BG)
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Text(label, color = KEY_COLOR)
Spacer(Modifier.width(4.dp))
if (!sessionId.isNullOrEmpty()) {
Text(truncateId(sessionId))
Spacer(Modifier.weight(1f))
OutlinedButton(onClick = { onCopy(sessionId) }) { Text("复制") }
OutlinedButton(onClick = onOpen) { Text("打开") }
if (tabOpen) {
OutlinedButton(onClick = onClose) { Text("关闭") }
}
} else {
Text("")
Spacer(Modifier.weight(1f))
if (canStart) {
OutlinedButton(onClick = onStart) { Text(startLabel) }
}
}
}
}
/** 属性区:键值只读展示。 */
@Composable
private fun PropertySection(issue: Issue) {
SectionTitle("属性")
PropertyRow("状态", columnLabel(issue.column))
PropertyRow("规格文件", issue.specFile)
PropertyRow("计划文件", issue.planFile)
PropertyRow("PR 变更摘要", issue.prDiffFile)
PropertyRow("分支", issue.branch)
PropertyRow(
"工作树",
issue.worktreePath?.let { path ->
if (issue.worktreeExists == true) "$path(存在)" else "$path(已清理)"
},
)
PropertyRow(
"合并请求",
issue.pr?.let { if (issue.prMerged == true) "#$it(已合并)" else "#$it" },
)
PropertyRow("自动审查", issue.autoReview?.let { if (it) "" else "" })
PropertyRow("颜色", issue.color)
}
/** 一行键值;value 为空时显示占位 —。 */
@Composable
private fun PropertyRow(key: String, value: String?) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
verticalAlignment = Alignment.Top,
) {
Text(key, color = KEY_COLOR, modifier = Modifier.width(96.dp))
Spacer(Modifier.width(8.dp))
Text(value?.takeIf { it.isNotEmpty() } ?: "", modifier = Modifier.weight(1f))
}
}
/** worktree 区:打开 / 删除按钮(仅有 worktreePath 时本区才被渲染)。 */
@Composable
private fun WorktreeSection(vm: KanbanViewModel, issue: Issue) {
SectionTitle("工作树")
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
OutlinedButton(
onClick = { vm.openWorktree(issue) },
enabled = issue.worktreeExists == true,
) { Text("打开 worktree") }
OutlinedButton(onClick = { vm.deleteWorktree(issue) }) { Text("删除 worktree") }
}
}
/**
* 操作区:按工单状态条件展示动作按钮。
* - 启动/继续规划:done 列以外恒显示(无 id=启动,有 id=继续)。
* - 实施/继续实施:有 planFile 才显示。
* - 审查:有 reviewSessionId(即开过 PR 审查)才显示。
* - 测试:PR 合并后显示。
* - 生成 PR 摘要 / 提交代码:占位禁用(动作尚未迁移,见 ViewModel TODO)。
* - 关闭工单:恒显示。
*/
@Composable
private fun ActionSection(vm: KanbanViewModel, issue: Issue) {
SectionTitle("操作")
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
if (issue.column != IssueColumn.DONE) {
OutlinedButton(onClick = { vm.startOrContinueBrainstorm(issue) }) {
Text(if (issue.sessionId.isNullOrEmpty()) "启动规划" else "继续规划")
}
}
if (!issue.planFile.isNullOrEmpty()) {
OutlinedButton(onClick = { vm.implement(issue) }) {
Text(if (issue.implementSessionId.isNullOrEmpty()) "实施此计划" else "继续实施")
}
}
if (!issue.reviewSessionId.isNullOrEmpty()) {
OutlinedButton(onClick = { vm.review(issue) }) { Text("恢复审查") }
}
if (issue.prMerged == true || !issue.pr.isNullOrEmpty()) {
OutlinedButton(onClick = { vm.test(issue) }) {
Text(if (issue.testSessionId.isNullOrEmpty()) "启动测试" else "继续测试")
}
}
// 生成 PR 摘要 / 提交代码:对应动作尚未迁移(见 KanbanViewModel TODO),暂禁用占位。
OutlinedButton(onClick = {}, enabled = false) { Text("生成 PR 摘要(未接入)") }
OutlinedButton(onClick = {}, enabled = false) { Text("提交代码(未接入)") }
OutlinedButton(onClick = { vm.closeIssue(issue) }) { Text("关闭工单") }
}
}
@Composable
private fun SectionTitle(title: String) {
Text(title, color = SECTION_TITLE)
}
@Composable
private fun Divider() {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
) {
Spacer(
Modifier
.fillMaxWidth()
.background(DIVIDER)
.padding(top = 1.dp),
)
}
}
/** 截断长 sessionId 显示:`头8…尾6`,短于 16 时原样显示。 */
private fun truncateId(id: String): String =
if (id.length <= 16) id else "${id.take(8)}${id.takeLast(6)}"
private fun columnLabel(column: IssueColumn): String = when (column) {
IssueColumn.TODO -> "待办"
IssueColumn.IN_PROGRESS -> "进行中"
IssueColumn.REVIEW -> "审查"
IssueColumn.DONE -> "完成"
}
@@ -0,0 +1,235 @@
package com.cruldra.superworkbench.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import com.cruldra.superworkbench.model.Issue
import com.cruldra.superworkbench.model.IssueColumn
import org.jetbrains.jewel.ui.component.Text
/** 四列固定顺序,对齐源项目 `COLUMN_ORDER`。 */
private val COLUMN_ORDER = listOf(
IssueColumn.TODO,
IssueColumn.IN_PROGRESS,
IssueColumn.REVIEW,
IssueColumn.DONE,
)
/** 列的中文标题,对齐源项目 `COLUMN_LABELS`。 */
private fun columnLabel(column: IssueColumn): String = when (column) {
IssueColumn.TODO -> "待办"
IssueColumn.IN_PROGRESS -> "进行中"
IssueColumn.REVIEW -> "审查"
IssueColumn.DONE -> "完成"
}
private val COLUMN_BG = Color(0x14808080)
private val DROP_HINT_BG = Color(0x333574F0)
/**
* 跨列拖拽的瞬时状态:哪张卡正在被拖、指针在 root 坐标系下的位置。
* 拖拽中由各列上报自己的 root 边界,松手时命中目标列触发 [KanbanViewModel.changeColumn]。
*/
private class DragState {
var draggingNumber: Int? by mutableStateOf(null)
var pointer: Offset by mutableStateOf(Offset.Zero)
val columnBounds: MutableMap<IssueColumn, Rect> = mutableMapOf()
/** 返回当前指针命中的列;不在任何列内返回 null。 */
fun columnAt(position: Offset): IssueColumn? =
columnBounds.entries.firstOrNull { it.value.contains(position) }?.key
fun reset() {
draggingNumber = null
}
}
/**
* 看板根 Composable:一行四列。每列渲染所属工单的 [IssueCard]。
*
* 拖拽换列用 [detectDragGesturesAfterLongPress](长按拖拽,避免与点击选中冲突):拖动时
* 记录指针 root 坐标与正在拖的卡号,松手时按指针命中的列调用 [KanbanViewModel.changeColumn]。
* 卡片上另保留「移动到…」下拉菜单作为保底交互(见 [IssueCard])。
*/
@Composable
fun KanbanBoard(vm: KanbanViewModel) {
val drag = remember { DragState() }
Row(
modifier = Modifier.fillMaxSize().padding(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
for (column in COLUMN_ORDER) {
KanbanColumn(
vm = vm,
column = column,
drag = drag,
modifier = Modifier.weight(1f).fillMaxHeight(),
)
}
}
}
/** 单列:标题 + 计数 + 该列卡片纵向列表,并上报自身 root 边界用于拖放命中判定。 */
@Composable
private fun KanbanColumn(
vm: KanbanViewModel,
column: IssueColumn,
drag: DragState,
modifier: Modifier = Modifier,
) {
val columnIssues = vm.issues.filter { it.column == column }
val isDropTarget = drag.draggingNumber != null && drag.columnAt(drag.pointer) == column
Column(
modifier = modifier
.clip(androidx.compose.foundation.shape.RoundedCornerShape(6.dp))
.background(if (isDropTarget) DROP_HINT_BG else COLUMN_BG)
.onGloballyPositioned { drag.columnBounds[column] = it.boundsInRoot() }
.padding(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(columnLabel(column))
Spacer(Modifier.width(6.dp))
Text("${columnIssues.size}")
}
if (column == IssueColumn.TODO) {
TodoColumnBody(vm, columnIssues, drag)
} else {
PlainColumnBody(vm, columnIssues, drag)
}
}
}
/** 非 todo 列:扁平列表,每张卡 depth=0。 */
@Composable
private fun PlainColumnBody(vm: KanbanViewModel, columnIssues: List<Issue>, drag: DragState) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(columnIssues) { issue ->
CardSlot(vm, issue, depth = 0, drag = drag)
}
}
}
/**
* todo 列:按 `issue.prerequisite` 构建前置依赖森林,前序 DFS 展开为带 depth 的扁平列表,
* 每级缩进 16dp(对齐源项目 `buildTodoTree` + `marginLeft: depth * 16`)。
*/
@Composable
private fun TodoColumnBody(vm: KanbanViewModel, columnIssues: List<Issue>, drag: DragState) {
val flattened = remember(columnIssues) { buildTodoTree(columnIssues) }
LazyColumn(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
items(flattened) { node ->
CardSlot(vm, node.issue, depth = node.depth, drag = drag)
}
}
}
/** 前序 DFS 展开后的一个节点:工单 + 在依赖树中的深度。 */
private data class TodoNode(val issue: Issue, val depth: Int)
/**
* 把 todo 列工单扁平表转成「前置依赖森林 → 前序 DFS 列表」。
*
* - 根:prerequisite 为空,或指向不在本列的工单。
* - 子:prerequisite 指向本列另一工单。
* - 顺序:保留输入(根)顺序与兄弟顺序,深度从 0 起逐级 +1。
*/
private fun buildTodoTree(todoIssues: List<Issue>): List<TodoNode> {
val inColumn = todoIssues.associateBy { it.number }
val childrenOf = LinkedHashMap<Int, MutableList<Issue>>()
val roots = mutableListOf<Issue>()
for (issue in todoIssues) {
val parent = issue.prerequisite
if (parent != null && inColumn.containsKey(parent)) {
childrenOf.getOrPut(parent) { mutableListOf() }.add(issue)
} else {
roots.add(issue)
}
}
val out = mutableListOf<TodoNode>()
val visited = mutableSetOf<Int>()
fun walk(issue: Issue, depth: Int) {
if (!visited.add(issue.number)) return // 环保护,避免循环依赖死递归
out.add(TodoNode(issue, depth))
childrenOf[issue.number]?.forEach { walk(it, depth + 1) }
}
roots.forEach { walk(it, 0) }
// 兜底:环导致未被任何根触达的工单仍以 depth 0 收尾,保证不丢卡。
todoIssues.filter { it.number !in visited }.forEach { out.add(TodoNode(it, 0)) }
return out
}
/** 给一张卡套上「长按拖拽」手势与缩进 padding,并桥接拖放命中到 [KanbanViewModel.changeColumn]。 */
@Composable
private fun CardSlot(vm: KanbanViewModel, issue: Issue, depth: Int, drag: DragState) {
// 本卡在 root 坐标系的左上角:detectDrag 回调给的是卡片本地坐标,加此原点换算为 root 坐标做列命中。
var cardOrigin by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.fillMaxWidth()
.padding(start = (depth * 16).dp)
.onGloballyPositioned { cardOrigin = it.boundsInRoot().topLeft }
.pointerInput(issue.number) {
detectDragGesturesAfterLongPress(
onDragStart = { offset ->
drag.draggingNumber = issue.number
drag.pointer = offset + cardOrigin
},
onDragEnd = {
val target = drag.columnAt(drag.pointer)
if (target != null && target != issue.column) {
vm.changeColumn(issue, target)
}
drag.reset()
},
onDragCancel = { drag.reset() },
onDrag = { change, _ ->
change.consume()
drag.pointer = change.position + cardOrigin
},
)
},
) {
IssueCard(
issue = issue,
selected = vm.selectedIssueNumber == issue.number,
dragging = drag.draggingNumber == issue.number,
onClick = { vm.select(issue.number) },
onMoveTo = { target -> vm.changeColumn(issue, target) },
)
}
}
@@ -0,0 +1,368 @@
package com.cruldra.superworkbench.ui
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.cruldra.superworkbench.gitea.GitRemote
import com.cruldra.superworkbench.gitea.GiteaApi
import com.cruldra.superworkbench.gitea.GiteaRepoRef
import com.cruldra.superworkbench.gitea.IssueLoader
import com.cruldra.superworkbench.gitea.IssueStateStore
import com.cruldra.superworkbench.git.WorktreeService
import com.cruldra.superworkbench.model.Issue
import com.cruldra.superworkbench.model.IssueColumn
import com.cruldra.superworkbench.model.IssueState
import com.cruldra.superworkbench.notifications.Notifications
import com.cruldra.superworkbench.session.SessionKind
import com.cruldra.superworkbench.session.SessionService
import com.cruldra.superworkbench.session.TerminalManager
import com.cruldra.superworkbench.settings.GiteaTokenStore
import com.intellij.ide.BrowserUtil
import com.intellij.openapi.application.ApplicationManager
import com.intellij.openapi.ide.CopyPasteManager
import com.intellij.openapi.ui.Messages
import java.awt.datatransfer.StringSelection
import java.nio.file.Path
/**
* 看板加载状态机,对齐源项目 useIssues 的 `loading` / `ready` / `error` 三态。
*
* 与源 React 版的差异:源里 ViewModel 只发消息给扩展进程,由扩展进程跑 IO;这里
* ViewModel 直接持有 [GiteaApi] / [IssueLoader] 并在 IDE 后台线程池跑 IO(协程被
* exclude,统一用 [ApplicationManager.executeOnPooledThread] + [ApplicationManager.invokeLater])。
*/
sealed interface LoadState {
object Loading : LoadState
data class Error(val message: String) : LoadState
object Ready : LoadState
}
/**
* 看板 UI 的状态持有者。**不是** IntelliJ @Service——它由工具窗口 Composable 通过
* `remember` 持有,生命周期跟随工具窗口内容。
*
* 所有可观察字段用 Compose 快照状态([mutableStateOf] / [mutableStateListOf]),UI 直接读即可
* 自动重组。IO 一律走后台线程,回 UI 线程([ApplicationManager.invokeLater])改快照状态。
*/
class KanbanViewModel(private val project: com.intellij.openapi.project.Project) {
/** 当前加载状态,初始 [LoadState.Loading],首次组合时由 [load] 驱动。 */
var state: LoadState by mutableStateOf(LoadState.Loading)
private set
/** 当前看板上的全部工单,乐观更新直接改这个列表。 */
val issues: MutableList<Issue> = mutableStateListOf()
/** 选中工单的 number(点击卡片设置),驱动卡片高亮与未来的详情面板。 */
var selectedIssueNumber: Int? by mutableStateOf(null)
private set
/** 解析出来的仓库坐标,加载成功后缓存,供 [changeColumn] / [setDependency] 复用。 */
private var repoRef: GiteaRepoRef? = null
/**
* 后台解析 git origin 远端 → 校验 token → 拉取工单 → 回 UI 线程更新状态。
*
* 无 basePath / 无远端 / 无 token 三种情况都进 [LoadState.Error] 并弹通知提示去设置配 token。
*/
fun load() {
state = LoadState.Loading
runInBackground {
val basePath = project.basePath
if (basePath == null) {
finishError("当前项目没有工作目录,无法解析 Gitea 仓库")
return@runInBackground
}
val workspaceRoot = Path.of(basePath)
val ref = GitRemote.detect(workspaceRoot)
if (ref == null) {
finishError("未检测到 git origin 远端,或远端不是 Gitea 仓库")
return@runInBackground
}
if (GiteaTokenStore.get(ref.host) == null) {
finishError("缺少 ${ref.host} 的 Gitea token,请在设置里配置 token")
return@runInBackground
}
val loaded = runCatching {
IssueLoader(GiteaApi(ref.host), workspaceRoot).loadIssues(ref)
}
loaded.onSuccess { list ->
onUi {
repoRef = ref
issues.clear()
issues.addAll(list)
state = LoadState.Ready
}
}.onFailure { e ->
finishError("加载工单失败:${e.message ?: e.javaClass.simpleName}")
}
}
}
/** 重新加载,等价于再跑一次 [load]。 */
fun refresh() = load()
/** 选中工单(点击卡片),未来由详情面板消费。 */
fun select(number: Int?) {
selectedIssueNumber = number
}
/**
* 把 [issue] 移动到 [toColumn]:先本地乐观更新列表,再后台把 column 合并写回 issue 状态评论。
* 写回失败回滚并弹错误通知。
*/
fun changeColumn(issue: Issue, toColumn: IssueColumn) {
if (issue.column == toColumn) return
val ref = repoRef ?: return
val previous = issue.column
replaceIssue(issue.number) { it.copy(column = toColumn) }
runInBackground {
val result = runCatching {
IssueStateStore.mergeIssueState(
GiteaApi(ref.host),
ref.owner,
ref.repo,
issue.number.toLong(),
IssueState(column = toColumn),
)
}
result.onFailure { e ->
onUi {
replaceIssue(issue.number) { it.copy(column = previous) }
Notifications.error(project, "移动工单 #${issue.number} 失败:${e.message ?: ""}")
}
}
}
}
/**
* 给 [issue] 设置前置依赖 [prerequisiteNumber]:后台调 Gitea addDependency 后整体刷新。
* 失败弹错误通知。
*/
fun setDependency(issue: Issue, prerequisiteNumber: Int) {
val ref = repoRef ?: return
runInBackground {
val result = runCatching {
GiteaApi(ref.host).addDependency(
ref.owner,
ref.repo,
issue.number.toLong(),
prerequisiteNumber.toLong(),
)
}
result.onSuccess { onUi { refresh() } }
.onFailure { e -> onUi { Notifications.error(project, "设置前置依赖失败:${e.message ?: ""}") } }
}
}
/**
* 清除 [issue] 的前置依赖:后台调 Gitea removeDependency 后整体刷新。issue 当前无前置时直接返回。
*/
fun clearDependency(issue: Issue) {
val ref = repoRef ?: return
val prerequisite = issue.prerequisite ?: return
runInBackground {
val result = runCatching {
GiteaApi(ref.host).removeDependency(
ref.owner,
ref.repo,
issue.number.toLong(),
prerequisite.toLong(),
)
}
result.onSuccess { onUi { refresh() } }
.onFailure { e -> onUi { Notifications.error(project, "清除前置依赖失败:${e.message ?: ""}") } }
}
}
// ---- 详情面板动作 ----------------------------------------------------
//
// 这些动作对齐源 webview-ui/src/hooks/useIssues.ts 里详情面板按钮发的消息,
// 但 JetBrains 版直接在 IDE 后台线程调本地服务(SessionService/WorktreeService/
// GiteaApi),不走消息总线。会话编排自身已在后台 + 自带通知,故仅薄封装;
// 涉及 IO 的(关闭工单、删 worktree)放线程池跑。
private val sessionService get() = SessionService.getInstance(project)
private val terminalManager get() = TerminalManager.getInstance(project)
/**
* 启动或继续规划(头脑风暴)会话:无 sessionId → [SessionService.startBrainstorm] 起新会话,
* 有 → [SessionService.resumeSession] 恢复。对齐源 onStartBrainstormSession / onResumeSession。
*/
fun startOrContinueBrainstorm(issue: Issue) {
if (issue.sessionId.isNullOrEmpty()) {
sessionService.startBrainstorm(issue)
} else {
sessionService.resumeSession(issue, SessionKind.BRAINSTORM)
}
}
/**
* 实施或继续实施:无 implementSessionId → 走完整 [SessionService.startImplement](建 worktree +
* 起实施会话),有 → [SessionService.resumeSession] 恢复实施会话。启动新实施需要 planFile,
* 缺失时仅通知。对齐源 onImplement / 实施会话 resume。
*/
fun implement(issue: Issue) {
if (!issue.implementSessionId.isNullOrEmpty()) {
sessionService.resumeSession(issue, SessionKind.IMPLEMENT)
return
}
val planFile = issue.planFile
if (planFile.isNullOrEmpty()) {
Notifications.error(project, "工单 #${issue.number} 没有计划文件,无法启动实施")
return
}
sessionService.startImplement(issue, planFile)
}
/** 审查会话:恢复 codex 审查([SessionService.resumeReview])。对齐源 onResumeReviewSession。 */
fun review(issue: Issue) {
sessionService.resumeReview(issue)
}
/**
* 测试会话:无 testSessionId → [SessionService.startTest] 起新会话,有 → 恢复。
* 对齐源 onStartTestSession / onResumeTestSession。
*/
fun test(issue: Issue) {
if (issue.testSessionId.isNullOrEmpty()) {
sessionService.startTest(issue)
} else {
sessionService.resumeSession(issue, SessionKind.TEST)
}
}
/** 聚焦某会话的终端 tab。对齐源「点击会话 id 行」=focus 已存在终端。 */
fun focusSession(issue: Issue, kind: SessionKind) {
terminalManager.focus(issue.number, kind)
}
/** 关闭某会话的终端 tab。对齐源 onCloseSessionTab。 */
fun closeSessionTab(issue: Issue, kind: SessionKind) {
terminalManager.close(issue.number, kind)
}
/** 在新 IDE 窗口打开 worktree。对齐源 onOpenWorktree。worktreePath 缺失时仅通知。 */
fun openWorktree(issue: Issue) {
val rel = issue.worktreePath
if (rel.isNullOrEmpty()) {
Notifications.error(project, "工单 #${issue.number} 没有记录 worktree 路径")
return
}
val abs = Path.of(workspaceRootOrReturn() ?: return).resolve(rel)
WorktreeService.openWorktree(project, abs)
}
/**
* 删除 worktree`git worktree remove`),删前弹确认。对齐源 onDeleteWorktree。
* 成功后清空工单 state 里的 worktreePath/branch 并刷新。
*/
fun deleteWorktree(issue: Issue) {
val rel = issue.worktreePath
if (rel.isNullOrEmpty()) {
Notifications.error(project, "工单 #${issue.number} 没有记录 worktree 路径")
return
}
val confirmed = Messages.showYesNoDialog(
project,
"确定删除工单 #${issue.number} 的 worktree$rel)吗?此操作会执行 git worktree remove --force。",
"删除 worktree",
Messages.getQuestionIcon(),
)
if (confirmed != Messages.YES) return
val root = workspaceRootOrReturn() ?: return
val workspaceRoot = Path.of(root)
val abs = workspaceRoot.resolve(rel)
runInBackground {
val result = WorktreeService.removeWorktree(workspaceRoot, abs, issue.branch ?: "", issue.number)
onUi {
result.onSuccess {
Notifications.info(project, "已删除工单 #${issue.number} 的 worktree")
refresh()
}.onFailure { e ->
Notifications.error(project, "删除 worktree 失败:${e.message ?: ""}")
}
}
}
}
/** 关闭 Gitea 工单(不清理本地会话/worktree/PR/分支),成功后刷新。对齐源 onCloseIssue。 */
fun closeIssue(issue: Issue) {
val ref = repoRef ?: return
runInBackground {
val result = runCatching {
GiteaApi(ref.host).closeIssue(ref.owner, ref.repo, issue.number.toLong())
}
onUi {
result.onSuccess {
Notifications.info(project, "已关闭工单 #${issue.number}")
refresh()
}.onFailure { e ->
Notifications.error(project, "关闭工单 #${issue.number} 失败:${e.message ?: ""}")
}
}
}
}
/** 在浏览器打开工单页面。对齐源 onOpenInBrowser(issue.htmlUrl)。 */
fun openInBrowser(issue: Issue) {
BrowserUtil.browse(issue.htmlUrl)
}
/**
* 在浏览器打开关联 PR:由 issue.htmlUrl`.../issues/N`)替换为 `.../pulls/<pr>` 得到 PR 页 URL。
* 对齐源 onOpenPr(源走扩展端按仓库坐标拼 URL,此处直接从 htmlUrl 派生,语义等价)。
*/
fun openPr(issue: Issue) {
val pr = issue.pr
if (pr.isNullOrEmpty()) return
val prUrl = issue.htmlUrl.replace(Regex("/issues/\\d+$"), "/pulls/$pr")
BrowserUtil.browse(prUrl)
}
/** 复制文本到系统剪贴板(详情面板的会话 id 复制按钮)。 */
fun copyToClipboard(text: String) {
CopyPasteManager.getInstance().setContents(StringSelection(text))
Notifications.info(project, "已复制到剪贴板")
}
// TODO: generate-pr-diff-summary —— 源走扩展端后台 claude run 写 PR 变更摘要 markdown
// 依赖尚未迁移的「后台一次性 cc run + 写文件」流程,本块暂不接。
// TODO: commit/run —— 源走扩展端 git 提交工作区改动;提交编排尚未迁移,暂不接。
// TODO: git/merge-preview(本地预合并 feature 分支)—— 同属尚未迁移的 git 编排,暂不接。
// TODO: update-auto-review / update-profile-path —— 可经 IssueStateStore.mergeIssueState 落地,
// 但当前详情面板未渲染这些可编辑控件(属性区为只读展示),待属性区可编辑化后再接。
/** 解析工作区根绝对路径字符串;缺 basePath 时通知并返回 null。 */
private fun workspaceRootOrReturn(): String? {
val base = project.basePath
if (base == null) {
Notifications.error(project, "当前项目没有工作目录")
return null
}
return base
}
/** 原地替换 number 匹配的工单(用于乐观更新)。必须在 UI 线程调用。 */
private fun replaceIssue(number: Int, transform: (Issue) -> Issue) {
val index = issues.indexOfFirst { it.number == number }
if (index >= 0) issues[index] = transform(issues[index])
}
/** 在 UI 线程把 [state] 置为错误并弹通知。可从任意线程调用。 */
private fun finishError(message: String) = onUi {
state = LoadState.Error(message)
Notifications.error(project, message)
}
private fun runInBackground(block: () -> Unit) {
ApplicationManager.getApplication().executeOnPooledThread(block)
}
private fun onUi(block: () -> Unit) {
ApplicationManager.getApplication().invokeLater(block)
}
}
@@ -0,0 +1,266 @@
package com.cruldra.superworkbench.webhook
import com.cruldra.superworkbench.gitea.GiteaApi
import com.cruldra.superworkbench.gitea.GiteaRepoRef
import com.cruldra.superworkbench.gitea.GitRemote
import com.cruldra.superworkbench.gitea.IssueLoader
import com.cruldra.superworkbench.gitea.IssueStateStore
import com.cruldra.superworkbench.model.Issue
import com.cruldra.superworkbench.model.IssueColumn
import com.cruldra.superworkbench.model.IssueState
import com.cruldra.superworkbench.session.SessionService
import com.cruldra.superworkbench.settings.GiteaTokenStore
import com.cruldra.superworkbench.settings.SettingsService
import com.intellij.ide.util.PropertiesComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.project.Project
import java.nio.file.Path
import java.nio.file.Paths
/**
* 项目级 webhook 协调器,对应源项目 `src/webhook/coordinator.ts`(务实精简版)。
*
* 拥有 [WebhookServer] 的生命周期:[ensureStarted] 懒启动监听到设置端口,[stop] 关闭。
* 收到 PR 事件后照 coordinator.ts 语义处理:
* - opened:解析关联 issue → 写 pr + column=REVIEW → 删本 feature 分支用过的旧 webhook
* 防堆积 → 启用自动审查时触发 [SessionService.resumeReview]。
* - synchronize:启用自动审查时触发/复用审查会话。
* - closed 且 merged:写 prMerged=true + column=DONE。
*
* 与源差异:源采用单个共享 webhook + 手动配置;本实现照任务要求由 [SessionService]
* 实施时按 feature 分支创建 per-issue webhookhookId 经本协调器用
* [PropertiesComponent] 持久化([rememberHook] / [forgetHooksForBranch])。
*/
@Service(Service.Level.PROJECT)
class WebhookCoordinator(private val project: Project) {
private val server = WebhookServer()
/** 已注册 hookId 持久化键(项目级 [PropertiesComponent])。 */
private val hooksKey = "com.cruldra.superworkbench.webhook.pendingHooks"
// ---- 生命周期 --------------------------------------------------------
/** 懒启动 webhook 服务器到设置端口;已在该端口监听则空操作。 */
fun ensureStarted() {
val port = SettingsService.getInstance().state.webhookPort
server.start(port) { event -> handleEvent(event) }
}
/** 关闭 webhook 服务器(未启动则空操作)。 */
fun stop() {
server.stop()
}
/** 当前监听端口,未启动时为 null。 */
val currentPort: Int? get() = server.currentPort
// ---- hookId 持久化 ----------------------------------------------------
/**
* 记住一个已注册的 hookId(连同其 feature 分支),供后续按分支去重清理。
* 存成 `branch|hookId` 列表,以 `;` 分隔,写进项目级 [PropertiesComponent]。
*/
fun rememberHook(branch: String, hookId: Long) {
val props = PropertiesComponent.getInstance(project)
val entries = readHookEntries(props).toMutableList()
entries.add(HookEntry(branch, hookId))
writeHookEntries(props, entries)
thisLogger().info("记住 webhook hookId=$hookId branch=$branch")
}
/**
* 删除某 feature 分支此前注册过的全部 webhook(防同分支重复实施时 hook 堆积),
* 并把它们从持久化列表移除。需要 [api]+[owner]+[repo] 调 Gitea 删除。
*/
private fun forgetHooksForBranch(api: GiteaApi, owner: String, repo: String, branch: String) {
val props = PropertiesComponent.getInstance(project)
val all = readHookEntries(props)
val (stale, keep) = all.partition { it.branch == branch }
if (stale.isEmpty()) return
for (entry in stale) {
runCatching { api.deleteHook(owner, repo, entry.hookId) }
.onFailure { thisLogger().warn("删除旧 webhook 失败 hookId=${entry.hookId}", it) }
}
writeHookEntries(props, keep)
thisLogger().info("清理 branch=$branch 旧 webhook ${stale.size}")
}
private fun readHookEntries(props: PropertiesComponent): List<HookEntry> {
val raw = props.getValue(hooksKey).orEmpty()
if (raw.isEmpty()) return emptyList()
return raw.split(';').mapNotNull { token ->
val parts = token.split('|')
if (parts.size != 2) return@mapNotNull null
val id = parts[1].toLongOrNull() ?: return@mapNotNull null
HookEntry(parts[0], id)
}
}
private fun writeHookEntries(props: PropertiesComponent, entries: List<HookEntry>) {
if (entries.isEmpty()) {
props.unsetValue(hooksKey)
return
}
props.setValue(hooksKey, entries.joinToString(";") { "${it.branch}|${it.hookId}" })
}
private data class HookEntry(val branch: String, val hookId: Long)
// ---- 事件处理 --------------------------------------------------------
/**
* 处理一条已规范化的 PR 事件,照 coordinator.ts 的 action 分发。异常吞掉,
* 避免拖垮 webhook 服务线程。
*/
private fun handleEvent(event: PrWebhookEvent) {
try {
val ctx = resolveRepo() ?: run {
thisLogger().warn("webhook 事件忽略:无法解析仓库上下文 action=${event.action} pr=#${event.pr}")
return
}
val issueNumber = resolveIssueNumber(event) ?: run {
thisLogger().warn("webhook 事件忽略:无法定位工单 branch=${event.branch} pr=#${event.pr}")
return
}
when (event.action) {
"opened", "reopened" -> handlePrOpened(ctx, issueNumber, event)
"synchronize", "synchronized" -> handlePrSynchronize(ctx, issueNumber, event)
"closed" -> handlePrClosed(ctx, issueNumber, event)
else -> thisLogger().info("未处理 webhook action=${event.action} issue=#$issueNumber pr=#${event.pr}")
}
} catch (e: Exception) {
thisLogger().warn("webhook handleEvent 异常 action=${event.action} pr=#${event.pr}", e)
}
}
/**
* opened/reopened:把 pr 写进工单 state、把列推到 REVIEW,删除本 feature 分支用过的
* 旧 webhook(防堆积),启用自动审查时触发审查会话。
*/
private fun handlePrOpened(ctx: RepoContext, issueNumber: Long, event: PrWebhookEvent) {
runCatching {
IssueStateStore.mergeIssueState(
ctx.api, ctx.repoRef.owner, ctx.repoRef.repo, issueNumber,
IssueState(pr = event.pr.toString(), column = IssueColumn.REVIEW),
)
}.onFailure { thisLogger().warn("写回 pr/column 失败 (#$issueNumber)", it) }
// 同分支重复实施会注册新 webhook,旧的留在 Gitea 会重复投递;这里按分支去重清理。
if (event.branch.isNotEmpty()) {
forgetHooksForBranch(ctx.api, ctx.repoRef.owner, ctx.repoRef.repo, event.branch)
}
if (autoReviewEnabled(ctx, issueNumber)) {
triggerReview(ctx, issueNumber)
} else {
thisLogger().info("跳过自动审查 #$issueNumberautoReview=off")
}
}
/** synchronize:启用自动审查时触发/复用审查会话。 */
private fun handlePrSynchronize(ctx: RepoContext, issueNumber: Long, event: PrWebhookEvent) {
if (autoReviewEnabled(ctx, issueNumber)) {
triggerReview(ctx, issueNumber)
} else {
thisLogger().info("跳过 synchronize 自动审查 #$issueNumberautoReview=off")
}
}
/**
* closed:仅当已合并才落 prMerged=true + column=DONE。merged 状态优先取 payload
* 缺省时回查一次 PR API 兜底(对齐 coordinator.ts)。
*
* TODO(prMergedAt): 持久化的 [IssueState] schema 暂无 prMergedAt 字段,仅运行时
* [Issue] 模型有;合并时间由 IssueLoader 从 PR API 实时补齐,故此处不写。
*/
private fun handlePrClosed(ctx: RepoContext, issueNumber: Long, event: PrWebhookEvent) {
val merged = event.merged || runCatching {
ctx.api.getPull(ctx.repoRef.owner, ctx.repoRef.repo, event.pr).merged
}.getOrDefault(false)
if (!merged) {
thisLogger().info("PR #${event.pr} closed 但未合并,忽略 issue=#$issueNumber")
return
}
runCatching {
IssueStateStore.mergeIssueState(
ctx.api, ctx.repoRef.owner, ctx.repoRef.repo, issueNumber,
IssueState(prMerged = true, column = IssueColumn.DONE),
)
}.onFailure { thisLogger().warn("写回 prMerged/column 失败 (#$issueNumber)", it) }
}
// ---- 触发审查 --------------------------------------------------------
/**
* 加载该工单并交 [SessionService.resumeReview] 触发审查会话。需要完整 [Issue]
* reviewSessionId/worktreePath),故走 [IssueLoader] 拿全量再按号过滤。
*
* TODO(impl-terminal): 源 coordinator 把审查反馈反向注入实施终端、自动推进列等
* 枝节依赖未迁移的 panel,本实现只触发审查会话。
*/
private fun triggerReview(ctx: RepoContext, issueNumber: Long) {
val issue = loadIssue(ctx, issueNumber)
if (issue == null) {
thisLogger().warn("触发审查失败:未加载到工单 #$issueNumber")
return
}
SessionService.getInstance(project).resumeReview(issue)
}
private fun loadIssue(ctx: RepoContext, issueNumber: Long): Issue? =
runCatching {
IssueLoader(ctx.api, ctx.workspaceRoot)
.loadIssues(ctx.repoRef)
.firstOrNull { it.number.toLong() == issueNumber }
}.getOrNull()
// ---- 工具 ------------------------------------------------------------
/**
* 叠加全局 [SettingsService] 的 autoReview 与工单级 [IssueState.autoReview]
* 工单级显式置位(非空)时优先,否则跟随全局设置。对齐 coordinator.ts。
*/
private fun autoReviewEnabled(ctx: RepoContext, issueNumber: Long): Boolean {
val global = SettingsService.getInstance().state.autoReview
val perIssue = runCatching {
IssueStateStore.readIssueState(ctx.api, ctx.repoRef.owner, ctx.repoRef.repo, issueNumber)?.autoReview
}.getOrNull()
return perIssue ?: global
}
/**
* 定位事件归属的工单号:优先 legacy 路径带来的 [PrWebhookEvent.issueNumber],否则
* 从 PR body 解析 `Closes/Fixes/Resolves #N`(大小写不敏感)。都找不到返回 null。
*/
private fun resolveIssueNumber(event: PrWebhookEvent): Long? {
event.issueNumber?.let { return it }
val match = CLOSES_RE.find(event.body) ?: return null
return match.groupValues[1].toLongOrNull()
}
/** 解析当前工作区对应的 Gitea 仓库 + token,构造 [GiteaApi]。失败时返回 null。 */
private fun resolveRepo(): RepoContext? {
val root = project.basePath ?: return null
val workspaceRoot = Paths.get(root)
val repoRef = GitRemote.detect(workspaceRoot) ?: return null
val token = GiteaTokenStore.get(repoRef.host)
if (token.isNullOrEmpty()) return null
return RepoContext(workspaceRoot, repoRef, GiteaApi(repoRef.host))
}
private data class RepoContext(
val workspaceRoot: Path,
val repoRef: GiteaRepoRef,
val api: GiteaApi,
)
companion object {
private val CLOSES_RE = Regex("""\b(?:closes|fixes|resolves|close|fix|resolve)\s+#(\d+)""", RegexOption.IGNORE_CASE)
fun getInstance(project: Project): WebhookCoordinator =
project.getService(WebhookCoordinator::class.java)
}
}
@@ -0,0 +1,83 @@
package com.cruldra.superworkbench.webhook
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Gitea webhook payload DTO(务实精简)。
*
* 对应源项目 `src/webhook/server.ts` 里 `parseEvent` 实际读取的字段,仅保留
* `pull_request` 事件流所需的字段——`action`、`pull_request`(number/merged/merged_at/
* head.ref/base.ref/body)、`repository`(owner/name)。issue/issue_comment/push 等其它
* 事件不在本模块迁移范围(依赖未迁移的 panel 注入/branch-sync 等枝节),故不建 DTO。
*
* 反序列化统一走带 `ignoreUnknownKeys=true` 的解析器(见 [WebhookServer]),容忍
* Gitea 发来的大量额外字段。
*/
@Serializable
data class GiteaWebhookPayload(
/** 事件动作:`opened` / `reopened` / `synchronized` / `closed` 等。push 事件无此字段。 */
val action: String? = null,
@SerialName("pull_request") val pullRequest: WebhookPullRequest? = null,
val repository: WebhookRepository? = null,
)
/** webhook payload 里的 `pull_request` 子对象。 */
@Serializable
data class WebhookPullRequest(
val number: Long = 0,
val title: String = "",
val body: String = "",
val merged: Boolean = false,
@SerialName("merged_at") val mergedAt: String? = null,
@SerialName("html_url") val htmlUrl: String = "",
val head: WebhookBranchRef? = null,
val base: WebhookBranchRef? = null,
)
/** PR head/base 引用,`ref` 是分支名。 */
@Serializable
data class WebhookBranchRef(
val ref: String = "",
)
/** webhook payload 里的 `repository` 子对象,取 owner.login + name 定位仓库。 */
@Serializable
data class WebhookRepository(
val name: String = "",
val owner: WebhookRepoOwner? = null,
)
/** repository.owner,取 login。 */
@Serializable
data class WebhookRepoOwner(
val login: String = "",
)
/**
* 经 [WebhookServer] 解析、规范化后的 PR 事件,转交 [WebhookCoordinator] 处理。
*
* 对应源 `PrWebhookEvent`(去掉了 issue/issue_comment/push 变体)。`issueNumber`
* 在 canonical `/webhook` 路由下为 null,由协调器从 PR body `Closes #N` 解析;legacy
* `/webhook/<n>` 路由下直接带上。
*/
data class PrWebhookEvent(
/** legacy 路径带来的 issue 号;canonical 路径为 null。 */
val issueNumber: Long?,
/** PR 动作。 */
val action: String,
/** PR number。 */
val pr: Long,
/** PR head 分支名。 */
val branch: String,
/** PR 浏览器 URL。 */
val htmlUrl: String,
/** PR 标题。 */
val title: String,
/** PR 正文,供 `Closes #N` 解析。 */
val body: String,
/** PR 是否已合并(closed 事件用)。 */
val merged: Boolean,
/** PR 合并时间(ISO 8601)。 */
val mergedAt: String?,
)
@@ -0,0 +1,149 @@
package com.cruldra.superworkbench.webhook
import com.intellij.openapi.diagnostic.thisLogger
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpServer
import kotlinx.serialization.json.Json
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
import java.util.concurrent.Executors
/**
* 基于 JDK 内置 [HttpServer] 的极简 webhook 服务器,对应源项目
* `src/webhook/server.ts`(仅保留 `pull_request` 事件流)。
*
* 接收 Gitea webhook POST,解析请求体 JSON,规范化为 [PrWebhookEvent] 后交给
* [start] 传入的 handler。不引第三方 HTTP 库;后台请求由独立线程池
* executor 处理,不阻塞 IDE 线程。
*
* 接受的路由(照搬源 server.ts):
* - `POST /webhook` canonicalissueNumber 留空,协调器从 PR body 解析。
* - `POST /webhook/<digits>` legacyissueNumber 取自路径。
*
* 其它路径 404、非 POST 405、JSON 解析失败 400;非 pull_request 或缺字段照样回 200
* 但跳过分发(让 Gitea 不要激进重投)。
*/
class WebhookServer {
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
private var server: HttpServer? = null
private var boundPort: Int? = null
/** 当前绑定端口,未监听时为 null。 */
val currentPort: Int? get() = boundPort
/**
* 在 [port] 上启动(或迁移)服务器。已在同端口监听则空操作;监听在别的端口则
* 先停旧再起新。[handler] 收到每个解析成功的 PR 事件(可能多线程并发调用,需自行
* 保证线程安全)。
*/
fun start(port: Int, handler: (PrWebhookEvent) -> Unit) {
if (server != null && boundPort == port) return
if (server != null) stop()
val httpServer = HttpServer.create(InetSocketAddress(port), 0)
httpServer.executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "superworkbench-webhook").apply { isDaemon = true }
}
httpServer.createContext("/webhook") { exchange -> handle(exchange, handler) }
httpServer.start()
server = httpServer
boundPort = port
thisLogger().info("webhook HTTP server listening on :$port")
}
/** 停止服务器(未启动则空操作)。 */
fun stop() {
val srv = server ?: return
server = null
boundPort = null
// delay=0:立即关闭,不等待 in-flight 请求。
srv.stop(0)
thisLogger().info("webhook HTTP server 已停止")
}
private fun handle(exchange: HttpExchange, handler: (PrWebhookEvent) -> Unit) {
try {
val method = exchange.requestMethod
val path = exchange.requestURI.path ?: ""
thisLogger().info("webhook 收到 $method $path")
if (!method.equals("POST", ignoreCase = true)) {
respond(exchange, 405, """{"ok":false,"error":"method_not_allowed"}""")
return
}
// 两种路由:/webhookissueNumber 后解析)/ /webhook/<digits>legacy)。
val legacy = LEGACY_PATH.matchEntire(path)
val issueNumber: Long? = when {
legacy != null -> legacy.groupValues[1].toLongOrNull()
path == "/webhook" -> null
else -> {
thisLogger().warn("webhook 路径不匹配: $path")
respond(exchange, 404, """{"ok":false,"error":"not_found"}""")
return
}
}
val rawBody = exchange.requestBody.readBytes().toString(StandardCharsets.UTF_8)
val eventHeader = exchange.requestHeaders.getFirst("X-Gitea-Event") ?: "<missing>"
thisLogger().info("webhook X-Gitea-Event=$eventHeader bodyLen=${rawBody.length}")
val event = parseEvent(issueNumber, rawBody)
if (event != null) {
val issuePart = event.issueNumber?.let { "issue=#$it" } ?: "path=/webhook"
thisLogger().info("webhook 匹配 action=${event.action} PR #${event.pr} 分支 ${event.branch} $issuePart")
runCatching { handler(event) }
.onFailure { thisLogger().warn("webhook handler 异常", it) }
} else {
thisLogger().info("webhook 未处理事件,已忽略 (X-Gitea-Event=$eventHeader)")
}
respond(exchange, 200, """{"ok":true}""")
} catch (e: Exception) {
thisLogger().warn("webhook 处理顶层异常", e)
runCatching { respond(exchange, 500, """{"ok":false,"error":"internal_error"}""") }
} finally {
exchange.close()
}
}
/**
* 校验并规范化 pull_request payload。缺字段或非 pull_request 返回 nullHTTP 仍回
* 200)。`issueNumber` 原样透传——canonical 路由下为 null,协调器再从 body/branch 解析。
*/
private fun parseEvent(issueNumber: Long?, rawBody: String): PrWebhookEvent? {
val payload = runCatching { json.decodeFromString<GiteaWebhookPayload>(rawBody) }
.getOrElse {
thisLogger().warn("webhook 请求体解析失败: ${rawBody.take(500)}")
return null
}
val action = payload.action ?: return null
val pr = payload.pullRequest ?: return null
val branch = pr.head?.ref.orEmpty()
if (branch.isEmpty() || pr.htmlUrl.isEmpty()) return null
return PrWebhookEvent(
issueNumber = issueNumber,
action = action,
pr = pr.number,
branch = branch,
htmlUrl = pr.htmlUrl,
title = pr.title,
body = pr.body,
merged = pr.merged,
mergedAt = pr.mergedAt,
)
}
private fun respond(exchange: HttpExchange, status: Int, body: String) {
val bytes = body.toByteArray(StandardCharsets.UTF_8)
exchange.responseHeaders.add("Content-Type", "application/json")
exchange.sendResponseHeaders(status, bytes.size.toLong())
exchange.responseBody.use { it.write(bytes) }
}
private companion object {
val LEGACY_PATH = Regex("""^/webhook/(\d+)$""")
}
}
@@ -0,0 +1,33 @@
<idea-plugin>
<id>com.cruldra.superworkbench</id>
<name>Superworkbench</name>
<vendor email="cruldra@gmail.com" url="https://github.com/cruldra">cruldra</vendor>
<description><![CDATA[
Superpowers specs/plans Kanban board with Claude/opencode session orchestration for Gitea.
Native JetBrains port of superpowers-vscode.
]]></description>
<depends>com.intellij.modules.platform</depends>
<depends>Git4Idea</depends>
<depends>org.jetbrains.plugins.terminal</depends>
<extensions defaultExtensionNs="com.intellij">
<toolWindow
id="Superworkbench"
anchor="right"
factoryClass="com.cruldra.superworkbench.toolwindow.KanbanToolWindowFactory"/>
<notificationGroup
id="Superworkbench"
displayType="BALLOON"/>
<applicationService
serviceImplementation="com.cruldra.superworkbench.settings.SettingsService"/>
<applicationConfigurable
instance="com.cruldra.superworkbench.settings.SuperworkbenchConfigurable"
id="com.cruldra.superworkbench.settings"
displayName="Superworkbench"/>
</extensions>
</idea-plugin>
@@ -0,0 +1,28 @@
/superpowers:brainstorming 讨论下 {issueNumber} 号工单
用tea命令找工单
## 后续 marker 维护(本会话有效)
spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支。创建后追加对应 marker:
```
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
```
## 严禁擅自继续
如果你创建/更新了 spec/plan 文件并 marker 已同步,**立即停下**汇报;不要进入实施流程。
特别地:
- 不要创建分支
- 不要切换分支,包括 `git checkout` / `git switch`
- 不要修改当前主 worktree 所在分支
- spec/plan 一律在 main 分支(当前主 worktree)创建
- 不要修改任何代码文件
- 不要创建 PR
- 不要调用 gitea 其他写操作(除上面的 marker 同步)
只讨论需求与 spec/plan,必要时用 spx issue marker 更新 marker。
@@ -0,0 +1,48 @@
接下来我要实现下面这样的功能: {userRequest}
你的任务:用 spx CLI 创建一个 Gitea 工单。spx 用法参考 `using-spx-cli` skill。
## 工单格式
先检查仓库的 `.gitea/ISSUE_TEMPLATE/` 目录:
- 有模板(`.md``.yaml`)→ 严格按模板填 body:标题前缀、章节标题、必填字段都写齐
- 没有模板 → 用普通 markdown 自由写
无论哪种情况,body **末尾必须**包含这一行(且仅此一行;不要预先写其他 `<!-- spx:* -->` marker):
```
<!-- spx:nonce={nonce} -->
```
## 创建
把 body 写到 `/tmp/issue-body.md`,调 spx
```
opencli spx issue create --title "<标题>" --body-file /tmp/issue-body.md
```
spx 返回工单号 + html_url。记下工单号,后续命令用。
## 后续 marker 维护(本会话有效)
**只有**当你真正创建了 spec 或 plan 文件后才追加对应 marker。路径形如 `docs/superpowers/specs/<slug>/spec.md` 或 `docs/superpowers/plans/<slug>/plan.md`。spec/plan 文件**一律在当前主 worktree(main 分支)创建**,不要为此新建或切换分支:
```
opencli spx issue marker --issue <工单号> --type spec --value <spec 路径>
opencli spx issue marker --issue <工单号> --type plan --value <plan 路径>
```
spx 自动找到对应行替换或追加,保留所有其他 marker。**不要自己手写 `<!-- spx:* -->` 行**。
## 严禁擅自继续
成功创建工单后**立即停下**汇报:输出工单号 + html_url 即可。
特别地:
- 不要创建分支
- 不要切换分支
- 不要修改当前主 worktree 所在分支
- spec/plan 一律在 main 分支(当前主 worktree)创建
@@ -0,0 +1,13 @@
/goal 使用子代理全程绿灯实施 @{planFile},发起 PR 时务必在 PR body 中包含 "Closes #{issueNumber}"。
**严禁合并 PR**:你的职责只到发起 PR 为止,后续审查反馈到了请继续修复并 push,永远不要执行 `tea pulls merge` 或任何合并操作。
## 数据库迁移(alembic
多个 feature worktree 共用同一台 dev DB`192.168.1.4:5433`)。任何 worktree 直接在共享库上跑迁移,都会让别的分支 `alembic upgrade` 崩(DB 里记着的 revision 在对方代码里不存在)。所以本会话:
- 只用 `uv run alembic revision --autogenerate -m "..."` 生成迁移文件,**绝不手写 revision 文件**。
- 生成后立即 `git add` 提交迁移文件,纳入 PR。
- **绝不**对共享 dev DB`192.168.1.4:5433`)或 prod 执行 `alembic upgrade` / `downgrade` 等任何改库命令。
- 需要运行时验证迁移,就起一个一次性独立库(本地 docker postgres 或唯一命名的 scratch 库),在它上面 upgrade,验证完即丢弃,不要留痕。
- 共享 dev DB 与 prod 的迁移合并后由用户统一执行,时机由用户决定。
@@ -0,0 +1,18 @@
/review 审查这个仓库的 PR #{prNumber}。
`tea pulls {prNumber}` 看 PR 元信息(标题/描述/分支)。看代码差异用 git(当前目录就是 PR 分支的 worktree):先 `git fetch origin main`,再 `git diff origin/main...HEAD`(概览可加 --stat)。注意 tea 没有 `diff` 子命令,不要尝试 `tea pulls diff`
## 提交审查意见
把审查意见(markdown 格式)写到 `/tmp/review-{prNumber}.md`,调 spx
```
opencli spx pr review-comment --pr {prNumber} --body-file /tmp/review-{prNumber}.md
## 严禁
- 不要在审查意见里建议"合并 PR"或"merge"
- 不要执行 `tea pulls merge` 或任何合并命令
- 不要 push 到 main / dev 分支
合并权完全在用户手上,你的工作只是指出问题或确认通过。