feat: notify ClaudeQQ on commander brief updates
This commit is contained in:
+442
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user