feat: improve github issue lifecycle cli
This commit is contained in:
+377
-39
@@ -10,6 +10,11 @@ const PREVIEW_CHARS = 240;
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
const MIN_SAFE_ISSUE_BODY_CHARS = 20;
|
||||
const MAX_INLINE_ISSUE_COMMENT_BODY_CHARS = 1000;
|
||||
const GITHUB_REST_PAGE_SIZE = 100;
|
||||
const MAX_ISSUE_LIST_LIMIT = 1000;
|
||||
const DEFAULT_STALE_CLOSE_INACTIVE_HOURS = 48;
|
||||
const MAX_STALE_CLOSE_INACTIVE_HOURS = 24 * 365 * 10;
|
||||
const ISSUE_LIFECYCLE_PREVIEW_LIMIT = 80;
|
||||
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_BASE_URL = "http://backend-core:8080/api/microservices/claudeqq/proxy";
|
||||
const DEFAULT_COMMANDER_BRIEF_CLAUDEQQ_USER_ID = "645275593";
|
||||
const CODE_QUEUE_BOARD_TARGET_ISSUE = 20;
|
||||
@@ -35,7 +40,7 @@ const GH_VALUE_OPTIONS = new Set([
|
||||
"--expect-updated-at", "--expect-body-sha", "--body-profile", "--label", "--field",
|
||||
"--value", "--section", "--to", "--status", "--row-file", "--category", "--branch",
|
||||
"--tasks", "--summary", "--focus", "--validation", "--progress", "--number", "--pr",
|
||||
"--search",
|
||||
"--search", "--inactive-hours",
|
||||
]);
|
||||
const GH_FLAG_OPTIONS = new Set(["--dry-run", "--draft", "--notify-claudeqq-brief-diff", "--allow-short-body", "--raw", "--full", "--stat", "--merge", "--squash", "--rebase", "--delete-branch"]);
|
||||
const MIN_SAFE_BODY_SCAN_CHARS = MIN_SAFE_ISSUE_BODY_CHARS;
|
||||
@@ -318,6 +323,7 @@ interface GitHubOptions {
|
||||
raw: boolean;
|
||||
full: boolean;
|
||||
limit: number;
|
||||
inactiveHours: number;
|
||||
boardIssue: number;
|
||||
knownMetaIssues: number[];
|
||||
ignoredIssues: number[];
|
||||
@@ -419,6 +425,24 @@ interface GitHubIssueSearchResponse {
|
||||
items?: GitHubIssue[];
|
||||
}
|
||||
|
||||
interface GitHubIssueListPage {
|
||||
path: string;
|
||||
rawCount: number;
|
||||
issueCount: number;
|
||||
}
|
||||
|
||||
interface GitHubIssueListResult {
|
||||
items: GitHubIssue[];
|
||||
rawCount: number;
|
||||
fetchedPages: number;
|
||||
pageSize: number;
|
||||
exhausted: boolean;
|
||||
hasMore: boolean;
|
||||
pages: GitHubIssueListPage[];
|
||||
searchTotalCount?: number;
|
||||
searchIncomplete?: boolean;
|
||||
}
|
||||
|
||||
interface GitHubPullRequest {
|
||||
id: number;
|
||||
number: number;
|
||||
@@ -592,6 +616,14 @@ function positiveIntegerOption(args: string[], name: string, defaultValue: numbe
|
||||
return Math.min(value, maxValue);
|
||||
}
|
||||
|
||||
function positiveNumberOption(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.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`);
|
||||
return Math.min(value, maxValue);
|
||||
}
|
||||
|
||||
function validateEnumValue<T extends string>(name: string, raw: string, allowedValues: readonly T[]): T {
|
||||
if ((allowedValues as readonly string[]).includes(raw)) return raw as T;
|
||||
throw new Error(`unsupported ${name} ${raw}; supported values: ${allowedValues.join(",")}`);
|
||||
@@ -759,13 +791,18 @@ function parseOptions(args: string[]): GitHubOptions {
|
||||
validateKnownOptions(args);
|
||||
const [top, sub] = args;
|
||||
const requestedJsonFields = commaListOption(args, "--json");
|
||||
const limitMax = top === "pr" && (sub === "files" || sub === "diff") ? MAX_PR_FILES_LIMIT : 100;
|
||||
const limitMax = top === "pr" && (sub === "files" || sub === "diff")
|
||||
? MAX_PR_FILES_LIMIT
|
||||
: top === "issue" && (sub === "list" || sub === "stale-close" || sub === "scan-escape" || sub === "cleanup-plan")
|
||||
? MAX_ISSUE_LIST_LIMIT
|
||||
: 100;
|
||||
return {
|
||||
repo: resolveRepoOption(args),
|
||||
dryRun: hasFlag(args, "--dry-run"),
|
||||
raw: hasFlag(args, "--raw"),
|
||||
full: hasFlag(args, "--full"),
|
||||
limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : 30, limitMax),
|
||||
limit: positiveIntegerOption(args, "--limit", top === "issue" && sub === "board-audit" ? 100 : top === "issue" && sub === "stale-close" ? MAX_ISSUE_LIST_LIMIT : 30, limitMax),
|
||||
inactiveHours: positiveNumberOption(args, "--inactive-hours", DEFAULT_STALE_CLOSE_INACTIVE_HOURS, MAX_STALE_CLOSE_INACTIVE_HOURS),
|
||||
boardIssue: positiveIntegerSingleOption(args, "--board-issue", CODE_QUEUE_BOARD_TARGET_ISSUE),
|
||||
knownMetaIssues: positiveIntegerValuesOption(args, "--known-meta-issue"),
|
||||
ignoredIssues: positiveIntegerValuesOption(args, "--ignore-issue"),
|
||||
@@ -1992,6 +2029,48 @@ function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; prev
|
||||
return summary;
|
||||
}
|
||||
|
||||
function issueLifecycleSummary(issue: GitHubIssue): Record<string, unknown> {
|
||||
return {
|
||||
id: issue.id,
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
state: issue.state,
|
||||
url: issue.html_url,
|
||||
author: issue.user?.login ?? null,
|
||||
createdAt: issue.created_at ?? null,
|
||||
updatedAt: issue.updated_at ?? null,
|
||||
commentCount: issue.comments ?? null,
|
||||
labels: issueLabelNames(issue),
|
||||
bodyChars: (issue.body ?? "").length,
|
||||
bodySha: bodySha(issue.body ?? ""),
|
||||
bodyOmitted: true,
|
||||
fullBodyIncluded: false,
|
||||
};
|
||||
}
|
||||
|
||||
function issueLifecycleDisclosure(repo: string, issueNumber: number, dryRun: boolean): Record<string, unknown> {
|
||||
return {
|
||||
defaultCompact: true,
|
||||
explicitFullDisclosure: false,
|
||||
fullBodyIncluded: false,
|
||||
bodyOmitted: true,
|
||||
dryRunBoundedPreview: dryRun,
|
||||
note: "Issue lifecycle write output omits full issue.body; use readCommands.full/raw or gh issue read --json body when full text is needed.",
|
||||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||||
};
|
||||
}
|
||||
|
||||
function issueLifecycleBatchSummary(issues: GitHubIssue[]): Record<string, unknown> {
|
||||
const visible = issues.slice(0, ISSUE_LIFECYCLE_PREVIEW_LIMIT);
|
||||
return {
|
||||
count: issues.length,
|
||||
returned: visible.length,
|
||||
omitted: Math.max(0, issues.length - visible.length),
|
||||
numbers: visible.map((issue) => issue.number),
|
||||
issues: visible.map(issueLifecycleSummary),
|
||||
};
|
||||
}
|
||||
|
||||
function labelSummary(label: string | { name?: string; color?: string; description?: string | null }): Record<string, unknown> {
|
||||
if (typeof label === "string") return { name: label, color: null, description: null };
|
||||
return {
|
||||
@@ -4915,17 +4994,78 @@ 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 listIssues(token: string, repo: string, state: IssueListState, limit: number, search?: string): Promise<GitHubIssue[] | GitHubErrorPayload | GitHubIssueSearchResponse> {
|
||||
function githubSearchLabelQualifier(label: string): string {
|
||||
if (/^[A-Za-z0-9_.:-]+$/u.test(label)) return `label:${label}`;
|
||||
return `label:"${label.replace(/["\\]/gu, "\\$&")}"`;
|
||||
}
|
||||
|
||||
function issueListPaginationSummary(result: GitHubIssueListResult): Record<string, unknown> {
|
||||
return {
|
||||
pageSize: result.pageSize,
|
||||
fetchedPages: result.fetchedPages,
|
||||
exhausted: result.exhausted,
|
||||
hasMore: result.hasMore,
|
||||
pages: result.pages,
|
||||
};
|
||||
}
|
||||
|
||||
async function listIssues(token: string, repo: string, state: IssueListState, limit: number, search?: string, labels: string[] = []): Promise<GitHubIssueListResult | GitHubErrorPayload> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const normalizedSearch = String(search ?? "").trim();
|
||||
if (normalizedSearch) {
|
||||
const qualifiers = [`repo:${owner}/${name}`, "type:issue"];
|
||||
if (state !== "all") qualifiers.push(`state:${state}`);
|
||||
const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(limit) });
|
||||
return githubRequest<GitHubIssueSearchResponse>(token, "GET", `/search/issues?${params.toString()}`);
|
||||
const pageSize = GITHUB_REST_PAGE_SIZE;
|
||||
const collected: GitHubIssue[] = [];
|
||||
const pages: GitHubIssueListPage[] = [];
|
||||
let rawCount = 0;
|
||||
let fetchedPages = 0;
|
||||
let exhausted = false;
|
||||
let searchTotalCount: number | undefined;
|
||||
let searchIncomplete: boolean | undefined;
|
||||
const maxPages = Math.max(1, Math.ceil(limit / pageSize) + 10);
|
||||
for (let page = 1; collected.length < limit && page <= maxPages; page += 1) {
|
||||
const perPage = pageSize;
|
||||
let path: string;
|
||||
let rawIssueItems: GitHubIssue[];
|
||||
if (normalizedSearch) {
|
||||
const qualifiers = [`repo:${owner}/${name}`, "type:issue"];
|
||||
if (state !== "all") qualifiers.push(`state:${state}`);
|
||||
for (const label of labels) qualifiers.push(githubSearchLabelQualifier(label));
|
||||
const params = new URLSearchParams({ q: `${normalizedSearch} ${qualifiers.join(" ")}`, per_page: String(perPage), page: String(page) });
|
||||
path = `/search/issues?${params.toString()}`;
|
||||
const response = await githubRequest<GitHubIssueSearchResponse>(token, "GET", path);
|
||||
if (isGitHubError(response)) return response;
|
||||
rawIssueItems = Array.isArray(response.items) ? response.items : [];
|
||||
if (searchTotalCount === undefined) searchTotalCount = response.total_count;
|
||||
searchIncomplete = searchIncomplete === true || response.incomplete_results === true;
|
||||
} else {
|
||||
const params = new URLSearchParams({ state, per_page: String(perPage), page: String(page) });
|
||||
if (labels.length > 0) params.set("labels", labels.join(","));
|
||||
path = `/repos/${owner}/${name}/issues?${params.toString()}`;
|
||||
const response = await githubRequest<GitHubIssue[]>(token, "GET", path);
|
||||
if (isGitHubError(response)) return response;
|
||||
rawIssueItems = response;
|
||||
}
|
||||
const issueItems = rawIssueItems.filter((issue) => issue.pull_request === undefined);
|
||||
rawCount += rawIssueItems.length;
|
||||
fetchedPages += 1;
|
||||
pages.push({ path, rawCount: rawIssueItems.length, issueCount: issueItems.length });
|
||||
collected.push(...issueItems);
|
||||
if (rawIssueItems.length < perPage) {
|
||||
exhausted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const params = new URLSearchParams({ state, per_page: String(limit) });
|
||||
return githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?${params.toString()}`);
|
||||
const hasMore = collected.length > limit || !exhausted;
|
||||
return {
|
||||
items: collected.slice(0, limit),
|
||||
rawCount,
|
||||
fetchedPages,
|
||||
pageSize,
|
||||
exhausted,
|
||||
hasMore,
|
||||
pages,
|
||||
searchTotalCount,
|
||||
searchIncomplete,
|
||||
};
|
||||
}
|
||||
|
||||
async function getIssue(token: string, repo: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
|
||||
@@ -4975,33 +5115,67 @@ async function issueView(repo: string, token: string, issueNumber: number, jsonF
|
||||
return issueRead(repo, token, issueNumber, jsonFields, "issue view", disclosure);
|
||||
}
|
||||
|
||||
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string): Promise<GitHubCommandResult> {
|
||||
function shellWord(value: string): string {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function issueListNextCommand(repo: string, state: IssueListState, limit: number, search: string, labels: string[]): string {
|
||||
const parts = [
|
||||
"bun scripts/cli.ts gh issue list",
|
||||
"--repo", shellWord(repo),
|
||||
"--state", state,
|
||||
"--limit", String(Math.min(limit * 2, MAX_ISSUE_LIST_LIMIT)),
|
||||
];
|
||||
if (search.length > 0) parts.push("--search", shellWord(search));
|
||||
for (const label of labels) parts.push("--label", shellWord(label));
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
async function issueList(repo: string, token: string, state: IssueListState, limit: number, jsonFields: IssueListJsonField[] | undefined, search?: string, labels: string[] = [], noDump = false): Promise<GitHubCommandResult> {
|
||||
const normalizedSearch = String(search ?? "").trim();
|
||||
const rawIssues = await listIssues(token, repo, state, limit, normalizedSearch);
|
||||
if (isGitHubError(rawIssues)) return commandError("issue list", repo, rawIssues, { state, limit, search: normalizedSearch || null });
|
||||
const searchResponse = Array.isArray(rawIssues) ? null : rawIssues;
|
||||
const rawIssueItems = Array.isArray(rawIssues) ? rawIssues : Array.isArray(rawIssues.items) ? rawIssues.items : [];
|
||||
const issues = rawIssueItems.filter((issue) => issue.pull_request === undefined).slice(0, limit);
|
||||
const result = await listIssues(token, repo, state, limit, normalizedSearch, labels);
|
||||
if (isGitHubError(result)) return commandError("issue list", repo, result, { state, limit, search: normalizedSearch || null, labels });
|
||||
const issues = result.items;
|
||||
const fields = jsonFields ?? ISSUE_LIST_JSON_FIELDS.slice();
|
||||
return {
|
||||
ok: true,
|
||||
command: "issue list",
|
||||
repo,
|
||||
...(noDump ? { noDump: true } : {}),
|
||||
state,
|
||||
limit,
|
||||
search: normalizedSearch || null,
|
||||
labels,
|
||||
count: issues.length,
|
||||
rawCount: rawIssueItems.length,
|
||||
searchTotalCount: searchResponse?.total_count,
|
||||
searchIncomplete: searchResponse?.incomplete_results,
|
||||
rawCount: result.rawCount,
|
||||
searchTotalCount: result.searchTotalCount,
|
||||
searchIncomplete: result.searchIncomplete,
|
||||
pagination: issueListPaginationSummary(result),
|
||||
hasMore: result.hasMore,
|
||||
jsonFields: fields,
|
||||
issues: issues.map((issue) => issueListSummary(issue, fields)),
|
||||
...(result.hasMore && limit < MAX_ISSUE_LIST_LIMIT
|
||||
? {
|
||||
next: {
|
||||
command: issueListNextCommand(repo, state, limit, normalizedSearch, labels),
|
||||
note: "Increase --limit to continue scanning beyond the current bounded result set.",
|
||||
},
|
||||
}
|
||||
: result.hasMore
|
||||
? {
|
||||
scanLimit: {
|
||||
maxLimitReached: true,
|
||||
maxLimit: MAX_ISSUE_LIST_LIMIT,
|
||||
note: "The command reached the maximum bounded issue scan; narrow with --state/--label/--search before treating the repository as exhausted.",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
request: {
|
||||
method: "GET",
|
||||
path: normalizedSearch ? "/search/issues" : "/repos/{owner}/{repo}/issues",
|
||||
query: normalizedSearch
|
||||
? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}`, per_page: limit }
|
||||
: { state, per_page: limit },
|
||||
? { q: `${normalizedSearch} repo:${repo} type:issue${state === "all" ? "" : ` state:${state}`}${labels.map((label) => ` ${githubSearchLabelQualifier(label)}`).join("")}`, per_page: GITHUB_REST_PAGE_SIZE }
|
||||
: { state, labels, per_page: GITHUB_REST_PAGE_SIZE },
|
||||
},
|
||||
note: "GitHub's issues endpoint may include pull requests; this command filters pull requests from .issues.",
|
||||
};
|
||||
@@ -5405,11 +5579,164 @@ async function commentDelete(repo: string, token: string, ownerKind: "issue" | "
|
||||
}
|
||||
|
||||
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 command = state === "closed" ? "issue close" : "issue reopen";
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
dryRun: true,
|
||||
repo,
|
||||
issueNumber,
|
||||
disclosure: issueLifecycleDisclosure(repo, issueNumber, true),
|
||||
readCommands: issueBodyReadCommands(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 };
|
||||
if (isGitHubError(issue)) return commandError(command, repo, issue, { issueNumber });
|
||||
return {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
issue: issueLifecycleSummary(issue),
|
||||
disclosure: issueLifecycleDisclosure(repo, issueNumber, false),
|
||||
readCommands: issueBodyReadCommands(repo, issueNumber),
|
||||
rest: true,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGitHubTimestamp(value: string | undefined): number | null {
|
||||
if (value === undefined) return null;
|
||||
const millis = Date.parse(value);
|
||||
return Number.isFinite(millis) ? millis : null;
|
||||
}
|
||||
|
||||
function inactiveIssueCandidates(issues: GitHubIssue[], cutoffMs: number): GitHubIssue[] {
|
||||
return issues.filter((issue) => {
|
||||
const updatedAtMs = parseGitHubTimestamp(issue.updated_at);
|
||||
return updatedAtMs !== null && updatedAtMs < cutoffMs;
|
||||
});
|
||||
}
|
||||
|
||||
async function closeIssueForBatch(repo: string, token: string, issueNumber: number): Promise<GitHubIssue | GitHubErrorPayload> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
return githubRequest<GitHubIssue>(token, "PATCH", `/repos/${owner}/${name}/issues/${issueNumber}`, { state: "closed" });
|
||||
}
|
||||
|
||||
async function issueStaleClose(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
||||
const command = "issue stale-close";
|
||||
const observedAt = new Date();
|
||||
const cutoffMs = observedAt.getTime() - Math.round(options.inactiveHours * 60 * 60 * 1000);
|
||||
const cutoffAt = new Date(cutoffMs).toISOString();
|
||||
const result = await listIssues(token, repo, "open", options.limit, "", options.labels);
|
||||
if (isGitHubError(result)) {
|
||||
return commandError(command, repo, result, {
|
||||
state: "open",
|
||||
limit: options.limit,
|
||||
inactiveHours: options.inactiveHours,
|
||||
cutoffAt,
|
||||
labels: options.labels,
|
||||
});
|
||||
}
|
||||
const staleIssues = inactiveIssueCandidates(result.items, cutoffMs);
|
||||
const base = {
|
||||
ok: true,
|
||||
command,
|
||||
repo,
|
||||
dryRun: options.dryRun,
|
||||
state: "open",
|
||||
inactiveHours: options.inactiveHours,
|
||||
observedAt: observedAt.toISOString(),
|
||||
cutoffAt,
|
||||
limit: options.limit,
|
||||
labels: options.labels,
|
||||
scannedCount: result.items.length,
|
||||
rawCount: result.rawCount,
|
||||
staleCount: staleIssues.length,
|
||||
pagination: issueListPaginationSummary(result),
|
||||
hasMore: result.hasMore,
|
||||
stale: issueLifecycleBatchSummary(staleIssues),
|
||||
policy: {
|
||||
basis: "GitHub issue updatedAt",
|
||||
selectedWhen: "updatedAt is older than observedAt - inactiveHours",
|
||||
commentsAndStateChangesCountAsActivity: true,
|
||||
pullRequestsFiltered: true,
|
||||
},
|
||||
readCommands: {
|
||||
dryRun: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${options.limit} --dry-run`,
|
||||
openList: `bun scripts/cli.ts gh issue list --repo ${repo} --state open --limit ${options.limit} --json number,title,state,url,updatedAt`,
|
||||
},
|
||||
...(result.hasMore && options.limit < MAX_ISSUE_LIST_LIMIT
|
||||
? {
|
||||
next: {
|
||||
command: `bun scripts/cli.ts gh issue stale-close --repo ${repo} --inactive-hours ${options.inactiveHours} --limit ${Math.min(options.limit * 2, MAX_ISSUE_LIST_LIMIT)} --dry-run`,
|
||||
note: "The scan reached the bounded --limit before GitHub pagination was exhausted; increase --limit before treating the cleanup as complete.",
|
||||
},
|
||||
}
|
||||
: result.hasMore
|
||||
? {
|
||||
scanLimit: {
|
||||
maxLimitReached: true,
|
||||
maxLimit: MAX_ISSUE_LIST_LIMIT,
|
||||
note: "The cleanup reached the maximum bounded issue scan; narrow with --label or split the policy before treating it as complete.",
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
...base,
|
||||
planned: true,
|
||||
wouldCloseCount: staleIssues.length,
|
||||
wouldCloseNumbers: staleIssues.map((issue) => issue.number),
|
||||
note: staleIssues.length === 0
|
||||
? "No open issues matched the inactive-hours policy; no GitHub issue would be closed."
|
||||
: "Dry-run only; no GitHub issue was closed.",
|
||||
};
|
||||
}
|
||||
|
||||
const closed: GitHubIssue[] = [];
|
||||
const failures: Array<Record<string, unknown>> = [];
|
||||
for (const issue of staleIssues) {
|
||||
const closedIssue = await closeIssueForBatch(repo, token, issue.number);
|
||||
if (isGitHubError(closedIssue)) {
|
||||
failures.push({
|
||||
number: issue.number,
|
||||
title: issue.title,
|
||||
url: issue.html_url,
|
||||
updatedAt: issue.updated_at ?? null,
|
||||
degradedReason: closedIssue.degradedReason,
|
||||
runnerDisposition: closedIssue.runnerDisposition,
|
||||
message: closedIssue.message,
|
||||
status: closedIssue.status ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
closed.push(closedIssue);
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
dryRun: false,
|
||||
planned: false,
|
||||
closedCount: closed.length,
|
||||
failedCount: failures.length,
|
||||
closed: issueLifecycleBatchSummary(closed),
|
||||
failures,
|
||||
rest: true,
|
||||
ok: failures.length === 0,
|
||||
...(failures.length > 0
|
||||
? {
|
||||
degradedReason: "github-transient" as GitHubDegradedReason,
|
||||
runnerDisposition: "infra-blocked" as RunnerDisposition,
|
||||
retryable: true,
|
||||
}
|
||||
: {}),
|
||||
note: failures.length === 0
|
||||
? "Closed all open issues that matched the inactive-hours policy."
|
||||
: "Some stale issue close operations failed; rerun the same command after checking failures.",
|
||||
};
|
||||
}
|
||||
|
||||
function escapeSnippet(text: string, index: number, radius = 80): string {
|
||||
@@ -5703,10 +6030,9 @@ function summarizeCleanupSuggestion(findings: EscapeScanEntry[]): EscapeCleanupS
|
||||
}
|
||||
|
||||
async function issueScanEscape(repo: string, token: string, limit: number, dryRun: boolean, commandName = "issue scan-escape"): Promise<GitHubCommandResult> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const issues = await githubRequest<GitHubIssue[]>(token, "GET", `/repos/${owner}/${name}/issues?state=all&per_page=${limit}`);
|
||||
const issues = await listIssues(token, repo, "all", limit);
|
||||
if (isGitHubError(issues)) return commandError(commandName, repo, issues);
|
||||
const issueOnly = issues.filter((issue) => issue.pull_request === undefined).slice(0, limit);
|
||||
const issueOnly = issues.items;
|
||||
|
||||
const patterns = textFindingPatterns();
|
||||
const findings: EscapeScanEntry[] = [];
|
||||
@@ -5754,7 +6080,8 @@ async function issueScanEscape(repo: string, token: string, limit: number, dryRu
|
||||
dryRun,
|
||||
planned: true,
|
||||
scannedIssues: issueOnly.length,
|
||||
rawIssues: issues.length,
|
||||
rawIssues: issues.rawCount,
|
||||
pagination: issueListPaginationSummary(issues),
|
||||
scannedComments,
|
||||
findings,
|
||||
cleanupSuggestions,
|
||||
@@ -5844,7 +6171,7 @@ async function authStatus(repo: string): Promise<GitHubCommandResult> {
|
||||
};
|
||||
}
|
||||
|
||||
async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined): Promise<GitHubCommandResult> {
|
||||
async function prList(repo: string, token: string, state: PrListState, limit: number, jsonFields: PrListJsonField[] | undefined, noDump = false): Promise<GitHubCommandResult> {
|
||||
const { owner, name } = repoParts(repo);
|
||||
const prs = await githubRequest<GitHubPullRequest[]>(token, "GET", `/repos/${owner}/${name}/pulls?state=${state}&per_page=${limit}`);
|
||||
if (isGitHubError(prs)) return commandError("pr list", repo, prs, { state, limit });
|
||||
@@ -5853,6 +6180,7 @@ async function prList(repo: string, token: string, state: PrListState, limit: nu
|
||||
ok: true,
|
||||
command: "pr list",
|
||||
repo,
|
||||
...(noDump ? { noDump: true } : {}),
|
||||
state,
|
||||
limit,
|
||||
count: prs.length,
|
||||
@@ -5959,7 +6287,7 @@ export function ghHelp(): unknown {
|
||||
output: "json",
|
||||
usage: [
|
||||
"bun scripts/cli.ts gh auth status [--repo owner/name]",
|
||||
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels]",
|
||||
"bun scripts/cli.ts gh issue list [owner/repo] [--state open|closed|all] [--limit N] [--search text] [--label label[,label...]]... [--repo owner/name] [--json number,title,state,url,updatedAt,createdAt,author,labels] [--raw|--full]",
|
||||
"bun scripts/cli.ts gh issue read <number|owner/repo#number> [--repo owner/name] [--json body,title,state,comments] [--raw|--full]",
|
||||
"bun scripts/cli.ts gh issue view <number|owner/repo#number> [--repo owner/name] [--raw|--full] [compatibility alias for issue read]",
|
||||
"bun scripts/cli.ts gh issue create --title <title> --body-file <file|-> [--label label[,label...]]... [--repo owner/name] [--dry-run]",
|
||||
@@ -5969,6 +6297,7 @@ export function ghHelp(): unknown {
|
||||
"bun scripts/cli.ts gh issue comment create <number> --body-file <file|->|--body <short-text> [--repo owner/name] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue comment delete <commentId> [--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 stale-close [--repo owner/name] [--inactive-hours N] [--limit N] [--label label[,label...]]... [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue delete <number> [unsupported: use close]",
|
||||
"bun scripts/cli.ts gh issue scan-escape [--repo owner/name] [--limit N] [--dry-run]",
|
||||
"bun scripts/cli.ts gh issue cleanup-plan [--repo owner/name] [--limit N] [--dry-run]",
|
||||
@@ -6002,10 +6331,10 @@ export function ghHelp(): unknown {
|
||||
"Issue and PR create/read/update/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.",
|
||||
"issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.",
|
||||
"issue list defaults to --state open and bounded --limit 30; --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
|
||||
"issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. Supported --json fields are number,title,state,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.",
|
||||
"PR list defaults to --state all for compatibility with earlier UniDesk CLI behavior; supported states are open, closed, and all.",
|
||||
"issue read is the canonical read path; view remains a compatibility alias. Read/view accept owner/repo#number shorthand and derive --repo unless an explicit conflicting --repo is supplied, which fails structurally with suggested commands. Read supports legacy --json field selection such as --json body and still exposes .data.issue.body for compatibility; unsupported fields fail structurally.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue read/view/update/edit and gh pr read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body.",
|
||||
"--raw and --full are explicit full-disclosure aliases for gh issue list/read/view/update/edit and gh pr list/read/view. For issue writes, default success output omits full issue.body and returns bodyChars/bodySha/bodyPreview plus readCommands; --full|--raw includes the full returned issue body only on commands that explicitly support full disclosure.",
|
||||
"GitHub CLI output larger than 20 KiB is automatically written to /tmp/unidesk-cli-output/*.json; stdout stays bounded JSON with outputTruncated=true, the dump path, total bytes/lines, and head/tail previews.",
|
||||
"issue create accepts --body-file <file|-> plus repeatable --label values and comma-separated labels; inline --body is intentionally unsupported for issue creation. Dry-run prints the parsed labels and non-dry-run sends them in the GitHub REST create-issue payload.",
|
||||
"--body-file is the recommended source for Markdown bodies so real newlines, backticks, and tables are read as file bytes instead of shell arguments.",
|
||||
@@ -6014,6 +6343,8 @@ export function ghHelp(): unknown {
|
||||
"issue update --body-file accepts files or - for stdin, refuses literal null, blank, and too-short bodies by default. Use --allow-short-body only for intentional short writes; #20 requires its board heading, warns when HWLAB product/user issue routing appears in favor of pikasTech/HWLAB, and still rejects commander brief update sections; commander-brief requires its stable heading on legacy #24 plus daily rolling brief issues titled YYYY-MM-DD 指挥简报(北京时间).",
|
||||
"issue update dry-run reports bounded bodyPreview/bodyPreviewLines, old/new body length slots, body SHA, required heading checks, literal \\n detection, shell-pollution signals, guard/concurrency summary, wouldPatch, and readCommands without printing an unbounded full body. Non-dry-run automatically reads current issue metadata before PATCH and returns oldBodySha/updatedAt; --expect-updated-at or --expect-body-sha remain available for explicit stale-cache protection.",
|
||||
"issue comment create accepts --body-file <file|-> for Markdown/generated content and --body only for short single-line text. Blank, multiline, shell-polluted, secret-like, and overlong inline bodies fail structurally.",
|
||||
"issue close/reopen default success output is compact and omits full issue.body. Use gh issue read <number> --json body or --full/--raw on read when full text is needed.",
|
||||
"issue stale-close is the reusable lifecycle cleanup path for policies such as closing open issues inactive for more than 48 hours. It selects open issues by GitHub updatedAt older than observedAt - --inactive-hours, treats comments and state changes as activity, filters pull requests, supports --dry-run, and returns bounded candidate/closed/failure summaries without echoing full bodies.",
|
||||
"For one-shot issue writes, pipe reviewed Markdown through stdin: cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - or gh issue comment create <number> --body-file -. When staging a body file from a shell, use a quoted heredoc such as cat <<'EOF' > /tmp/body.md so backticks and backslashes are not expanded before --body-file reads the file.",
|
||||
"For JSON request bodies in other CLI namespaces, prefer --body-file or --body-stdin over long inline shell arguments. GitHub issue/PR Markdown writes use --body-file <file|-> for long or multiline content.",
|
||||
"issue scan-escape classifies literal \\n findings as suspected-pollution, explanatory-mention, or risk, and emits cleanupSuggestions with body/comment ids plus diff-like previews. cleanup-plan is an alias that remains dry-run/read-only.",
|
||||
@@ -6064,13 +6395,15 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
return validationError(command, options.repo, "--json field selection is only supported by gh issue read/view/list and gh pr read/view/list");
|
||||
}
|
||||
}
|
||||
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "preflight" || sub === "closeout")))) {
|
||||
if ((optionWasProvided(args, "--raw") || optionWasProvided(args, "--full")) && !((top === "issue" && (isIssueReadCommand(sub) || sub === "list" || sub === "update" || sub === "edit")) || top === "preflight" || (top === "pr" && (isPrReadCommand(sub) || sub === "list" || sub === "preflight" || sub === "closeout")))) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue read/view/update/edit, gh pr read/view, and gh pr preflight/closeout.", {
|
||||
return validationError(command, options.repo, "--raw and --full are explicit full-disclosure aliases only for gh issue list/read/view/update/edit, gh pr list/read/view, and gh pr preflight/closeout.", {
|
||||
supportedCommands: [
|
||||
"bun scripts/cli.ts gh issue list --repo owner/name --limit 200 --full",
|
||||
"bun scripts/cli.ts gh issue read owner/name#<number> --raw",
|
||||
"bun scripts/cli.ts gh issue read <number> --repo owner/name --json body,title,state,comments",
|
||||
"cat body.md | bun scripts/cli.ts gh issue update <number> --repo owner/name --body-file - --full",
|
||||
"bun scripts/cli.ts gh pr list --repo owner/name --limit 100 --full",
|
||||
"bun scripts/cli.ts gh pr read owner/name#<number> --raw",
|
||||
`bun scripts/cli.ts gh pr read <number> --repo owner/name --json ${readViewSupportedJsonFields("pr")}`,
|
||||
"bun scripts/cli.ts gh pr preflight <number> --repo owner/name --full",
|
||||
@@ -6135,14 +6468,18 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
const command = [top, sub, third].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--category, --branch, --tasks, --summary, --focus, --validation, and --progress are only supported by gh issue board-row upsert");
|
||||
}
|
||||
if (optionWasProvided(args, "--label") && !(top === "issue" && (sub === "create" || sub === "list"))) {
|
||||
if (optionWasProvided(args, "--label") && !(top === "issue" && (sub === "create" || sub === "list" || sub === "stale-close"))) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--label is only supported by gh issue create and gh issue list");
|
||||
return validationError(command, options.repo, "--label is only supported by gh issue create, gh issue list, and gh issue stale-close");
|
||||
}
|
||||
if (optionWasProvided(args, "--search") && !(top === "issue" && sub === "list")) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--search is only supported by gh issue list");
|
||||
}
|
||||
if (optionWasProvided(args, "--inactive-hours") && !(top === "issue" && sub === "stale-close")) {
|
||||
const command = [top, sub].filter((value): value is string => value !== undefined).join(" ") || "gh";
|
||||
return validationError(command, options.repo, "--inactive-hours is only supported by gh issue stale-close");
|
||||
}
|
||||
|
||||
if (top === "auth" && sub === "status") return authStatus(options.repo);
|
||||
|
||||
@@ -6234,7 +6571,8 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
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 === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields, options.search);
|
||||
if (sub === "list") return issueList(options.repo, token, options.listState, options.limit, options.issueListJsonFields, options.search, options.labels, options.raw || options.full);
|
||||
if (sub === "stale-close") return issueStaleClose(options.repo, token, options);
|
||||
if (sub === "create") return issueCreate(options.repo, token, options);
|
||||
if (sub === "edit") return issueEdit(options.repo, token, parseNumber(third, "issue edit"), options);
|
||||
if (sub === "update") return issueEdit(options.repo, token, parseNumber(third, "issue update"), options, "issue update");
|
||||
@@ -6356,7 +6694,7 @@ export async function runGhCommand(args: string[]): Promise<GitHubCommandResult
|
||||
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.prListState, options.limit, options.prListJsonFields);
|
||||
if (sub === "list") return prList(options.repo, token, options.prListState, options.limit, options.prListJsonFields, options.raw || options.full);
|
||||
}
|
||||
|
||||
return unsupportedCommand(args.join(" ") || "gh", options.repo, "Unsupported gh command", { help: ghHelp() });
|
||||
|
||||
@@ -80,7 +80,7 @@ function shouldSuppressStack(prefix: string): boolean {
|
||||
|
||||
function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
const fullText = `${JSON.stringify(envelope, null, 2)}\n`;
|
||||
if (!shouldDumpLargeOutput(command, fullText)) return fullText;
|
||||
if (!shouldDumpLargeOutput(command, fullText, envelope)) return fullText;
|
||||
|
||||
const dump = dumpLargeOutput(command, fullText);
|
||||
const compactPayload = {
|
||||
@@ -96,9 +96,10 @@ function renderEnvelope<T>(command: string, envelope: JsonEnvelope<T>): string {
|
||||
return `${JSON.stringify(compactEnvelope, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function shouldDumpLargeOutput(command: string, text: string): boolean {
|
||||
function shouldDumpLargeOutput(command: string, text: string, envelope: JsonEnvelope<unknown>): boolean {
|
||||
if (!(command === "gh" || command.startsWith("gh "))) return false;
|
||||
if (process.env.UNIDESK_CLI_GH_OUTPUT_DUMP_DISABLED === "1") return false;
|
||||
if (typeof envelope.data === "object" && envelope.data !== null && (envelope.data as { noDump?: unknown }).noDump === true) return false;
|
||||
const threshold = configuredDumpThresholdBytes();
|
||||
return Buffer.byteLength(text, "utf8") > threshold;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user