11
This commit is contained in:
@@ -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}:取单个 issue,404 返回 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:创建后只需回 id;config 保留备查。 */
|
||||
@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`(仅 gitea,YouTrack 逻辑不迁移)。
|
||||
*
|
||||
* 流程:
|
||||
* 1. `getCurrentUser` 取 login。
|
||||
* 2. 顺序拉 `assigned_by=login` 与 `created_by=login` 两个 issue 列表,按 number 去重合并。
|
||||
* 3. 拉全仓库评论 firehose,按 issue number 分组。
|
||||
* 4. 每个 issue:从评论尾部取最近一条 state JSON。解析到 column 就用它;否则按
|
||||
* issue.state(open→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,
|
||||
/** 后端无关的审查会话 id(v1 存 codex thread id)。 */
|
||||
val reviewSessionId: String? = null,
|
||||
/** 测试会话的 Claude Code session id。 */
|
||||
val testSessionId: String? = null,
|
||||
/** Gitea issue 页面的浏览器 URL。 */
|
||||
val htmlUrl: String,
|
||||
/** 前置任务 issue number(Gitea 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,
|
||||
/** 审查阶段的会话 id(v1 存 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-op;worktree 已真正接入(见 [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 / worktreePath(workspace 相对)/ 列 / 状态写回工单 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` 的 webhook(content_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}-{规划|实施|审查|测试}`)。
|
||||
* - 维护 key(issueNumber + 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
|
||||
}
|
||||
}
|
||||
+198
@@ -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("如 main,Jenkins 不监听它")
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
+110
@@ -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 webhook,hookId 经本协调器用
|
||||
* [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("跳过自动审查 #$issueNumber(autoReview=off)")
|
||||
}
|
||||
}
|
||||
|
||||
/** synchronize:启用自动审查时触发/复用审查会话。 */
|
||||
private fun handlePrSynchronize(ctx: RepoContext, issueNumber: Long, event: PrWebhookEvent) {
|
||||
if (autoReviewEnabled(ctx, issueNumber)) {
|
||||
triggerReview(ctx, issueNumber)
|
||||
} else {
|
||||
thisLogger().info("跳过 synchronize 自动审查 #$issueNumber(autoReview=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` canonical:issueNumber 留空,协调器从 PR body 解析。
|
||||
* - `POST /webhook/<digits>` legacy:issueNumber 取自路径。
|
||||
*
|
||||
* 其它路径 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
|
||||
}
|
||||
|
||||
// 两种路由:/webhook(issueNumber 后解析)/ /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 返回 null(HTTP 仍回
|
||||
* 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 分支
|
||||
|
||||
合并权完全在用户手上,你的工作只是指出问题或确认通过。
|
||||
Reference in New Issue
Block a user