Files
pikasTech-unidesk/scripts/src/gh/body-guards.ts
T
2026-06-26 00:16:53 +08:00

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;
}