feat: notify ClaudeQQ on commander brief updates

This commit is contained in:
Codex
2026-05-20 10:32:59 +00:00
parent 77ffea132b
commit ba8bdc828e
5 changed files with 534 additions and 9 deletions
+442 -6
View File
@@ -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);