edc437cdd8
Co-authored-by: Codex <codex@noreply.local>
235 lines
12 KiB
TypeScript
235 lines
12 KiB
TypeScript
// SPEC: PJ2026-010606 GitHub入口 draft-2026-06-25-gh-split
|
|
// Moved mechanically from scripts/src/gh.ts:4462-4684.
|
|
|
|
import path from "node:path";
|
|
import { bodySafetySignals, bodySha, normalizeExpectedSha, preview, previewLines } from "./auth-and-safety";
|
|
import { sortedIssueEntries } from "./board-parser";
|
|
import { codeQueueBoardCommanderBriefHint, codeQueueBoardHintHasCommanderBriefUpdates, codeQueueBoardHintHasHwlabProductRouting, extractTimelineSections, normalizeNewlines } from "./body-profiles";
|
|
import { validationError } from "./client";
|
|
import { CODE_QUEUE_BOARD_TARGET_ISSUE, COMMANDER_BRIEF_TARGET_ISSUE, DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN, DAILY_COMMANDER_BRIEF_TITLE_PATTERN, ISSUE_BODY_PROFILES, LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN, MIN_SAFE_ISSUE_BODY_CHARS } from "./types";
|
|
import type { BoardIgnoredIssue, BoardIgnoreReason, BoardIssueEntry, GitHubCommandResult, GitHubComment, GitHubIssue, GitHubOptions, IssueBodyProfileName, IssueBodyProfileOption, IssueProfileValidationContext } from "./types";
|
|
|
|
export function ignoredIssueList(issues: BoardIssueEntry[], ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
|
|
return sortedIssueEntries(issues.filter((issue) => ignoreMap.has(issue.number))).map((issue) => ({
|
|
number: issue.number,
|
|
reason: ignoreMap.get(issue.number) ?? "ignored",
|
|
title: issue.title,
|
|
state: issue.state,
|
|
url: issue.url,
|
|
}));
|
|
}
|
|
|
|
export function ignoredBoardOnlyIssues(issueNumbers: number[], issueMap: Map<number, BoardIssueEntry>, ignoreMap: Map<number, BoardIgnoreReason>): BoardIgnoredIssue[] {
|
|
return issueNumbers
|
|
.filter((issueNumber) => ignoreMap.has(issueNumber) && !issueMap.has(issueNumber))
|
|
.sort((a, b) => a - b)
|
|
.map((issueNumber) => ({ number: issueNumber, reason: ignoreMap.get(issueNumber) ?? "ignored" }));
|
|
}
|
|
|
|
export function issueProfileFor(issueNumber: number, requested: IssueBodyProfileOption): IssueBodyProfileName | null {
|
|
if (requested !== "auto") return requested;
|
|
if (issueNumber === CODE_QUEUE_BOARD_TARGET_ISSUE) return "code-queue-board";
|
|
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return "commander-brief";
|
|
return null;
|
|
}
|
|
|
|
export function firstMarkdownLine(body: string | null | undefined): string {
|
|
return normalizeNewlines(body ?? "").split("\n").find((line) => line.trim().length > 0)?.trim() ?? "";
|
|
}
|
|
|
|
export function isDailyCommanderBriefTitle(title: string | null | undefined): boolean {
|
|
return DAILY_COMMANDER_BRIEF_TITLE_PATTERN.test((title ?? "").trim());
|
|
}
|
|
|
|
export function isDailyCommanderBriefBodyHeading(line: string): boolean {
|
|
return DAILY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
|
|
}
|
|
|
|
export function isLegacyCommanderBriefBodyHeading(line: string): boolean {
|
|
return LEGACY_COMMANDER_BRIEF_BODY_HEADING_PATTERN.test(line.trim());
|
|
}
|
|
|
|
export function currentBodyLooksLikeCommanderBrief(body: string | null | undefined): boolean {
|
|
const normalized = normalizeNewlines(body ?? "");
|
|
const firstLine = firstMarkdownLine(normalized);
|
|
if (isDailyCommanderBriefBodyHeading(firstLine)) return true;
|
|
return isLegacyCommanderBriefBodyHeading(firstLine)
|
|
&& normalized.includes("## 常驻观察与长期建议")
|
|
&& extractTimelineSections(normalized).length > 0;
|
|
}
|
|
|
|
export function commanderBriefIssueProfileMatch(issueNumber: number, context: IssueProfileValidationContext): { ok: boolean; reason: string } {
|
|
if (issueNumber === COMMANDER_BRIEF_TARGET_ISSUE) return { ok: true, reason: "legacy-issue-number" };
|
|
if (isDailyCommanderBriefTitle(context.issueTitle)) return { ok: true, reason: "daily-title" };
|
|
if (currentBodyLooksLikeCommanderBrief(context.issueBody)) return { ok: true, reason: "current-body-heading" };
|
|
return {
|
|
ok: false,
|
|
reason: context.issueMetadataFetched === true ? "not-commander-brief" : "issue-metadata-unavailable",
|
|
};
|
|
}
|
|
|
|
export function issueProfileNeedsMetadata(issueNumber: number, requested: IssueBodyProfileOption): boolean {
|
|
return requested === "commander-brief" && issueNumber !== COMMANDER_BRIEF_TARGET_ISSUE;
|
|
}
|
|
|
|
export function issueProfileValidation(issueNumber: number, body: string, requested: IssueBodyProfileOption, context: IssueProfileValidationContext = {}): Record<string, unknown> {
|
|
const profileName = issueProfileFor(issueNumber, requested);
|
|
if (profileName === null) return { profile: null, applied: false, requiredHeadings: [], missingHeadings: [], ok: true };
|
|
const profile = ISSUE_BODY_PROFILES[profileName];
|
|
const match = profileName === "commander-brief"
|
|
? commanderBriefIssueProfileMatch(issueNumber, context)
|
|
: { ok: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber, reason: issueNumber === ISSUE_BODY_PROFILES["code-queue-board"].issueNumber ? "issue-number" : "wrong-issue-number" };
|
|
const missingHeadings = profile.requiredHeadings.filter((heading) => !body.includes(heading));
|
|
return {
|
|
profile: profileName,
|
|
label: profile.label,
|
|
applied: true,
|
|
...(profileName === "code-queue-board"
|
|
? { expectedIssueNumber: ISSUE_BODY_PROFILES["code-queue-board"].issueNumber }
|
|
: { legacyIssueNumber: COMMANDER_BRIEF_TARGET_ISSUE }),
|
|
issueMatchesProfile: match.ok,
|
|
issueMatchReason: match.reason,
|
|
issueMetadataFetched: context.issueMetadataFetched === true,
|
|
issueTitle: context.issueTitle ?? null,
|
|
requiredHeadings: profile.requiredHeadings,
|
|
missingHeadings,
|
|
ok: match.ok && missingHeadings.length === 0,
|
|
};
|
|
}
|
|
|
|
export function validationBoolean(record: Record<string, unknown>, key: string): boolean {
|
|
return record[key] === true;
|
|
}
|
|
|
|
export function validationStringArray(record: Record<string, unknown>, key: string): string[] {
|
|
const value = record[key];
|
|
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
}
|
|
|
|
export function validateIssueBodyGuard(repo: string, issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}, commandName = "issue edit", bodySource: Record<string, unknown> = { kind: "body-file", path: options.bodyFile ?? null }): GitHubCommandResult | null {
|
|
const trimmed = body.trim();
|
|
const isLiteralNull = trimmed.toLowerCase() === "null";
|
|
const isBlank = trimmed.length === 0;
|
|
const isShort = trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS;
|
|
const profileValidation = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
|
|
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
|
|
const boardContainsCommanderBriefUpdates = codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint);
|
|
const profileOk = validationBoolean(profileValidation, "ok");
|
|
const failures: string[] = [];
|
|
if (isLiteralNull) failures.push("literal-null-body");
|
|
if (isBlank) failures.push("blank-body");
|
|
if (isShort && !options.allowShortBody) failures.push("short-body");
|
|
if (boardContainsCommanderBriefUpdates) failures.push("code-queue-board-contains-commander-brief-updates");
|
|
if (!profileOk) {
|
|
if (profileValidation.issueMatchesProfile === false) failures.push("profile-issue-mismatch");
|
|
const missingHeadings = validationStringArray(profileValidation, "missingHeadings");
|
|
if (missingHeadings.length > 0) failures.push("profile-heading-missing");
|
|
}
|
|
if (failures.length === 0) return null;
|
|
const overrideAvailable = failures.every((failure) => failure === "short-body");
|
|
const message = overrideAvailable
|
|
? `${commandName} body is shorter than the safe default; pass --allow-short-body only for intentional short writes`
|
|
: `${commandName} body failed safety guard; refusing to write possible body-only issue corruption`;
|
|
return validationError(commandName, repo, message, {
|
|
issueNumber,
|
|
guard: {
|
|
ok: false,
|
|
failures,
|
|
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
|
|
allowShortBody: options.allowShortBody,
|
|
shortBodyOverrideAvailable: overrideAvailable,
|
|
bodySource,
|
|
bodyPreview: preview(body),
|
|
bodyPreviewLines: previewLines(body),
|
|
codeQueueBoardHint: boardBriefHint,
|
|
...bodySafetySignals(body),
|
|
isLiteralNull,
|
|
isBlank,
|
|
isShort,
|
|
profile: profileValidation,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function issueEditGuardSummary(issueNumber: number, body: string, options: GitHubOptions, profileContext: IssueProfileValidationContext = {}): Record<string, unknown> {
|
|
const trimmed = body.trim();
|
|
const profile = issueProfileValidation(issueNumber, body, options.bodyProfile, profileContext);
|
|
const boardBriefHint = codeQueueBoardCommanderBriefHint(issueNumber, body);
|
|
const warnings: string[] = [];
|
|
if (trimmed.length > 0 && trimmed.length < MIN_SAFE_ISSUE_BODY_CHARS) {
|
|
warnings.push(options.allowShortBody ? "short-body-allowed" : "short-body-would-fail");
|
|
}
|
|
if (options.allowShortBody) warnings.push("allow-short-body enabled; caller accepted short-body corruption risk");
|
|
if (profile.ok === false) warnings.push("profile guard would fail");
|
|
if (codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint)) warnings.push("code-queue-board-contains-commander-brief-updates");
|
|
if (codeQueueBoardHintHasHwlabProductRouting(boardBriefHint)) warnings.push("code-queue-board-contains-hwlab-product-work");
|
|
const signals = bodySafetySignals(body);
|
|
const shellPollution = signals.shellPollution as Record<string, unknown>;
|
|
if (shellPollution.suspected === true) warnings.push("shell-pollution-suspected");
|
|
return {
|
|
ok: trimmed.length > 0
|
|
&& trimmed.toLowerCase() !== "null"
|
|
&& (trimmed.length >= MIN_SAFE_ISSUE_BODY_CHARS || options.allowShortBody)
|
|
&& profile.ok === true
|
|
&& !codeQueueBoardHintHasCommanderBriefUpdates(boardBriefHint),
|
|
minSafeBodyChars: MIN_SAFE_ISSUE_BODY_CHARS,
|
|
allowShortBody: options.allowShortBody,
|
|
warnings,
|
|
profile,
|
|
codeQueueBoardHint: boardBriefHint,
|
|
...signals,
|
|
};
|
|
}
|
|
|
|
export function assertConcurrencyOptions(options: GitHubOptions, commandName: string): GitHubCommandResult | null {
|
|
if (options.expectBodySha === undefined) return null;
|
|
try {
|
|
normalizeExpectedSha(options.expectBodySha);
|
|
return null;
|
|
} catch (error) {
|
|
return validationError(commandName, options.repo, error instanceof Error ? error.message : String(error), {
|
|
expectBodySha: options.expectBodySha,
|
|
});
|
|
}
|
|
}
|
|
|
|
export function validateIssueConcurrency(repo: string, issueNumber: number, oldIssue: GitHubIssue, options: GitHubOptions, commandName = "issue edit"): GitHubCommandResult | null {
|
|
const checks: Record<string, unknown> = {
|
|
issueNumber,
|
|
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
|
actualUpdatedAt: oldIssue.updated_at ?? null,
|
|
expectBodySha: options.expectBodySha ?? null,
|
|
actualBodySha: bodySha(oldIssue.body ?? ""),
|
|
};
|
|
if (options.expectUpdatedAt !== undefined && oldIssue.updated_at !== options.expectUpdatedAt) {
|
|
return validationError(commandName, repo, "--expect-updated-at did not match current GitHub issue updated_at; refusing stale body overwrite", checks);
|
|
}
|
|
if (options.expectBodySha !== undefined) {
|
|
const expected = normalizeExpectedSha(options.expectBodySha);
|
|
if (bodySha(oldIssue.body ?? "") !== expected) {
|
|
return validationError(commandName, repo, "--expect-body-sha did not match current GitHub issue body; refusing stale body overwrite", checks);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function validateCommentConcurrency(repo: string, commentId: number, oldComment: GitHubComment, options: GitHubOptions, commandName: string): GitHubCommandResult | null {
|
|
const checks: Record<string, unknown> = {
|
|
commentId,
|
|
expectUpdatedAt: options.expectUpdatedAt ?? null,
|
|
actualUpdatedAt: oldComment.updated_at ?? null,
|
|
expectBodySha: options.expectBodySha ?? null,
|
|
actualBodySha: bodySha(oldComment.body ?? ""),
|
|
};
|
|
if (options.expectUpdatedAt !== undefined && oldComment.updated_at !== options.expectUpdatedAt) {
|
|
return validationError(commandName, repo, "--expect-updated-at did not match current GitHub comment updated_at; refusing stale body overwrite", checks);
|
|
}
|
|
if (options.expectBodySha !== undefined) {
|
|
const expected = normalizeExpectedSha(options.expectBodySha);
|
|
if (bodySha(oldComment.body ?? "") !== expected) {
|
|
return validationError(commandName, repo, "--expect-body-sha did not match current GitHub comment body; refusing stale body overwrite", checks);
|
|
}
|
|
}
|
|
return null;
|
|
}
|