Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/gittensory-miner/lib/rejection-templates.d.ts
Original file line number Diff line number Diff line change
@@ -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;
71 changes: 71 additions & 0 deletions packages/gittensory-miner/lib/rejection-templates.js
Original file line number Diff line number Diff line change
@@ -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/<repo>`) 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;
}
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 67 additions & 0 deletions test/unit/miner-rejection-templates.test.ts
Original file line number Diff line number Diff line change
@@ -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/<repo>", "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",
);
});
});
Loading