From f211c4d944c846e831a501e2175d6bb7a4fb7857 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:20:56 +0900 Subject: [PATCH] feat(miner): add CoC-compliant rejection message templates Add packages/gittensory-miner/lib/rejection-templates.js: a pure, deterministic renderer for the courtesy note the miner may leave locally when one of its PRs is closed/rejected. Static templates keyed by reason bucket (gate_close, maintainer_close_no_reason, superseded_by_duplicate) rendered from a structured context (repoFullName + prNumber); no GitHub calls, no LLM, no network. Notes are courteous and non-defensive, never re-litigating the maintainer decision. containsPrivateLanguage mirrors sanitizePublicComment's redaction set and the templates are asserted free of private-language tokens. Closes #2324. --- .../lib/rejection-templates.d.ts | 12 ++++ .../lib/rejection-templates.js | 71 +++++++++++++++++++ packages/gittensory-miner/package.json | 2 +- test/unit/miner-rejection-templates.test.ts | 67 +++++++++++++++++ 4 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 packages/gittensory-miner/lib/rejection-templates.d.ts create mode 100644 packages/gittensory-miner/lib/rejection-templates.js create mode 100644 test/unit/miner-rejection-templates.test.ts diff --git a/packages/gittensory-miner/lib/rejection-templates.d.ts b/packages/gittensory-miner/lib/rejection-templates.d.ts new file mode 100644 index 0000000000..62bced6f5b --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-templates.d.ts @@ -0,0 +1,12 @@ +export type RejectionReason = "gate_close" | "maintainer_close_no_reason" | "superseded_by_duplicate"; + +export type RejectionContext = { + repoFullName: string; + prNumber: number; +}; + +export const REJECTION_REASONS: readonly RejectionReason[]; + +export function containsPrivateLanguage(text: string): boolean; + +export function renderRejectionMessage(reason: RejectionReason, context: RejectionContext): string; diff --git a/packages/gittensory-miner/lib/rejection-templates.js b/packages/gittensory-miner/lib/rejection-templates.js new file mode 100644 index 0000000000..bfb192bf95 --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-templates.js @@ -0,0 +1,71 @@ +// CoC-compliant rejection message templates (#2324). When one of the miner's PRs is closed/rejected, it may leave +// a single, final, human-readable local note (e.g. in a run summary or CLI output — posting anywhere is a separate +// write action, out of scope here). The note must be courteous, non-defensive, and never re-litigate the +// maintainer's decision. This module is pure content/formatting: static template strings + a deterministic +// renderer — no GitHub calls, no LLM, no network. Same inputs always render the same message. + +// Templates keyed by rejection-reason bucket. Every placeholder is `{name}`; the renderer resolves the structured +// context (a PR number + a repo) and never interpolates free-form/private text. +const REASON_TEMPLATES = { + gate_close: + "The automated review gate closed PR #{prNumber} on {repoFullName}. Thanks for the review — I'll address the flagged points and open a fresh PR if the change still fits.", + maintainer_close_no_reason: + "PR #{prNumber} on {repoFullName} was closed by the maintainer. Thanks for taking the time to look — I'll leave it here unless you'd like me to revisit it.", + superseded_by_duplicate: + "PR #{prNumber} on {repoFullName} looks superseded by other work on the same issue, so I'm closing it on my side to avoid duplication. Thanks to whoever is carrying it forward.", +}; + +/** The supported rejection-reason buckets, in declaration order. */ +export const REJECTION_REASONS = Object.freeze(Object.keys(REASON_TEMPLATES)); + +// Private-language tokens that must never surface in a public-facing courtesy note (mirrors the redaction set in +// `sanitizePublicComment`, src/github/commands.ts). Templates are authored clean and this is asserted in tests; +// the structured context (a PR number + a validated `owner/repo`) carries no private scoring/reward/wallet data, +// so — deliberately — no value-level redaction is applied that could mangle a legitimate repo name. +const PRIVATE_LANGUAGE = + /\b(?:raw trust scores?|trust scores?|wallets?|hotkeys?|coldkeys?|seed phrases?|mnemonics?|payouts?|rewards?)\b/i; + +/** True when the given text contains any banned private-language token. */ +export function containsPrivateLanguage(text) { + return PRIVATE_LANGUAGE.test(text); +} + +// A GitHub `owner/repo`: owner is 1-39 chars of alphanumerics/hyphens starting alphanumeric; repo is +// alphanumerics/`.`/`_`/`-`. Anchored + character-class-restricted so control characters, whitespace, markup, or an +// extra `/` (e.g. `owner/repo\nextra`, `owner/`) are rejected — the note interpolates this text directly, so a +// malformed value must throw rather than leak caller-controlled display text. +const GITHUB_FULL_NAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/; + +function normalizeRepoFullName(repoFullName) { + if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); + const trimmed = repoFullName.trim(); + if (!GITHUB_FULL_NAME.test(trimmed)) throw new Error("invalid_repo_full_name"); + return trimmed; +} + +function normalizePrNumber(prNumber) { + if (!Number.isInteger(prNumber) || prNumber < 1) throw new Error("invalid_pr_number"); + return prNumber; +} + +/** + * Render the courtesy note for a closed/rejected PR. `reason` must be one of {@link REJECTION_REASONS}; `context` + * supplies `repoFullName` (`owner/repo`) and `prNumber` (a positive integer). Throws on an unknown reason, a + * malformed context, or (defensively) any placeholder a template leaves unresolved — so a caller can never emit a + * half-rendered note. Pure and deterministic. + */ +export function renderRejectionMessage(reason, context = {}) { + const template = REASON_TEMPLATES[reason]; + if (template === undefined) throw new Error("invalid_rejection_reason"); + const values = { + repoFullName: normalizeRepoFullName(context.repoFullName), + prNumber: normalizePrNumber(context.prNumber), + }; + const rendered = template.replace(/\{(\w+)\}/g, (_match, key) => { + const value = values[key]; + if (value === undefined) throw new Error(`missing_placeholder:${key}`); + return String(value); + }); + if (/\{[^}]+\}/.test(rendered)) throw new Error("unresolved_placeholder"); + return rendered; +} diff --git a/packages/gittensory-miner/package.json b/packages/gittensory-miner/package.json index 644bd221fd..2c73af25b4 100644 --- a/packages/gittensory-miner/package.json +++ b/packages/gittensory-miner/package.json @@ -31,7 +31,7 @@ "lib" ], "scripts": { - "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js" + "build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/deny-check.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js && node --check lib/deny-hooks.js && node --check lib/event-ledger.js && node --check lib/claim-ledger.js && node --check lib/portfolio-queue.js && node --check lib/opportunity-ranker.js && node --check lib/plan-store.js && node --check lib/rejection-templates.js" }, "dependencies": { "@jsonbored/gittensory-engine": "0.1.0" diff --git a/test/unit/miner-rejection-templates.test.ts b/test/unit/miner-rejection-templates.test.ts new file mode 100644 index 0000000000..50fc183e37 --- /dev/null +++ b/test/unit/miner-rejection-templates.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + REJECTION_REASONS, + containsPrivateLanguage, + renderRejectionMessage, +} from "../../packages/gittensory-miner/lib/rejection-templates.js"; + +const CONTEXT = { repoFullName: "JSONbored/gittensory", prNumber: 2751 } as const; + +describe("gittensory-miner rejection templates (#2324)", () => { + it("exposes the frozen reason vocabulary", () => { + expect(REJECTION_REASONS).toEqual(["gate_close", "maintainer_close_no_reason", "superseded_by_duplicate"]); + expect(Object.isFrozen(REJECTION_REASONS)).toBe(true); + }); + + it("renders every reason bucket with no unresolved placeholders and the resolved context", () => { + for (const reason of REJECTION_REASONS) { + const message = renderRejectionMessage(reason, CONTEXT); + expect(message).not.toMatch(/\{[^}]+\}/); // no unresolved {placeholder} + expect(message).toContain("JSONbored/gittensory"); + expect(message).toContain("#2751"); + } + }); + + it("keeps every rendered note courteous and free of private-language tokens", () => { + for (const reason of REJECTION_REASONS) { + const message = renderRejectionMessage(reason, CONTEXT); + expect(containsPrivateLanguage(message)).toBe(false); + // Non-defensive: no blaming / decision re-litigation language. + expect(message.toLowerCase()).not.toMatch(/\b(wrong|unfair|mistake|should have|disagree)\b/); + } + }); + + it("detects private-language tokens (the public-safe guard)", () => { + expect(containsPrivateLanguage("thanks for the review")).toBe(false); + expect(containsPrivateLanguage("do not expose the hotkey")).toBe(true); + expect(containsPrivateLanguage("no trust score here")).toBe(true); + }); + + it("throws on an unknown reason bucket", () => { + // @ts-expect-error — reason must be a known bucket + expect(() => renderRejectionMessage("unknown_reason", CONTEXT)).toThrow("invalid_rejection_reason"); + }); + + it("throws on a malformed context rather than emitting a half-rendered note", () => { + expect(() => renderRejectionMessage("gate_close", { repoFullName: "no-slash", prNumber: 1 })).toThrow( + "invalid_repo_full_name", + ); + expect(() => renderRejectionMessage("gate_close", { repoFullName: "o/a", prNumber: 0 })).toThrow( + "invalid_pr_number", + ); + // @ts-expect-error — prNumber is required + expect(() => renderRejectionMessage("gate_close", { repoFullName: "o/a" })).toThrow("invalid_pr_number"); + }); + + it("rejects a repoFullName carrying control characters, markup, or an extra slash (no display-text leakage)", () => { + for (const bad of ["owner/repo\nextra", "owner/repo extra", "owner/", "owner/repo/extra", "-owner/repo", "owner/re*po"]) { + expect(() => renderRejectionMessage("gate_close", { repoFullName: bad, prNumber: 1 })).toThrow( + "invalid_repo_full_name", + ); + } + // A well-formed owner/repo with the allowed punctuation still renders. + expect(renderRejectionMessage("gate_close", { repoFullName: "JSONbored/gittensory.io_test-1", prNumber: 9 })).toContain( + "JSONbored/gittensory.io_test-1", + ); + }); +});