diff --git a/AGENTS.md b/AGENTS.md index a7c5d9ec..dddc0687 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,7 @@ UniDesk 是一个以主 server 为统一入口的分布式工作平台;本文 - `bun scripts/cli.ts deploy check/plan/apply [--file deploy.json|--env dev|prod] [--service ]`:按根目录 `deploy.json` 或 `origin/master:deploy.json#environments.` 的服务 repo 和 commit 期望状态校验或更新用户服务;`--env dev` 开放 D601 `backend-core` rollout、reviewed registry artifact consumers 和 D601 direct consumer validation,`findjob`/`pipeline` 是 D601 direct pull-only 样板,`met-nonlinear` dry-run blocked,`k3sctl-adapter` supervisor-only,`code-queue` prod unsupported,规则见 `docs/reference/deploy.md` 与 `docs/reference/dev-environment.md`。 - `bun scripts/cli.ts dev-env validate [--manifest path] [--kubectl-dry-run]` / `dev-env prewarm-images`:离线校验 D601 `unidesk-dev` 生产隔离护栏和 dev workload manifests,或把开发底座基础镜像预热到 D601 原生 k3s containerd,规则见 `docs/reference/deploy.md` 与 `docs/reference/microservices.md`。 - `bun scripts/cli.ts artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service`:管理 D601 host-managed CNCF Distribution registry,并通过短生命周期 relay 或 D601 pull/import 做 commit-pinned pull-only artifact CD;`deploy-backend-core` 是 deprecated 兼容名,`findjob`/`pipeline` 支持 D601 direct dev/prod,`met-nonlinear` 和 `k3sctl-adapter` 只给受限计划路径,`code-queue` 只支持 dev,规则见 `docs/reference/artifact-registry.md`。 -- `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|comment`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、escape 扫描和 PR 创建/评论;`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 +- `bun scripts/cli.ts gh auth status|issue ...|pr list|view|create|comment`:通过 REST 执行安全 GitHub issue 读写、脱敏 auth/status 诊断、body-file Markdown 写入、#24 指挥简报新增时间线 ClaudeQQ 通知、escape 扫描和 PR 创建/评论;`gh pr merge` 当前仍结构化拒绝,规则见 `docs/reference/cli.md` 和 `docs/reference/code-queue-supervision.md`。 - `bun scripts/cli.ts ci install/status/run/publish-backend-core/publish-user-service/run-dev-e2e/logs`:在 D601 原生 k3s 上安装和运行 Tekton CI,支持每 commit 检查、Code Queue 只读性能门禁、`CI.json` catalog 驱动的 backend-core 与 user-service commit-pinned 镜像发布和手动触发的 `origin/master:deploy.json#environments.dev` 临时 namespace e2e;catalog/producer/consumer 分工见 `docs/reference/cicd-standardization.md`,`run-dev-e2e` 的 Git 控制 runner、短 launcher 和 no-CD 边界见 `docs/reference/dev-ci-runner.md`,Tekton 规则见 `docs/reference/ci.md`。 - `bun scripts/cli.ts codex deploy `:旧 Code Queue 兼容部署入口已禁用,原因是它会绕过受控部署边界直连 D601 部署 Code Queue;规则见 `docs/reference/codex-deploy.md`。 - `bun scripts/cli.ts codex submit [prompt] [--prompt-file path|--prompt-stdin] [--queue ]`:通过 backend-core 私有代理提交 Code Queue 任务;控制面默认走主 server `code-queue-mgr` 写入 PostgreSQL,`--dry-run` 可只检查请求体不入队,规则见 `docs/reference/cli.md`。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c3ba46ce..245f3920 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -30,7 +30,8 @@ CLI 可以从 `master` 快速演进,但必须兼容 `deploy.json` 固定的 CI - `dev-env prewarm-images [--image image] [--provider-id D601] [--no-pull] [--proxy-url URL] [--pull-timeout-ms N] [--dry-run]` 创建异步 job,通过 UniDesk SSH 维护桥在 D601 上把开发底座依赖镜像从 Docker 缓存导入原生 k3s containerd。默认镜像是 `postgres:16-alpine` 和 `rancher/mirrored-library-busybox:1.36.1`,用于避免 `postgres-dev` 与 local-path helper pod 卡在外部 registry 拉取。该命令固定验证 `/etc/rancher/k3s/k3s.yaml` 指向的 native k3s 上下文,并输出 `dev_env_containerd_image_ready=...` 作为成功判据;它不 apply manifest、不修改生产 `unidesk` namespace。 - `artifact-registry plan|render|status|health|install|deploy-backend-core|deploy-service` 管理 D601 host-managed CNCF Distribution registry 的声明、安装、只读检查和 pull-only artifact CD。该 registry 固定为 D601 loopback `127.0.0.1:5000`,由 systemd + Docker Compose 管理,位于 native k3s 故障域外;`deploy-service` 只拉取 CI 已发布的 commit-pinned 镜像、retag/recreate 或导入 native k3s,并做 live commit 验证,不构建 runtime source。`deploy-backend-core` 是 deprecated 兼容名,标准 backend-core prod CD 入口是 `deploy apply --env prod --service backend-core`。长期规则见 `docs/reference/artifact-registry.md`。 - `gh auth status [--repo owner/name]` 探测 GitHub 操作前置条件并输出脱敏 JSON:是否存在 `gh` binary、是否存在 `GH_TOKEN`/`GITHUB_TOKEN` 或可用 `gh auth token` fallback、REST API 是否可达、目标 repo 是否可见、issue 是否可读。degraded reason 必须归类为 `missing-binary`、`missing-token`、`auth-failed`、`network-proxy-failed`、`permission-denied`、`repo-not-found`、`repo-forbidden`、`issue-not-found`、`pr-not-found`、`scope-insufficient`、`validation-failed`、`invalid-response` 或 `unsupported-command`,不得打印 token;失败对象必须包含 `runnerDisposition=infra-blocked|business-failed`,runner 应优先用该字段分流。 -- `gh issue view [--repo owner/name]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON。`gh issue create --title --body-file <file> [--dry-run]`、`gh issue edit <number> --body-file <file> [--title ...] [--dry-run]`、`gh issue comment <number> --body-file <file> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary;`--dry-run` 不调用 GitHub,只返回 repo/title/bodyChars/bodyPreview/bodyPreviewLines 和 newline/Markdown 检测结果。 +- `gh issue view <number> [--repo owner/name]` 通过 GitHub REST 读取 issue title/body/state/url 和 comments,默认输出 JSON。`gh issue create --title <title> --body-file <file> [--dry-run]`、`gh issue edit <number> --body-file <file> [--title ...] [--dry-run]`、`gh issue comment <number> --body-file <file> [--dry-run]`、`gh issue close|reopen <number> [--dry-run]` 都走 REST,不依赖 `gh` binary;普通 `--dry-run` 不调用 GitHub,只返回 repo/title/bodyChars/bodyPreview/bodyPreviewLines 和 newline/Markdown 检测结果。 +- `gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]` 是指挥简报 #24 的通知入口。正式执行会先读取 GitHub 上 #24 旧正文,再从 `--body-file` 读取新正文;随后先 PATCH issue 主体,再把本次新增的 `## 更新 YYYY-MM-DD HH:MM 北京时间` 段落发送给 ClaudeQQ,ClaudeQQ 失败不会回滚 issue 正文。带通知 flag 的 `--dry-run` 不 PATCH、不发送,也不要求 GitHub 凭证;它按新正文做发送预览,并在输出中标明非 dry-run 才会读取旧正文做可靠 diff。dry-run 输出包含将要 patch 的 issue、body 字符数、是否检测到新增 brief diff、待发送文本预览、字符数、目标类型和脱敏目标信息。默认 ClaudeQQ 目标是私聊 `645275593`,默认 base URL 是 UniDesk 受控入口 `http://backend-core:8080/api/microservices/claudeqq/proxy`,可用 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID`、`UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID` 和 `UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS` 覆盖。 - `gh issue scan-escape [--repo owner/name] [--limit N]` 只读扫描 issue 主体和 comments 中的字面量 `\n`、可疑 `\t`、shell newline escape 和 ANSI escape 字符串,输出 issue/comment id、url、kind、snippet,不自动修复。`gh pr list|view` 继续提供 REST 只读列表和详情;`gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--draft] [--dry-run]` 与 `gh pr comment <number> --body-file <file>|--body <text> [--dry-run]` 是安全写入口。`pr create --dry-run` 只输出 planned operation,不访问 GitHub;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,成功时返回 PR number/url。`pr comment --dry-run` 只输出计划并保留 Markdown 原始换行和反引号;非 dry-run 会先确认 PR 存在再写入 issue comment。`gh pr merge` 本阶段不开放,始终结构化返回 `unsupported-command`。 - `ci install|status|run|publish-backend-core|publish-user-service|run-dev-e2e|logs` 管理 D601 原生 k3s 上的 Tekton CI。`run` 手动创建每 commit 检查和 Code Queue 只读性能门禁;`publish-backend-core` 与 `publish-user-service` 从 pushed Git commit 构建并发布 `127.0.0.1:5000/unidesk/<service>:<commit>` commit-pinned artifacts,输出 `artifactSummary`(含 `serviceId`、`sourceCommit`、`sourceRepo`、`dockerfile`、`imageRef`、`tag`、`digest`、`digestRef`),但不部署生产;`run-dev-e2e` 的 Git 控制 runner、短 launcher、host fetch 边界、临时 smoke namespace 和 no-CD 规则只在 `docs/reference/dev-ci-runner.md` 定义;Tekton CI 通用规则见 `docs/reference/ci.md`。 - `codex deploy <commitId>` 是旧 Code Queue 兼容部署入口,已禁用以防止维护通道直连 D601 部署 Code Queue;当前 dev 自动化只做 `ci run-dev-e2e` smoke,不提供 Code Queue CD,详细规则见 `docs/reference/codex-deploy.md`。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 1f0fb1c1..c6f26407 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -61,7 +61,7 @@ GitHub issue/PR 操作应优先使用 UniDesk CLI 的安全入口:`bun scripts CLI 是短 shout 的需求原语,不是长驻服务器进程。CLI 功能不好用、兼容性不足、安全 guard 不够或输出不利于 runner/指挥官使用时,应默认创建 GitHub issue 并用 Code Queue 推进;这类 CLI 问题走 `master`、remote commit、轻量 contract test 和文档更新,不套用 backend-core、Code Queue runtime 这类运行态服务的重部署门禁。若 CLI 缺陷已经阻塞当前指挥,可以先做最小安全绕行,同时把长期修复写入 issue 并派单。 -所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。`gh issue` 写命令第一阶段不接受 stdin 正文;需要更新 #20 总看板、#24 指挥简报或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue edit|comment|create ... --body-file <file>`;更新看板和简报应使用 `issue edit` 更新主体,除非明确需要追加评论。提交前或巡检时可用 `gh issue scan-escape --limit N` 只读扫描污染,不自动修复。 +所有 GitHub Markdown 正文写入必须来自 `--body-file <file>`。不要使用 `gh issue comment --body`、`gh api -f body=...` 或把多行正文直接拼进 shell 参数;这些路径容易把真实换行、反引号和 Markdown 表格污染成字面量 `\n` 或 shell escape。`gh issue` 写命令第一阶段不接受 stdin 正文;需要更新 #20 总看板或创建新 issue/comment 时,先把正文写入 Markdown 文件,再运行 `bun scripts/cli.ts gh issue edit|comment|create ... --body-file <file>`。更新 #24 指挥简报主体时使用 `bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff`,命令会先读取旧正文并只把本次新增的 `## 更新 ... 北京时间` 时间线段落推送给 ClaudeQQ;头部“常驻观察与长期建议”等非时间线修改不会单独通知。发送失败只体现在返回 JSON 的 `claudeqq.ok=false`,不回滚已经写入的 GitHub issue。提交前或巡检时可用 `gh issue scan-escape --limit N` 只读扫描污染,不自动修复。 PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。默认 master-only 交付仍按项目 Git 规则执行;当变更风险高、跨模块、需要人工审查、或任务目标明确要求 PR 交付时,worker 可以创建 PR。PR 型任务必须报告源分支、目标分支、PR URL、关联 issue、测试证据和未完成风险。禁止把 PR 当成隐藏分支仓库;PR 分支必须来自最新目标线,保持小而可审查,并在合并后确认目标分支远端 commit 可 fetch。 diff --git a/scripts/gh-commander-brief-contract-test.ts b/scripts/gh-commander-brief-contract-test.ts new file mode 100644 index 00000000..f17cf632 --- /dev/null +++ b/scripts/gh-commander-brief-contract-test.ts @@ -0,0 +1,88 @@ +import { commanderBriefDiff } from "./src/gh"; + +type JsonRecord = Record<string, unknown>; + +function assertCondition(condition: unknown, message: string, detail: unknown = {}): void { + if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`); +} + +export function runGhCommanderBriefContract(): JsonRecord { + const oldBody = [ + "# 指挥简报", + "", + "## 常驻观察与长期建议", + "", + "- 保持队列监督。", + "", + "## 更新 2026-05-20 17:28 北京时间", + "", + "- 已完成初始观察。", + "", + ].join("\n"); + const newSection = [ + "## 更新 2026-05-20 18:05 北京时间", + "", + "- 新增进展包含 `code`。", + "", + "| 项 | 状态 |", + "| --- | --- |", + "| GitHub | ready |", + "", + "真实换行必须保留。", + ].join("\n"); + + const appended = commanderBriefDiff(oldBody, `${oldBody}${newSection}\n`); + assertCondition(appended.ok === true, "append-only update should be detected", appended); + assertCondition(appended.mode === "append-only", "append-only mode should be reported", appended); + assertCondition(appended.sectionCount === 1, "one appended section should be extracted", appended); + assertCondition(appended.message === newSection, "appended section text should be exact", { message: appended.message }); + assertCondition(appended.message.includes("\n| 项 | 状态 |"), "markdown table should keep real newline", { message: appended.message }); + assertCondition(appended.message.includes("`code`"), "backticks should be preserved", { message: appended.message }); + assertCondition(!appended.message.includes("\\n"), "real newlines must not become literal backslash-n", { message: appended.message }); + + const identical = commanderBriefDiff(oldBody, oldBody); + assertCondition(identical.ok === false, "identical body should skip", identical); + assertCondition(identical.mode === "identical", "identical mode should be reported", identical); + assertCondition(String(identical.skippedReason ?? "").length > 0, "identical skip reason should be present", identical); + + const headerOnly = commanderBriefDiff(oldBody, oldBody.replace("- 保持队列监督。", "- 保持队列监督,并记录阻塞。")); + assertCondition(headerOnly.ok === false, "header-only modification should skip", headerOnly); + assertCondition(headerOnly.mode === "heading-diff", "header-only modification should use heading diff mode", headerOnly); + + const reordered = commanderBriefDiff( + oldBody, + [ + "# 指挥简报", + "", + "## 常驻观察与长期建议", + "", + "- 保持队列监督。", + "", + newSection, + "", + "## 更新 2026-05-20 17:28 北京时间", + "", + "- 已完成初始观察。", + "", + ].join("\n"), + ); + assertCondition(reordered.ok === true, "non append-only new heading should be detected", reordered); + assertCondition(reordered.mode === "heading-diff", "non append-only new heading should use heading-diff", reordered); + assertCondition(reordered.message === newSection, "heading diff should return only new section", { message: reordered.message }); + + return { + ok: true, + checks: [ + "append-only commander brief section extraction", + "identical body skip", + "header-only long-term observation edit skip", + "non append-only heading diff extraction", + "backticks, markdown table, and real newlines preserved", + ], + }; +} + +if (import.meta.main) { + const result = runGhCommanderBriefContract(); + process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); +} diff --git a/scripts/src/gh.ts b/scripts/src/gh.ts index 424198cb..f8154e9f 100644 --- a/scripts/src/gh.ts +++ b/scripts/src/gh.ts @@ -1,11 +1,15 @@ import { execFileSync } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; +import { coreInternalFetch } from "./microservices"; const DEFAULT_REPO = "pikasTech/unidesk"; const GITHUB_API = "https://api.github.com"; const USER_AGENT = "unidesk-cli-gh"; const PREVIEW_CHARS = 240; const REQUEST_TIMEOUT_MS = 20_000; +const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy"; +const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593"; +const COMMANDER_BRIEF_TARGET_ISSUE = 24; type GitHubDegradedReason = | "missing-binary" @@ -24,6 +28,55 @@ type GitHubDegradedReason = | "unsupported-command"; type RunnerDisposition = "infra-blocked" | "business-failed"; +type ClaudeQqTargetType = "private" | "group"; +type CommanderBriefDiffMode = "identical" | "append-only" | "heading-diff" | "unreliable"; + +interface CommanderBriefDiff { + ok: boolean; + mode: CommanderBriefDiffMode; + message: string; + chars: number; + sections: string[]; + sectionCount: number; + skippedReason?: string; +} + +interface ClaudeQqConfig { + enabled: boolean; + baseUrl: string; + targetType: ClaudeQqTargetType; + userId?: string; + groupId?: string; + timeoutMs: number; +} + +interface ClaudeQqSendResult { + ok: boolean; + attempted: boolean; + skipped?: boolean; + skippedReason?: string; + endpoint?: string; + status?: number; + degradedReason?: string; + message?: string; + response?: unknown; + target: Record<string, unknown>; +} + +interface ClaudeQqEndpointResult { + ok: boolean; + endpoint: string; + status?: number; + degradedReason?: string; + message?: string; + response?: unknown; +} + +interface CommanderBriefSection { + heading: string; + text: string; + startLine: number; +} interface GitHubCommandResult { ok: boolean; @@ -48,6 +101,7 @@ interface GitHubOptions { dryRun: boolean; limit: number; draft: boolean; + notifyClaudeQqBriefDiff: boolean; title?: string; body?: string; bodyFile?: string; @@ -154,7 +208,7 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe function validateKnownOptions(args: string[]): void { const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head"]); - const flagOptions = new Set(["--dry-run", "--draft"]); + const flagOptions = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff"]); for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (!arg.startsWith("--")) continue; @@ -174,6 +228,7 @@ function parseOptions(args: string[]): GitHubOptions { dryRun: hasFlag(args, "--dry-run"), limit: positiveIntegerOption(args, "--limit", 30, 100), draft: hasFlag(args, "--draft"), + notifyClaudeQqBriefDiff: hasFlag(args, "--notify-claudeqq-brief-diff"), title: optionValue(args, "--title"), body: optionValue(args, "--body"), bodyFile: optionValue(args, "--body-file"), @@ -273,13 +328,17 @@ function preview(text: string): string { return text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}...` : text; } +function previewLines(text: string, maxLines = 12): string[] { + return text.split(/\r?\n/).slice(0, maxLines); +} + function dryRunBody(repo: string, title: string | undefined, body: string): Record<string, unknown> { return { repo, ...(title === undefined ? {} : { title }), bodyChars: body.length, bodyPreview: preview(body), - bodyPreviewLines: body.split(/\r?\n/).slice(0, 12), + bodyPreviewLines: previewLines(body), preservesRawNewlines: body.includes("\n"), containsLiteralBackslashN: body.includes("\\n"), containsBackticks: body.includes("`"), @@ -287,6 +346,312 @@ function dryRunBody(repo: string, title: string | undefined, body: string): Reco }; } +function normalizeNewlines(text: string): string { + return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); +} + +function isTimelineHeading(line: string): boolean { + return /^##\s+更新\s+\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\s+北京时间\s*$/u.test(line.trimEnd()); +} + +function extractTimelineSections(markdown: string): CommanderBriefSection[] { + const normalized = normalizeNewlines(markdown); + const lines = normalized.split("\n"); + const sections: CommanderBriefSection[] = []; + let currentStart = -1; + for (let index = 0; index < lines.length; index += 1) { + if (!isTimelineHeading(lines[index])) continue; + if (currentStart !== -1) { + sections.push({ + heading: lines[currentStart].trimEnd(), + text: lines.slice(currentStart, index).join("\n").trimEnd(), + startLine: currentStart + 1, + }); + } + currentStart = index; + } + if (currentStart !== -1) { + sections.push({ + heading: lines[currentStart].trimEnd(), + text: lines.slice(currentStart).join("\n").trimEnd(), + startLine: currentStart + 1, + }); + } + return sections.filter((section) => section.text.length > 0); +} + +export function commanderBriefDiff(oldBodyRaw: string, newBodyRaw: string): CommanderBriefDiff { + const oldBody = normalizeNewlines(oldBodyRaw); + const newBody = normalizeNewlines(newBodyRaw); + if (oldBody === newBody) { + return { + ok: false, + mode: "identical", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: "issue body is unchanged", + }; + } + + if (newBody.startsWith(oldBody)) { + const suffix = newBody.slice(oldBody.length); + const sections = extractTimelineSections(suffix).map((section) => section.text); + const message = sections.join("\n\n").trim(); + if (message.length === 0) { + return { + ok: false, + mode: "append-only", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: "append-only suffix contains no new commander brief update section", + }; + } + return { ok: true, mode: "append-only", message, chars: message.length, sections, sectionCount: sections.length }; + } + + const oldSections = new Set(extractTimelineSections(oldBody).map((section) => section.text)); + const allNewSections = extractTimelineSections(newBody); + const newSections = allNewSections.map((section) => section.text).filter((section) => !oldSections.has(section)); + if (newSections.length === 0) { + return { + ok: false, + mode: "heading-diff", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: "no new commander brief update section found; non-timeline edits are not notified", + }; + } + const duplicateNewHeadings = allNewSections.some((section, index) => allNewSections.findIndex((candidate) => candidate.heading === section.heading) !== index); + const oldHeadings = new Set(extractTimelineSections(oldBody).map((section) => section.heading)); + const ambiguousChangedHeadings = allNewSections.some((section) => oldHeadings.has(section.heading) && !oldSections.has(section.text)); + if (duplicateNewHeadings || ambiguousChangedHeadings) { + return { + ok: false, + mode: "unreliable", + message: "", + chars: 0, + sections: [], + sectionCount: 0, + skippedReason: duplicateNewHeadings + ? "duplicate update headings make commander brief diff unreliable" + : "an existing update section changed; cannot reliably isolate only new timeline content", + }; + } + const message = newSections.join("\n\n").trim(); + return { ok: true, mode: "heading-diff", message, chars: message.length, sections: newSections, sectionCount: newSections.length }; +} + +function envEnabled(name: string, defaultValue: boolean): boolean { + const raw = process.env[name]; + if (raw === undefined || raw.length === 0) return defaultValue; + return !["0", "false", "no", "off", "disabled"].includes(raw.toLowerCase()); +} + +function positiveEnvInteger(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (raw === undefined || raw.length === 0) return defaultValue; + const value = Number(raw); + return Number.isInteger(value) && value > 0 ? value : defaultValue; +} + +function commanderBriefClaudeQqConfig(): ClaudeQqConfig { + const targetTypeRaw = (process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TARGET_TYPE ?? "private").toLowerCase(); + const targetType: ClaudeQqTargetType = targetTypeRaw === "group" ? "group" : "private"; + return { + enabled: envEnabled("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED", true), + baseUrl: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL, + targetType, + userId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_USER_ID ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, + groupId: process.env.UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID, + timeoutMs: positiveEnvInteger("UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_TIMEOUT_MS", 15_000), + }; +} + +function maskedTarget(config: ClaudeQqConfig): Record<string, unknown> { + if (config.targetType === "group") return { targetType: "group", groupId: config.groupId === undefined ? null : maskId(config.groupId) }; + return { targetType: "private", userId: config.userId === undefined ? null : maskId(config.userId) }; +} + +function maskId(value: string): string { + if (value.length <= 4) return "*".repeat(value.length); + return `${value.slice(0, 2)}***${value.slice(-2)}`; +} + +function sanitizeUrlForOutput(value: string): string { + try { + const parsed = new URL(value); + parsed.username = parsed.username.length > 0 ? "***" : ""; + parsed.password = parsed.password.length > 0 ? "***" : ""; + parsed.search = parsed.search.length > 0 ? "?..." : ""; + parsed.hash = ""; + return parsed.toString(); + } catch { + return value.includes("?") ? `${value.split("?")[0]}?...` : value; + } +} + +function claudeQqPayload(config: ClaudeQqConfig, message: string): Record<string, unknown> { + if (config.targetType === "group") return { targetType: "group", groupId: config.groupId ?? "", message }; + return { targetType: "private", userId: config.userId ?? DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID, message }; +} + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function proxiedClaudeQqBasePath(baseUrl: string): string | null { + try { + const parsed = new URL(baseUrl); + const path = parsed.pathname.replace(/\/$/u, ""); + if (parsed.hostname === "backend-core" && path === "/api/microservices/claudeqq/proxy") return path; + if ((parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost") && parsed.port === "8080" && path === "/api/microservices/claudeqq/proxy") return path; + return null; + } catch { + return baseUrl === "/api/microservices/claudeqq/proxy" ? baseUrl : null; + } +} + +function normalizeClaudeQqEndpoint(basePath: string, endpoint: string): string { + return `${basePath.replace(/\/$/u, "")}${endpoint}`; +} + +function claudeQqResponseOk(response: unknown): boolean { + if (!isRecord(response)) return false; + if (response.ok === false) return false; + if (typeof response.status === "number" && (response.status < 200 || response.status >= 300)) return false; + const body = response.body; + if (isRecord(body) && (body.ok === false || body.success === false)) return false; + return response.ok === true || typeof response.status === "number"; +} + +function summarizeClaudeQqProxyFailure(response: unknown, endpoint: string): ClaudeQqEndpointResult { + if (!isRecord(response)) return { ok: false, endpoint, degradedReason: "invalid-response", message: "ClaudeQQ proxy returned a non-object response", response }; + const status = typeof response.status === "number" ? response.status : undefined; + const stdoutTail = typeof response.stdoutTail === "string" ? response.stdoutTail : ""; + let parsedTail: unknown = null; + if (stdoutTail.length > 0) { + try { + parsedTail = JSON.parse(stdoutTail.trim()) as unknown; + } catch { + parsedTail = null; + } + } + return { + ok: false, + endpoint, + status, + degradedReason: response.ok === false ? "microservice-proxy-failed" : "invalid-response", + message: typeof response.error === "string" + ? response.error + : typeof response.stderrTail === "string" && response.stderrTail.length > 0 + ? response.stderrTail.slice(-500) + : "ClaudeQQ proxy request failed", + response: parsedTail ?? response, + }; +} + +async function sendClaudeQqEndpoint(config: ClaudeQqConfig, endpoint: string, payload: Record<string, unknown>): Promise<ClaudeQqEndpointResult> { + const basePath = proxiedClaudeQqBasePath(config.baseUrl); + if (basePath !== null) { + const response = coreInternalFetch(normalizeClaudeQqEndpoint(basePath, endpoint), { + method: "POST", + body: payload, + maxResponseBytes: 240_000, + }); + if (claudeQqResponseOk(response)) return { ok: true, endpoint, status: isRecord(response) && typeof response.status === "number" ? response.status : 200, response }; + return summarizeClaudeQqProxyFailure(response, endpoint); + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeoutMs); + try { + const response = await fetch(normalizeClaudeQqEndpoint(config.baseUrl, endpoint), { + method: "POST", + signal: controller.signal, + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(payload), + }); + const parsed = await parseGitHubResponse(response); + const bodyFailed = isRecord(parsed) && (parsed.ok === false || parsed.success === false); + if (response.ok && !bodyFailed) return { ok: true, endpoint, status: response.status, response: parsed }; + return { + ok: false, + endpoint, + status: response.status, + degradedReason: bodyFailed ? "upstream-rejected" : "http-failed", + message: isRecord(parsed) && typeof parsed.error === "string" ? parsed.error : response.statusText, + response: sanitizedErrorDetails(parsed), + }; + } catch (error) { + return { + ok: false, + endpoint, + degradedReason: "network-proxy-failed", + message: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timeout); + } +} + +async function sendCommanderBriefClaudeQq(config: ClaudeQqConfig, message: string): Promise<ClaudeQqSendResult> { + const target = maskedTarget(config); + if (!config.enabled) { + return { ok: true, attempted: false, skipped: true, skippedReason: "UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_ENABLED disabled", target }; + } + if (config.targetType === "group" && (config.groupId === undefined || config.groupId.length === 0)) { + return { ok: false, attempted: false, skipped: true, skippedReason: "group target requires UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_GROUP_ID", target }; + } + const payload = claudeQqPayload(config, message); + const first = await sendClaudeQqEndpoint(config, "/api/push/text", payload); + if (first.ok) return { ok: true, attempted: true, endpoint: first.endpoint, status: first.status, response: first.response, target }; + const second = await sendClaudeQqEndpoint(config, "/api/send/text", payload); + if (second.ok) return { ok: true, attempted: true, endpoint: second.endpoint, status: second.status, response: second.response, target }; + return { + ok: false, + attempted: true, + endpoint: second.endpoint, + status: second.status ?? first.status, + degradedReason: second.degradedReason ?? first.degradedReason ?? "claudeqq-send-failed", + message: second.message ?? first.message ?? "ClaudeQQ send failed", + response: { attempts: [first, second] }, + target, + }; +} + +function commanderBriefNotificationPlan(issueNumber: number, body: string, diff: CommanderBriefDiff, config: ClaudeQqConfig): Record<string, unknown> { + return { + enabled: config.enabled, + issueNumber, + bodyChars: body.length, + diff: { + ok: diff.ok, + mode: diff.mode, + chars: diff.chars, + sectionCount: diff.sectionCount, + skippedReason: diff.skippedReason ?? null, + preview: diff.ok ? preview(diff.message) : "", + previewLines: diff.ok ? previewLines(diff.message) : [], + containsLiteralBackslashN: diff.message.includes("\\n"), + containsBackticks: diff.message.includes("`"), + containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message), + }, + claudeqq: { + dryRun: true, + wouldSend: config.enabled && diff.ok, + baseUrl: sanitizeUrlForOutput(config.baseUrl), + target: maskedTarget(config), + timeoutMs: config.timeoutMs, + }, + }; +} + function runnerDisposition(reason: GitHubDegradedReason): RunnerDisposition { if (reason === "unsupported-command" || reason === "validation-failed" || reason === "issue-not-found" || reason === "pr-not-found") return "business-failed"; return "infra-blocked"; @@ -707,9 +1072,13 @@ async function listIssueComments(token: string, repo: string, issueNumber: numbe return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`); } -async function issueView(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> { +async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> { const { owner, name } = repoParts(repo); - const issue = await githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`); + return githubRequest<GitHubIssue>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}`); +} + +async function issueView(repo: string, token: string, issueNumber: number): Promise<GitHubCommandResult> { + const issue = await getIssue(token, repo, issueNumber); if (isGitHubError(issue)) return commandError("issue view", repo, issue, { issueNumber }); const comments = await listIssueComments(token, repo, issueNumber); if (isGitHubError(comments)) return commandError("issue view", repo, comments, { issueNumber, issue: issueSummary(issue) }); @@ -734,7 +1103,20 @@ async function issueCreate(repo: string, token: string, options: GitHubOptions): async function issueEdit(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { const body = readBodyFile(options.bodyFile, "issue edit"); + let oldIssue: GitHubIssue | null = null; + let briefDiff: CommanderBriefDiff | null = null; + const claudeQqConfig = commanderBriefClaudeQqConfig(); + if (options.notifyClaudeQqBriefDiff && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE) { + return validationError("issue edit", repo, "--notify-claudeqq-brief-diff is only supported for commander brief issue #24", { issueNumber }); + } + if (options.notifyClaudeQqBriefDiff && !options.dryRun) { + const issue = await getIssue(token, repo, issueNumber); + if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber, phase: "read-before-edit" }); + oldIssue = issue; + briefDiff = commanderBriefDiff(issue.body ?? "", body); + } if (options.dryRun) { + const dryRunDiff = options.notifyClaudeQqBriefDiff ? commanderBriefDiff("", body) : null; return { ok: true, command: "issue edit", @@ -743,6 +1125,15 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio issueNumber, ...dryRunBody(repo, options.title, body), wouldPatch: { title: options.title ?? null, bodyFromFile: options.bodyFile }, + ...(options.notifyClaudeQqBriefDiff + ? { + commanderBriefNotification: commanderBriefNotificationPlan(issueNumber, body, dryRunDiff ?? commanderBriefDiff("", body), claudeQqConfig), + dryRunOldBodySource: "not-fetched", + dryRunDiffReliability: "new-body-only preview; non-dry-run reads the GitHub issue body before PATCH and sends only sections absent from the old body", + dryRunNoWrite: true, + dryRunNoClaudeQqSend: true, + } + : {}), }; } const { owner, name } = repoParts(repo); @@ -750,7 +1141,42 @@ async function issueEdit(repo: string, token: string, issueNumber: number, optio if (options.title !== undefined) payload.title = options.title; const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, payload); if (isGitHubError(issue)) return commandError("issue edit", repo, issue, { issueNumber }); - return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true }; + if (!options.notifyClaudeQqBriefDiff) return { ok: true, command: "issue edit", repo, issue: issueSummary(issue), rest: true }; + + const diff = briefDiff ?? commanderBriefDiff(oldIssue?.body ?? "", body); + const claudeqq = diff.ok + ? await sendCommanderBriefClaudeQq(claudeQqConfig, diff.message) + : { + ok: true, + attempted: false, + skipped: true, + skippedReason: diff.skippedReason ?? "no commander brief diff to send", + target: maskedTarget(claudeQqConfig), + } satisfies ClaudeQqSendResult; + return { + ok: true, + command: "issue edit", + repo, + issue: issueSummary(issue), + rest: true, + commanderBriefNotification: { + issueNumber, + oldIssueUpdatedAt: oldIssue?.updated_at ?? null, + diff: { + ok: diff.ok, + mode: diff.mode, + chars: diff.chars, + sectionCount: diff.sectionCount, + skippedReason: diff.skippedReason ?? null, + preview: diff.ok ? preview(diff.message) : "", + previewLines: diff.ok ? previewLines(diff.message) : [], + containsLiteralBackslashN: diff.message.includes("\\n"), + containsBackticks: diff.message.includes("`"), + containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(diff.message), + }, + }, + claudeqq, + }; } async function issueComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> { @@ -945,6 +1371,7 @@ export function ghHelp(): unknown { "bun scripts/cli.ts gh issue view <number> [--repo owner/name]", "bun scripts/cli.ts gh issue create --title <title> --body-file <file> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue edit <number> --body-file <file> [--title title] [--repo owner/name] [--dry-run]", + "bun scripts/cli.ts gh issue edit 24 --body-file <file> --notify-claudeqq-brief-diff [--dry-run]", "bun scripts/cli.ts gh issue comment <number> --body-file <file> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue close|reopen <number> [--repo owner/name] [--dry-run]", "bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N]", @@ -959,6 +1386,8 @@ export function ghHelp(): unknown { "Token values are never printed; auth status reports only token source and presence.", "--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.", "Issue body stdin is intentionally unsupported in this CLI; write generated Markdown to a file and pass --body-file.", + "issue edit 24 --notify-claudeqq-brief-diff reads the old issue body, PATCHes the new body, and sends only newly added '## 更新 ... 北京时间' sections to ClaudeQQ; ClaudeQQ failure does not roll back GitHub.", + "Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.", "PR create/comment are safe-write operations with dry-run planning; merge is intentionally unsupported in this phase.", ], }; @@ -978,13 +1407,20 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult ? unsupportedCommand(command, repo, message) : validationError(command, repo, message); } + if (options.notifyClaudeQqBriefDiff && !(top === "issue" && sub === "edit")) { + const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh"; + return validationError(command, options.repo, "--notify-claudeqq-brief-diff is only supported by gh issue edit 24"); + } if (top === "auth" && sub === "status") return authStatus(options.repo); if (top === "issue") { if (options.dryRun) { if (sub === "create") return issueCreate(options.repo, "", options); - if (sub === "edit") return issueEdit(options.repo, "", parseNumber(third, "issue edit"), options); + if (sub === "edit") { + const issueNumber = parseNumber(third, "issue edit"); + return issueEdit(options.repo, "", issueNumber, options); + } if (sub === "comment") return issueComment(options.repo, "", parseNumber(third, "issue comment"), options); if (sub === "close") return issueState(options.repo, "", parseNumber(third, "issue close"), "closed", true); if (sub === "reopen") return issueState(options.repo, "", parseNumber(third, "issue reopen"), "open", true);