edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
|
|
// Moved mechanically from scripts/src/gh.ts:2463-2672.
|
|
|
|
import path from "node:path";
|
|
import { bodySha, preview, previewLines, repoParts } from "./auth-and-safety";
|
|
import { commandError, githubRequest, isGitHubError, issueBodyReadCommands, validationError } from "./client";
|
|
import { repoSummary } from "./repo-compare";
|
|
import { ISSUE_LIFECYCLE_PREVIEW_LIMIT } from "./types";
|
|
import type { GitHubAuthenticatedUser, GitHubCommandResult, GitHubIssue, GitHubOptions, GitHubRepository, IssueListJsonField } from "./types";
|
|
|
|
export function issueSummary(issue: GitHubIssue, options: { includeBody?: boolean; includePreview?: boolean; previewLineCount?: number } = {}): Record<string, unknown> {
|
|
const body = issue.body ?? "";
|
|
const includeBody = options.includeBody ?? true;
|
|
const includePreview = options.includePreview ?? true;
|
|
const summary: Record<string, unknown> = {
|
|
id: issue.id,
|
|
number: issue.number,
|
|
title: issue.title,
|
|
state: issue.state,
|
|
closed: issue.state === "closed",
|
|
closedAt: issue.closed_at ?? null,
|
|
url: issue.html_url,
|
|
author: issue.user?.login ?? null,
|
|
createdAt: issue.created_at ?? null,
|
|
updatedAt: issue.updated_at ?? null,
|
|
commentCount: issue.comments ?? null,
|
|
bodyChars: body.length,
|
|
bodySha: bodySha(body),
|
|
};
|
|
if (includeBody) {
|
|
summary.body = body;
|
|
} else {
|
|
summary.bodyOmitted = true;
|
|
summary.fullBodyIncluded = false;
|
|
if (includePreview) {
|
|
summary.bodyPreview = preview(body);
|
|
summary.bodyPreviewLines = previewLines(body, options.previewLineCount ?? 8);
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
export async function repoView(repo: string, token: string): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const result = await githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`);
|
|
if (isGitHubError(result)) return commandError("repo view", repo, result);
|
|
return {
|
|
ok: true,
|
|
command: "repo view",
|
|
repo,
|
|
repository: repoSummary(result),
|
|
request: {
|
|
method: "GET",
|
|
path: `/repos/${owner}/${name}`,
|
|
},
|
|
rest: true,
|
|
};
|
|
}
|
|
|
|
export async function repoCreate(repo: string, token: string, options: GitHubOptions): Promise<GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const visibility = options.repoVisibility ?? "private";
|
|
const viewer = options.dryRun ? null : await githubRequest<GitHubAuthenticatedUser>(token, "GET", "/user");
|
|
if (isGitHubError(viewer)) return commandError("repo create", repo, viewer, { phase: "viewer" });
|
|
const viewerLogin = viewer?.login ?? null;
|
|
const createPath = viewerLogin !== null && viewerLogin.toLowerCase() === owner.toLowerCase()
|
|
? "/user/repos"
|
|
: `/orgs/${owner}/repos`;
|
|
const payload: Record<string, unknown> = {
|
|
name,
|
|
private: visibility === "private",
|
|
auto_init: options.repoAutoInit,
|
|
};
|
|
if (options.repoDescription !== undefined) payload.description = options.repoDescription;
|
|
const planned = {
|
|
repo,
|
|
owner,
|
|
name,
|
|
visibility,
|
|
description: options.repoDescription ?? null,
|
|
autoInit: options.repoAutoInit,
|
|
request: {
|
|
method: "POST",
|
|
path: createPath,
|
|
body: {
|
|
name,
|
|
private: visibility === "private",
|
|
descriptionChars: options.repoDescription?.length ?? 0,
|
|
auto_init: options.repoAutoInit,
|
|
},
|
|
},
|
|
};
|
|
if (options.dryRun) {
|
|
return {
|
|
ok: true,
|
|
command: "repo create",
|
|
repo,
|
|
dryRun: true,
|
|
planned,
|
|
note: "Dry-run only; no GitHub repository was created.",
|
|
};
|
|
}
|
|
const existing = await githubRequest<GitHubRepository>(token, "GET", `/repos/${owner}/${name}`);
|
|
if (!isGitHubError(existing)) {
|
|
return validationError("repo create", repo, "repository already exists; refusing duplicate create", {
|
|
repository: repoSummary(existing),
|
|
planned,
|
|
next: {
|
|
command: `bun scripts/cli.ts gh repo view ${repo}`,
|
|
},
|
|
});
|
|
}
|
|
if (existing.degradedReason !== "repo-not-found") {
|
|
return commandError("repo create", repo, existing, { phase: "preflight-existing-repo", planned });
|
|
}
|
|
const created = await githubRequest<GitHubRepository>(token, "POST", createPath, payload);
|
|
if (isGitHubError(created)) return commandError("repo create", repo, created, { planned });
|
|
return {
|
|
ok: true,
|
|
command: "repo create",
|
|
repo,
|
|
repository: repoSummary(created),
|
|
planned,
|
|
rest: true,
|
|
};
|
|
}
|
|
|
|
export function issueLifecycleSummary(issue: GitHubIssue): Record<string, unknown> {
|
|
return {
|
|
id: issue.id,
|
|
number: issue.number,
|
|
title: issue.title,
|
|
state: issue.state,
|
|
closed: issue.state === "closed",
|
|
closedAt: issue.closed_at ?? null,
|
|
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,
|
|
};
|
|
}
|
|
|
|
export 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 view --json body when full text is needed.",
|
|
readCommands: issueBodyReadCommands(repo, issueNumber),
|
|
};
|
|
}
|
|
|
|
export 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),
|
|
};
|
|
}
|
|
|
|
export 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 {
|
|
name: label.name ?? "",
|
|
color: label.color ?? null,
|
|
description: label.description ?? null,
|
|
};
|
|
}
|
|
|
|
export function issueLabelNames(issue: GitHubIssue): string[] {
|
|
return (issue.labels ?? []).map((label) => typeof label === "string" ? label : label.name ?? "").filter((label) => label.length > 0);
|
|
}
|
|
|
|
export function issueListSummary(issue: GitHubIssue, fields: IssueListJsonField[]): Record<string, unknown> {
|
|
const summary: Record<IssueListJsonField, unknown> = {
|
|
number: issue.number,
|
|
title: issue.title,
|
|
state: issue.state,
|
|
closed: issue.state === "closed",
|
|
closedAt: issue.closed_at ?? null,
|
|
url: issue.html_url,
|
|
updatedAt: issue.updated_at ?? null,
|
|
createdAt: issue.created_at ?? null,
|
|
author: issue.user?.login ?? null,
|
|
labels: (issue.labels ?? []).map(labelSummary),
|
|
};
|
|
const selected: Record<string, unknown> = {};
|
|
for (const field of fields) selected[field] = summary[field];
|
|
return selected;
|
|
}
|