1477 lines
58 KiB
TypeScript
1477 lines
58 KiB
TypeScript
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"
|
|
| "missing-token"
|
|
| "auth-failed"
|
|
| "egress-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";
|
|
|
|
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;
|
|
repo: string;
|
|
command: string;
|
|
degradedReason?: GitHubDegradedReason;
|
|
degraded?: GitHubDegradedReason[];
|
|
runnerDisposition?: RunnerDisposition;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
interface GitHubTokenProbe {
|
|
present: boolean;
|
|
source: "GH_TOKEN" | "GITHUB_TOKEN" | "gh-auth-token" | null;
|
|
ghFallbackAttempted: boolean;
|
|
ghBinaryFound?: boolean;
|
|
ghAuthTokenAvailable?: boolean | null;
|
|
}
|
|
|
|
interface GitHubOptions {
|
|
repo: string;
|
|
dryRun: boolean;
|
|
limit: number;
|
|
draft: boolean;
|
|
notifyClaudeQqBriefDiff: boolean;
|
|
title?: string;
|
|
body?: string;
|
|
bodyFile?: string;
|
|
base?: string;
|
|
head?: string;
|
|
}
|
|
|
|
interface GitHubErrorPayload {
|
|
ok: false;
|
|
degradedReason: GitHubDegradedReason;
|
|
runnerDisposition: RunnerDisposition;
|
|
status?: number;
|
|
message: string;
|
|
details?: unknown;
|
|
scopes?: {
|
|
accepted: string | null;
|
|
token: string | null;
|
|
};
|
|
request?: {
|
|
method: string;
|
|
path: string;
|
|
};
|
|
}
|
|
|
|
interface GitHubIssue {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
state: string;
|
|
html_url: string;
|
|
comments: number;
|
|
user?: { login?: string };
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
interface GitHubComment {
|
|
id: number;
|
|
body: string | null;
|
|
html_url: string;
|
|
user?: { login?: string };
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
interface GitHubPullRequest {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
state: string;
|
|
html_url: string;
|
|
draft?: boolean;
|
|
user?: { login?: string };
|
|
head?: { ref?: string; sha?: string };
|
|
base?: { ref?: string; sha?: string };
|
|
created_at?: string;
|
|
updated_at?: string;
|
|
}
|
|
|
|
interface GitHubRepository {
|
|
id?: number;
|
|
full_name?: string;
|
|
private?: boolean;
|
|
default_branch?: string;
|
|
permissions?: Record<string, boolean>;
|
|
}
|
|
|
|
interface GitHubBranch {
|
|
name?: string;
|
|
commit?: { sha?: string };
|
|
}
|
|
|
|
interface GitHubCompare {
|
|
status?: string;
|
|
ahead_by?: number;
|
|
behind_by?: number;
|
|
total_commits?: number;
|
|
html_url?: string;
|
|
base_commit?: { sha?: string };
|
|
merge_base_commit?: { sha?: string };
|
|
}
|
|
|
|
function optionValue(args: string[], name: string): string | undefined {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return undefined;
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.length === 0 || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
function hasFlag(args: string[], name: string): boolean {
|
|
return args.includes(name);
|
|
}
|
|
|
|
function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
|
|
const raw = optionValue(args, name);
|
|
if (raw === undefined) return defaultValue;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
return Math.min(value, maxValue);
|
|
}
|
|
|
|
function validateKnownOptions(args: string[]): void {
|
|
const valueOptions = new Set(["--repo", "--limit", "--title", "--body-file", "--body", "--base", "--head"]);
|
|
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;
|
|
if (flagOptions.has(arg)) continue;
|
|
if (valueOptions.has(arg)) {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
throw new Error(`unknown gh option: ${arg}`);
|
|
}
|
|
}
|
|
|
|
function parseOptions(args: string[]): GitHubOptions {
|
|
validateKnownOptions(args);
|
|
return {
|
|
repo: optionValue(args, "--repo") ?? DEFAULT_REPO,
|
|
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"),
|
|
base: optionValue(args, "--base"),
|
|
head: optionValue(args, "--head"),
|
|
};
|
|
}
|
|
|
|
function parseNumber(raw: string | undefined, label: string): number {
|
|
if (raw === undefined) throw new Error(`${label} requires a number`);
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${label} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
function parseNumberForCommand(repo: string, raw: string | undefined, label: string): number | GitHubCommandResult {
|
|
try {
|
|
return parseNumber(raw, label);
|
|
} catch (error) {
|
|
return validationError(label, repo, error instanceof Error ? error.message : String(error));
|
|
}
|
|
}
|
|
|
|
function readBodyFile(path: string | undefined, command: string): string {
|
|
if (path === undefined) throw new Error(`${command} requires --body-file <file>`);
|
|
if (!existsSync(path)) throw new Error(`body file not found: ${path}`);
|
|
return readFileSync(path, "utf8");
|
|
}
|
|
|
|
function readMarkdownBody(options: GitHubOptions, command: string): { body: string; bodySource: Record<string, unknown> } {
|
|
if (options.bodyFile !== undefined && options.body !== undefined) throw new Error(`${command} accepts only one body source: --body-file or --body`);
|
|
if (options.bodyFile !== undefined) {
|
|
const body = readBodyFile(options.bodyFile, command);
|
|
return { body, bodySource: { kind: "body-file", path: options.bodyFile } };
|
|
}
|
|
if (options.body !== undefined) {
|
|
return {
|
|
body: options.body,
|
|
bodySource: {
|
|
kind: "inline",
|
|
warning: options.body.includes("\n") ? "inline body contains real newlines; --body-file is safer for generated Markdown" : "inline body is intended only for short single-line text",
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`${command} requires --body-file <file> or --body <text>`);
|
|
}
|
|
|
|
function tokenFromEnvironment(): GitHubTokenProbe {
|
|
if (process.env.GH_TOKEN && process.env.GH_TOKEN.length > 0) {
|
|
return { present: true, source: "GH_TOKEN", ghFallbackAttempted: false };
|
|
}
|
|
if (process.env.GITHUB_TOKEN && process.env.GITHUB_TOKEN.length > 0) {
|
|
return { present: true, source: "GITHUB_TOKEN", ghFallbackAttempted: false };
|
|
}
|
|
return { present: false, source: null, ghFallbackAttempted: false };
|
|
}
|
|
|
|
function ghBinaryPath(): string | null {
|
|
try {
|
|
const output = execFileSync("sh", ["-lc", "command -v gh"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
return output.length > 0 ? output : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function ghAuthToken(): string | null {
|
|
try {
|
|
const output = execFileSync("gh", ["auth", "token"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5000 }).trim();
|
|
return output.length > 0 ? output : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveToken(allowGhFallback: boolean): { token: string | null; probe: GitHubTokenProbe } {
|
|
const envProbe = tokenFromEnvironment();
|
|
if (envProbe.present) {
|
|
const token = envProbe.source === "GH_TOKEN" ? process.env.GH_TOKEN ?? null : process.env.GITHUB_TOKEN ?? null;
|
|
return { token, probe: envProbe };
|
|
}
|
|
if (!allowGhFallback) return { token: null, probe: envProbe };
|
|
const ghPath = ghBinaryPath();
|
|
if (ghPath === null) return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: false, ghAuthTokenAvailable: null } };
|
|
const token = ghAuthToken();
|
|
if (token !== null) return { token, probe: { present: true, source: "gh-auth-token", ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: true } };
|
|
return { token: null, probe: { present: false, source: null, ghFallbackAttempted: true, ghBinaryFound: true, ghAuthTokenAvailable: false } };
|
|
}
|
|
|
|
function repoParts(repo: string): { owner: string; name: string } {
|
|
const [owner, name, extra] = repo.split("/");
|
|
if (!owner || !name || extra !== undefined) throw new Error("--repo must be in owner/name format");
|
|
return { owner, name };
|
|
}
|
|
|
|
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: previewLines(body),
|
|
preservesRawNewlines: body.includes("\n"),
|
|
containsLiteralBackslashN: body.includes("\\n"),
|
|
containsBackticks: body.includes("`"),
|
|
containsMarkdownTable: /^\s*\|.+\|\s*$/m.test(body),
|
|
};
|
|
}
|
|
|
|
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";
|
|
}
|
|
|
|
function sanitizedErrorDetails(parsed: unknown): unknown {
|
|
if (typeof parsed !== "object" || parsed === null) return parsed;
|
|
const source = parsed as Record<string, unknown>;
|
|
const details: Record<string, unknown> = {};
|
|
if ("message" in source) details.message = source.message;
|
|
if ("documentation_url" in source) details.documentationUrl = source.documentation_url;
|
|
if ("status" in source) details.status = source.status;
|
|
if (Array.isArray(source.errors)) details.errors = source.errors.slice(0, 5);
|
|
if ("raw" in source) details.raw = source.raw;
|
|
return details;
|
|
}
|
|
|
|
function errorPayload(reason: GitHubDegradedReason, message: string, extra: Omit<GitHubErrorPayload, "ok" | "degradedReason" | "runnerDisposition" | "message"> = {}): GitHubErrorPayload {
|
|
return {
|
|
ok: false,
|
|
degradedReason: reason,
|
|
runnerDisposition: runnerDisposition(reason),
|
|
message,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function commandError(command: string, repo: string, error: GitHubErrorPayload, extra: Record<string, unknown> = {}): GitHubCommandResult {
|
|
return {
|
|
ok: false,
|
|
command,
|
|
repo,
|
|
degradedReason: error.degradedReason,
|
|
runnerDisposition: error.runnerDisposition,
|
|
details: error,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function validationError(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
|
|
const error = errorPayload("validation-failed", message, { details: extra });
|
|
return commandError(command, repo, error, extra);
|
|
}
|
|
|
|
function unsupportedCommand(command: string, repo: string, message: string, extra: Record<string, unknown> = {}): GitHubCommandResult {
|
|
const error = errorPayload("unsupported-command", message, { details: extra });
|
|
return commandError(command, repo, error, { message, ...extra });
|
|
}
|
|
|
|
async function parseGitHubResponse(response: Response): Promise<unknown> {
|
|
const text = await response.text();
|
|
if (text.length === 0) return null;
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch {
|
|
return { raw: preview(text) };
|
|
}
|
|
}
|
|
|
|
function classifyHttpStatus(status: number, message: string, path: string, response: Response): GitHubDegradedReason {
|
|
const lower = message.toLowerCase();
|
|
const acceptedScopes = response.headers.get("x-accepted-oauth-scopes");
|
|
if (status === 401) return "auth-failed";
|
|
if (status === 403) {
|
|
if (lower.includes("resource not accessible") || lower.includes("insufficient") || lower.includes("scope") || (acceptedScopes !== null && acceptedScopes.length > 0)) return "scope-insufficient";
|
|
if (path.startsWith("/repos/")) return "repo-forbidden";
|
|
return "permission-denied";
|
|
}
|
|
if (status === 404) {
|
|
if (/\/pulls\/\d+/u.test(path)) return "pr-not-found";
|
|
if (/\/issues\/\d+/u.test(path)) return "issue-not-found";
|
|
if (path.includes("/branches/") || path.includes("/compare/")) return "validation-failed";
|
|
return "repo-not-found";
|
|
}
|
|
if (status === 422) return "validation-failed";
|
|
return "invalid-response";
|
|
}
|
|
|
|
async function githubRequest<T>(
|
|
token: string,
|
|
method: string,
|
|
path: string,
|
|
body?: Record<string, unknown>,
|
|
): Promise<T | GitHubErrorPayload> {
|
|
let response: Response;
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
try {
|
|
response = await fetch(`${GITHUB_API}${path}`, {
|
|
method,
|
|
signal: controller.signal,
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
"User-Agent": USER_AGENT,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
} catch (error) {
|
|
return errorPayload("network-proxy-failed", error instanceof Error ? error.message : String(error), { request: { method, path } });
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
const parsed = await parseGitHubResponse(response);
|
|
if (!response.ok) {
|
|
const message = typeof parsed === "object" && parsed !== null && "message" in parsed ? String((parsed as { message?: unknown }).message) : response.statusText;
|
|
return errorPayload(classifyHttpStatus(response.status, message, path, response), message, {
|
|
status: response.status,
|
|
details: sanitizedErrorDetails(parsed),
|
|
scopes: {
|
|
accepted: response.headers.get("x-accepted-oauth-scopes"),
|
|
token: response.headers.get("x-oauth-scopes"),
|
|
},
|
|
request: { method, path },
|
|
});
|
|
}
|
|
return parsed as T;
|
|
}
|
|
|
|
function authRequired(repo: string, command: string, tokenProbe: GitHubTokenProbe): GitHubCommandResult | null {
|
|
if (!tokenProbe.present) {
|
|
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === false) {
|
|
return commandError(command, repo, errorPayload("missing-binary", "gh binary is missing and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
|
|
}
|
|
if (tokenProbe.ghFallbackAttempted && tokenProbe.ghBinaryFound === true && tokenProbe.ghAuthTokenAvailable === false) {
|
|
return commandError(command, repo, errorPayload("auth-failed", "gh auth token failed and no GH_TOKEN/GITHUB_TOKEN is available", { details: tokenProbe }));
|
|
}
|
|
return commandError(command, repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: tokenProbe }), {
|
|
degraded: ["missing-token"],
|
|
token: tokenProbe,
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isGitHubError(value: unknown): value is GitHubErrorPayload {
|
|
return typeof value === "object" && value !== null && (value as { ok?: unknown }).ok === false && "degradedReason" in value;
|
|
}
|
|
|
|
function issueSummary(issue: GitHubIssue): Record<string, unknown> {
|
|
return {
|
|
id: issue.id,
|
|
number: issue.number,
|
|
title: issue.title,
|
|
body: issue.body ?? "",
|
|
state: issue.state,
|
|
url: issue.html_url,
|
|
author: issue.user?.login ?? null,
|
|
createdAt: issue.created_at ?? null,
|
|
updatedAt: issue.updated_at ?? null,
|
|
};
|
|
}
|
|
|
|
function commentSummary(comment: GitHubComment): Record<string, unknown> {
|
|
return {
|
|
id: comment.id,
|
|
body: comment.body ?? "",
|
|
url: comment.html_url,
|
|
author: comment.user?.login ?? null,
|
|
createdAt: comment.created_at ?? null,
|
|
updatedAt: comment.updated_at ?? null,
|
|
};
|
|
}
|
|
|
|
function prSummary(pr: GitHubPullRequest): Record<string, unknown> {
|
|
return {
|
|
id: pr.id,
|
|
number: pr.number,
|
|
title: pr.title,
|
|
body: pr.body ?? "",
|
|
state: pr.state,
|
|
draft: pr.draft ?? false,
|
|
url: pr.html_url,
|
|
author: pr.user?.login ?? null,
|
|
head: { ref: pr.head?.ref ?? null, sha: pr.head?.sha ?? null },
|
|
base: { ref: pr.base?.ref ?? null, sha: pr.base?.sha ?? null },
|
|
createdAt: pr.created_at ?? null,
|
|
updatedAt: pr.updated_at ?? null,
|
|
};
|
|
}
|
|
|
|
function repoSummary(repo: GitHubRepository): Record<string, unknown> {
|
|
return {
|
|
id: repo.id ?? null,
|
|
fullName: repo.full_name ?? null,
|
|
private: repo.private ?? null,
|
|
defaultBranch: repo.default_branch ?? null,
|
|
permissions: repo.permissions ?? null,
|
|
};
|
|
}
|
|
|
|
function branchSummary(branch: GitHubBranch): Record<string, unknown> {
|
|
return {
|
|
name: branch.name ?? null,
|
|
sha: branch.commit?.sha ?? null,
|
|
};
|
|
}
|
|
|
|
function compareSummary(compare: GitHubCompare): Record<string, unknown> {
|
|
return {
|
|
status: compare.status ?? null,
|
|
aheadBy: compare.ahead_by ?? null,
|
|
behindBy: compare.behind_by ?? null,
|
|
totalCommits: compare.total_commits ?? null,
|
|
htmlUrl: compare.html_url ?? null,
|
|
baseSha: compare.base_commit?.sha ?? null,
|
|
mergeBaseSha: compare.merge_base_commit?.sha ?? null,
|
|
};
|
|
}
|
|
|
|
function encodePathPart(value: string): string {
|
|
return encodeURIComponent(value);
|
|
}
|
|
|
|
async function repoInfo(token: string, repo: string): Promise<GitHubRepository | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
return githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`);
|
|
}
|
|
|
|
async function branchInfo(token: string, repo: string, branch: string): Promise<GitHubBranch | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
return githubRequest<GitHubBranch>(token, "GET", `/repos/${owner}/${name}/branches/${encodePathPart(branch)}`);
|
|
}
|
|
|
|
async function compareBranches(token: string, repo: string, base: string, head: string): Promise<GitHubCompare | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
return githubRequest<GitHubCompare>(token, "GET", `/repos/${owner}/${name}/compare/${encodePathPart(base)}...${encodePathPart(head)}`);
|
|
}
|
|
|
|
function prCreatePlannedOperation(repo: string, options: GitHubOptions, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
repo,
|
|
title: options.title,
|
|
base: options.base,
|
|
head: options.head,
|
|
draft: options.draft,
|
|
bodyChars: body.length,
|
|
bodyPreview: preview(body),
|
|
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
|
|
bodySource,
|
|
preservesRawNewlines: body.includes("\n"),
|
|
containsLiteralBackslashN: body.includes("\\n"),
|
|
containsBackticks: body.includes("`"),
|
|
request: {
|
|
method: "POST",
|
|
path: "/repos/{owner}/{repo}/pulls",
|
|
body: {
|
|
title: options.title,
|
|
base: options.base,
|
|
head: options.head,
|
|
draft: options.draft,
|
|
bodyChars: body.length,
|
|
},
|
|
},
|
|
validation: {
|
|
repo: "required",
|
|
base: "required",
|
|
head: "required",
|
|
title: "required",
|
|
body: body.length > 0 ? "optional" : "empty",
|
|
},
|
|
};
|
|
}
|
|
|
|
function prCommentPlannedOperation(repo: string, issueNumber: number, body: string, bodySource: Record<string, unknown>): Record<string, unknown> {
|
|
return {
|
|
repo,
|
|
issueNumber,
|
|
bodyChars: body.length,
|
|
bodyPreview: preview(body),
|
|
bodyPreviewLines: body.split(/\r?\n/).slice(0, 12),
|
|
bodySource,
|
|
preservesRawNewlines: body.includes("\n"),
|
|
containsLiteralBackslashN: body.includes("\\n"),
|
|
containsBackticks: body.includes("`"),
|
|
request: {
|
|
method: "POST",
|
|
path: `/repos/{owner}/{repo}/issues/${issueNumber}/comments`,
|
|
body: { bodyChars: body.length },
|
|
},
|
|
validation: {
|
|
repo: "required",
|
|
issueNumber: "required",
|
|
body: "required",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function prCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
|
if (options.title === undefined) return validationError("pr create", repo, "pr create requires --title <title>");
|
|
if (options.base === undefined) return validationError("pr create", repo, "pr create requires --base <branch>");
|
|
if (options.head === undefined) return validationError("pr create", repo, "pr create requires --head <branch>");
|
|
let bodySource: { body: string; bodySource: Record<string, unknown> };
|
|
try {
|
|
bodySource = readMarkdownBody(options, "pr create");
|
|
} catch (error) {
|
|
return validationError("pr create", repo, error instanceof Error ? error.message : String(error));
|
|
}
|
|
const body = bodySource.body;
|
|
const planned = prCreatePlannedOperation(repo, options, body, bodySource.bodySource);
|
|
if (options.dryRun) {
|
|
return {
|
|
ok: true,
|
|
command: "pr create",
|
|
repo,
|
|
dryRun: true,
|
|
planned: true,
|
|
draft: options.draft,
|
|
...planned,
|
|
};
|
|
}
|
|
|
|
const repoResult = await repoInfo(token, repo);
|
|
if (isGitHubError(repoResult)) return commandError("pr create", repo, repoResult, { planned });
|
|
const baseBranch = await branchInfo(token, repo, options.base);
|
|
if (isGitHubError(baseBranch)) return commandError("pr create", repo, baseBranch, { base: options.base, planned });
|
|
const headBranch = await branchInfo(token, repo, options.head);
|
|
if (isGitHubError(headBranch)) return commandError("pr create", repo, headBranch, { head: options.head, planned });
|
|
const compare = await compareBranches(token, repo, options.base, options.head);
|
|
if (isGitHubError(compare)) return commandError("pr create", repo, compare, { base: options.base, head: options.head, planned });
|
|
const aheadBy = typeof compare.ahead_by === "number" ? compare.ahead_by : null;
|
|
if (aheadBy === null || aheadBy <= 0) {
|
|
return validationError("pr create", repo, "head branch must be ahead of base branch before creating a PR", {
|
|
base: options.base,
|
|
head: options.head,
|
|
compare: compareSummary(compare),
|
|
});
|
|
}
|
|
|
|
const { owner, name } = repoParts(repo);
|
|
const payload: Record<string, unknown> = {
|
|
title: options.title,
|
|
base: options.base,
|
|
head: options.head,
|
|
draft: options.draft,
|
|
body,
|
|
};
|
|
const pr = await githubRequest<GitHubPullRequest>(token, "POST", `/repos/${owner}/${name}/pulls`, payload);
|
|
if (isGitHubError(pr)) return commandError("pr create", repo, pr, { base: options.base, head: options.head, planned });
|
|
return {
|
|
ok: true,
|
|
command: "pr create",
|
|
repo,
|
|
pr: prSummary(pr),
|
|
validation: {
|
|
repo: repoSummary(repoResult),
|
|
base: branchSummary(baseBranch),
|
|
head: branchSummary(headBranch),
|
|
compare: compareSummary(compare),
|
|
bodySource: bodySource.bodySource,
|
|
},
|
|
request: {
|
|
method: "POST",
|
|
path: `/repos/${owner}/${name}/pulls`,
|
|
title: options.title,
|
|
base: options.base,
|
|
head: options.head,
|
|
draft: options.draft,
|
|
bodyChars: body.length,
|
|
},
|
|
rest: true,
|
|
};
|
|
}
|
|
|
|
async function prComment(repo: string, token: string, issueNumber: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
|
let bodySource: { body: string; bodySource: Record<string, unknown> };
|
|
try {
|
|
bodySource = readMarkdownBody(options, "pr comment");
|
|
} catch (error) {
|
|
return validationError("pr comment", repo, error instanceof Error ? error.message : String(error), { issueNumber });
|
|
}
|
|
const body = bodySource.body;
|
|
const planned = prCommentPlannedOperation(repo, issueNumber, body, bodySource.bodySource);
|
|
if (options.dryRun) {
|
|
return {
|
|
ok: true,
|
|
command: "pr comment",
|
|
repo,
|
|
dryRun: true,
|
|
planned: true,
|
|
issueNumber,
|
|
...planned,
|
|
};
|
|
}
|
|
|
|
const repoResult = await repoInfo(token, repo);
|
|
if (isGitHubError(repoResult)) return commandError("pr comment", repo, repoResult, { issueNumber, planned });
|
|
const { owner, name } = repoParts(repo);
|
|
const prResult = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${issueNumber}`);
|
|
if (isGitHubError(prResult)) return commandError("pr comment", repo, prResult, { issueNumber, planned });
|
|
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
|
if (isGitHubError(comment)) return commandError("pr comment", repo, comment, { issueNumber, planned });
|
|
return {
|
|
ok: true,
|
|
command: "pr comment",
|
|
repo,
|
|
issueNumber,
|
|
pr: prSummary(prResult),
|
|
comment: commentSummary(comment),
|
|
request: {
|
|
method: "POST",
|
|
path: `/repos/${owner}/${name}/issues/${issueNumber}/comments`,
|
|
bodyChars: body.length,
|
|
},
|
|
validation: {
|
|
repo: repoSummary(repoResult),
|
|
pr: prSummary(prResult),
|
|
bodySource: bodySource.bodySource,
|
|
},
|
|
rest: true,
|
|
};
|
|
}
|
|
|
|
async function listIssueComments(token: string, repo: string, issueNumber: number): Promise<GitHubComment[] | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
return githubRequest<GitHubComment[]>(token, "GET", `/repos/${owner}/${name}/issues/${issueNumber}/comments?per_page=100`);
|
|
}
|
|
|
|
async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
|
|
const { owner, name } = repoParts(repo);
|
|
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) });
|
|
return {
|
|
ok: true,
|
|
command: "issue view",
|
|
repo,
|
|
issue: issueSummary(issue),
|
|
comments: comments.map(commentSummary),
|
|
};
|
|
}
|
|
|
|
async function issueCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
|
if (options.title === undefined) throw new Error("issue create requires --title <title>");
|
|
const body = readBodyFile(options.bodyFile, "issue create");
|
|
if (options.dryRun) return { ok: true, command: "issue create", repo, dryRun: true, ...dryRunBody(repo, options.title, body) };
|
|
const { owner, name } = repoParts(repo);
|
|
const issue = await githubRequest<GitHubIssue>(token, "POST", `/repos/${owner}/${name}/issues`, { title: options.title, body });
|
|
if (isGitHubError(issue)) return commandError("issue create", repo, issue);
|
|
return { ok: true, command: "issue create", repo, issue: issueSummary(issue), rest: true };
|
|
}
|
|
|
|
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",
|
|
repo,
|
|
dryRun: true,
|
|
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);
|
|
const payload: Record<string, unknown> = { body };
|
|
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 });
|
|
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> {
|
|
const body = readBodyFile(options.bodyFile, "issue comment");
|
|
if (options.dryRun) return { ok: true, command: "issue comment", repo, dryRun: true, issueNumber, ...dryRunBody(repo, undefined, body) };
|
|
const { owner, name } = repoParts(repo);
|
|
const comment = await githubRequest<GitHubComment>(token, "POST", `/repos/${owner}/${name}/issues/${issueNumber}/comments`, { body });
|
|
if (isGitHubError(comment)) return commandError("issue comment", repo, comment, { issueNumber });
|
|
return { ok: true, command: "issue comment", repo, comment: commentSummary(comment), rest: true };
|
|
}
|
|
|
|
async function issueState(repo: string, token: string, issueNumber: number, state: "open" | "closed", dryRun: boolean): Promise<GitHubCommandResult> {
|
|
if (dryRun) return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", dryRun: true, repo, issueNumber, wouldPatch: { state } };
|
|
const { owner, name } = repoParts(repo);
|
|
const issue = await githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state });
|
|
if (isGitHubError(issue)) return commandError(state === "closed" ? "issue close" : "issue reopen", repo, issue, { issueNumber });
|
|
return { ok: true, command: state === "closed" ? "issue close" : "issue reopen", repo, issue: issueSummary(issue), rest: true };
|
|
}
|
|
|
|
function escapeSnippet(text: string, index: number): string {
|
|
const start = Math.max(0, index - 80);
|
|
const end = Math.min(text.length, index + 120);
|
|
return text.slice(start, end).replace(/\n/g, "\\n");
|
|
}
|
|
|
|
function scanText(text: string, patterns: Array<{ kind: string; pattern: RegExp }>): Array<{ kind: string; snippet: string }> {
|
|
const findings: Array<{ kind: string; snippet: string }> = [];
|
|
for (const item of patterns) {
|
|
item.pattern.lastIndex = 0;
|
|
let match = item.pattern.exec(text);
|
|
while (match !== null) {
|
|
findings.push({ kind: item.kind, snippet: escapeSnippet(text, match.index) });
|
|
if (findings.length >= 5) return findings;
|
|
match = item.pattern.exec(text);
|
|
}
|
|
}
|
|
return findings;
|
|
}
|
|
|
|
async function issueScanEscape(repo: string, token: string, limit: number): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`);
|
|
if (isGitHubError(issues)) return commandError("issue scan-escape", repo, issues);
|
|
|
|
const patterns = [
|
|
{ kind: "literal-backslash-n", pattern: /\\n/g },
|
|
{ kind: "literal-backslash-t", pattern: /\\t/g },
|
|
{ kind: "shell-escaped-newline", pattern: /\\r\\n|\\012|\\x0a/g },
|
|
{ kind: "ansi-escape-literal", pattern: /\\u001b|\\033/g },
|
|
];
|
|
|
|
const findings: Array<Record<string, unknown>> = [];
|
|
for (const issue of issues) {
|
|
for (const finding of scanText(issue.body ?? "", patterns)) {
|
|
findings.push({ type: "issue", issueNumber: issue.number, id: issue.id, url: issue.html_url, ...finding });
|
|
}
|
|
const comments = await listIssueComments(token, repo, issue.number);
|
|
if (isGitHubError(comments)) {
|
|
findings.push({
|
|
type: "comment-scan-error",
|
|
issueNumber: issue.number,
|
|
url: issue.html_url,
|
|
degradedReason: comments.degradedReason,
|
|
runnerDisposition: comments.runnerDisposition ?? runnerDisposition(comments.degradedReason),
|
|
details: comments,
|
|
});
|
|
continue;
|
|
}
|
|
for (const comment of comments) {
|
|
for (const finding of scanText(comment.body ?? "", patterns)) {
|
|
findings.push({ type: "comment", issueNumber: issue.number, id: comment.id, url: comment.html_url, ...finding });
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
ok: true,
|
|
command: "issue scan-escape",
|
|
repo,
|
|
scannedIssues: issues.length,
|
|
findings,
|
|
note: "Read-only scan; no issue or comment content was modified.",
|
|
};
|
|
}
|
|
|
|
async function authStatus(repo: string): Promise<GitHubCommandResult> {
|
|
const ghPath = ghBinaryPath();
|
|
const { token, probe } = resolveToken(ghPath !== null);
|
|
const degraded: GitHubDegradedReason[] = [];
|
|
if (ghPath === null) degraded.push("missing-binary");
|
|
if (!probe.present || token === null) {
|
|
if (ghPath === null) {
|
|
return {
|
|
ok: false,
|
|
command: "auth status",
|
|
repo,
|
|
degradedReason: "missing-binary",
|
|
degraded,
|
|
runnerDisposition: runnerDisposition("missing-binary"),
|
|
gh: { binaryFound: false, path: null },
|
|
token: probe,
|
|
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
|
|
};
|
|
}
|
|
degraded.push("missing-token");
|
|
return commandError("auth status", repo, errorPayload("missing-token", "GH_TOKEN or GITHUB_TOKEN is required", { details: probe }), {
|
|
degraded,
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: "skipped", repo: "skipped", issueRead: "skipped" },
|
|
});
|
|
}
|
|
|
|
const { owner, name } = repoParts(repo);
|
|
const api = await githubRequest<Record<string, unknown>>(token, "GET", "/rate_limit");
|
|
if (isGitHubError(api)) {
|
|
return commandError("auth status", repo, api, {
|
|
degraded: [...degraded, api.degradedReason],
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: api, repo: "skipped", issueRead: "skipped" },
|
|
});
|
|
}
|
|
|
|
const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`);
|
|
if (isGitHubError(repoProbe)) {
|
|
return commandError("auth status", repo, repoProbe, {
|
|
degraded: [...degraded, repoProbe.degradedReason],
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: "ok", repo: repoProbe, issueRead: "skipped" },
|
|
});
|
|
}
|
|
|
|
const issueProbe = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?per_page=1&state=all`);
|
|
if (isGitHubError(issueProbe)) {
|
|
return commandError("auth status", repo, issueProbe, {
|
|
degraded: [...degraded, issueProbe.degradedReason],
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: { restApi: "ok", repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: issueProbe },
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
command: "auth status",
|
|
repo,
|
|
degraded,
|
|
gh: { binaryFound: ghPath !== null, path: ghPath },
|
|
token: probe,
|
|
probes: {
|
|
restApi: "ok",
|
|
repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null },
|
|
issueRead: { ok: true, readable: true, sampleCount: issueProbe.length },
|
|
},
|
|
restFallback: true,
|
|
};
|
|
}
|
|
|
|
async function prList(repo: string, token: string, limit: number): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=all&per_page=${limit}`);
|
|
if (isGitHubError(prs)) return commandError("pr list", repo, prs);
|
|
return {
|
|
ok: true,
|
|
command: "pr list",
|
|
repo,
|
|
plannedScope: "read-only REST support",
|
|
pullRequests: prs.map(prSummary),
|
|
};
|
|
}
|
|
|
|
async function prView(repo: string, token: string, number: number): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
|
if (isGitHubError(pr)) return commandError("pr view", repo, pr, { number });
|
|
return {
|
|
ok: true,
|
|
command: "pr view",
|
|
repo,
|
|
plannedScope: "read-only REST support",
|
|
pullRequest: prSummary(pr),
|
|
};
|
|
}
|
|
|
|
export function ghHelp(): unknown {
|
|
return {
|
|
command: "gh",
|
|
output: "json",
|
|
usage: [
|
|
"bun scripts/cli.ts gh auth status [--repo owner/name]",
|
|
"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]",
|
|
"bun scripts/cli.ts gh pr list [--repo owner/name] [--limit N]",
|
|
"bun scripts/cli.ts gh pr view <number> [--repo owner/name]",
|
|
"bun scripts/cli.ts gh pr create --title <title> --body-file <file>|--body <text> --base <branch> --head <branch> [--repo owner/name] [--draft] [--dry-run]",
|
|
"bun scripts/cli.ts gh pr comment <number> --body-file <file>|--body <text> [--repo owner/name] [--dry-run]",
|
|
],
|
|
defaults: { repo: DEFAULT_REPO },
|
|
notes: [
|
|
"Issue create/edit/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.",
|
|
"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.",
|
|
],
|
|
};
|
|
}
|
|
|
|
export async function runGhCommand(args: string[]): Promise<GitHubCommandResult | unknown> {
|
|
const [top, sub, third] = args;
|
|
if (top === undefined || top === "help" || top === "--help" || top === "-h") return ghHelp();
|
|
let options: GitHubOptions;
|
|
try {
|
|
options = parseOptions(args);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
|
const repo = optionValue(args, "--repo") ?? DEFAULT_REPO;
|
|
return message.startsWith("unknown gh option:")
|
|
? 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") {
|
|
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);
|
|
}
|
|
const { token, probe } = resolveToken(true);
|
|
const missing = authRequired(options.repo, `issue ${sub ?? ""}`.trim(), probe);
|
|
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `issue ${sub ?? ""}`.trim(), { present: false, source: null, ghFallbackAttempted: true });
|
|
|
|
if (sub === "view") return issueView(options.repo, token, parseNumber(third, "issue view"));
|
|
if (sub === "create") return issueCreate(options.repo, token, options);
|
|
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
|
|
if (sub === "comment") return issueComment(options.repo, token, parseNumber(third, "issue comment"), options);
|
|
if (sub === "close") return issueState(options.repo, token, parseNumber(third, "issue close"), "closed", options.dryRun);
|
|
if (sub === "reopen") return issueState(options.repo, token, parseNumber(third, "issue reopen"), "open", options.dryRun);
|
|
if (sub === "scan-escape") return issueScanEscape(options.repo, token, options.limit);
|
|
}
|
|
|
|
if (top === "pr") {
|
|
if (sub === "create") {
|
|
if (options.dryRun) return prCreate(options.repo, "", options);
|
|
const { token, probe } = resolveToken(true);
|
|
const missing = authRequired(options.repo, "pr create", probe);
|
|
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr create", { present: false, source: null, ghFallbackAttempted: true });
|
|
return prCreate(options.repo, token, options);
|
|
}
|
|
if (sub === "comment") {
|
|
if (options.dryRun) {
|
|
const number = parseNumberForCommand(options.repo, third, "pr comment");
|
|
if (typeof number !== "number") return number;
|
|
return prComment(options.repo, "", number, options);
|
|
}
|
|
const { token, probe } = resolveToken(true);
|
|
const missing = authRequired(options.repo, "pr comment", probe);
|
|
if (missing !== null || token === null) return missing ?? authRequired(options.repo, "pr comment", { present: false, source: null, ghFallbackAttempted: true });
|
|
const number = parseNumberForCommand(options.repo, third, "pr comment");
|
|
if (typeof number !== "number") return number;
|
|
return prComment(options.repo, token, number, options);
|
|
}
|
|
if (sub === "merge") {
|
|
return unsupportedCommand("pr merge", options.repo, "PR merge is intentionally unsupported in this phase; use create/comment/read only.");
|
|
}
|
|
if (sub !== "list" && sub !== "view") {
|
|
return unsupportedCommand(`pr ${sub ?? ""}`.trim(), options.repo, "PR supported commands are list, view, create, comment, and unsupported merge.");
|
|
}
|
|
const { token, probe } = resolveToken(true);
|
|
const missing = authRequired(options.repo, `pr ${sub}`, probe);
|
|
if (missing !== null || token === null) return missing ?? authRequired(options.repo, `pr ${sub}`, { present: false, source: null, ghFallbackAttempted: true });
|
|
if (sub === "list") return prList(options.repo, token, options.limit);
|
|
return prView(options.repo, token, parseNumber(third, "pr view"));
|
|
}
|
|
|
|
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
|
|
}
|