From e89f5f7a370a99679a00396acf708594119ef3b1 Mon Sep 17 00:00:00 2001 From: dhgoal <153369624+dhgoal@users.noreply.github.com> Date: Fri, 10 Jul 2026 03:36:06 +0900 Subject: [PATCH] feat(miner-manage): add rejection state machine (closed/rejected -> disengaged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New packages/gittensory-miner/lib/rejection-state-machine.js: the missing detector + classifier that turns a closed-without-merge PR into a rejection-reason bucket and, for the first time, drives renderRejectionMessage (previously caller-less). Pure — no GitHub calls, no network, no writes. - extractPrOutcomeFields: pull state/merged/merged_at/closed_at from a PR payload the poller already fetches (no second API call, no change to ci-poller's fetchHeadSha). - isRejectedPr / classifyRejectionReason: closed-without-merge detection + map to gate_close / superseded_by_duplicate / maintainer_close_no_reason (documented zero-signal fallback; gate outranks duplicate). - resolveRejection: the full transition to the 'disengaged' per-PR outcome + the rendered courtesy note. Design decisions documented in-module: 'disengaged' is a per-PR outcome (manage-poll family), NOT a run-state; this module does not silently expand another module's enum. Closes #4278 --- .../lib/rejection-state-machine.d.ts | 37 +++++++++ .../lib/rejection-state-machine.js | 81 +++++++++++++++++++ .../miner-rejection-state-machine.test.ts | 75 +++++++++++++++++ 3 files changed, 193 insertions(+) create mode 100644 packages/gittensory-miner/lib/rejection-state-machine.d.ts create mode 100644 packages/gittensory-miner/lib/rejection-state-machine.js create mode 100644 test/unit/miner-rejection-state-machine.test.ts diff --git a/packages/gittensory-miner/lib/rejection-state-machine.d.ts b/packages/gittensory-miner/lib/rejection-state-machine.d.ts new file mode 100644 index 0000000000..35b15d5600 --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-state-machine.d.ts @@ -0,0 +1,37 @@ +import type { RejectionReason, RejectionContext } from "./rejection-templates.js"; + +export type PrOutcomeFields = { + state: string | null; + merged: boolean; + mergedAt: string | null; + closedAt: string | null; +}; + +export type RejectionSignal = { + gateClosed?: boolean; + supersededByDuplicate?: boolean; +}; + +export type RejectionTransition = { + outcome: "disengaged"; + reason: RejectionReason; + note: string; + fields: PrOutcomeFields; +}; + +/** Per-PR terminal outcome for a rejected (closed-without-merge) PR. */ +export const DISENGAGED_OUTCOME: "disengaged"; + +export function extractPrOutcomeFields(prPayload: unknown): PrOutcomeFields; + +export function isRejectedPr( + fields: { state?: string | null; merged?: boolean } | null | undefined, +): boolean; + +export function classifyRejectionReason(signal?: RejectionSignal): RejectionReason; + +export function resolveRejection( + prPayload: unknown, + signal: RejectionSignal | undefined, + context: RejectionContext, +): RejectionTransition | null; diff --git a/packages/gittensory-miner/lib/rejection-state-machine.js b/packages/gittensory-miner/lib/rejection-state-machine.js new file mode 100644 index 0000000000..687543b717 --- /dev/null +++ b/packages/gittensory-miner/lib/rejection-state-machine.js @@ -0,0 +1,81 @@ +// Rejection state machine (#4278): the missing detector + classifier that turns a closed-without-merge PR +// into a rejection-reason bucket and, for the first time, drives `renderRejectionMessage` +// (rejection-templates.js, which until now had zero callers outside its own test). Pure classification and +// content only — no GitHub calls, no network, no writes. The caller (a poller) persists the result locally. +// +// DESIGN DECISIONS (called out explicitly by #4278): +// • "disengaged" is a per-PR OUTCOME, not a per-repo run-state. A rejection is about one PR, so it belongs +// with the `manage-poll.js` outcome family (ready / needs-work / open), NOT `run-state.js`'s RUN_STATES +// (idle / discovering / planning / preparing). `DISENGAGED_OUTCOME` is defined HERE and left for a poller +// to adopt — this module deliberately does NOT mutate manage-poll.js's or run-state.js's enum as a side +// effect (the issue explicitly warns against silently expanding another module's vocabulary). +// • Zero-signal fallback: with no gate/duplicate signal, a rejection classifies as `maintainer_close_no_reason` +// — the courteous, non-assuming bucket — rather than being left unclassified, so a rejection ALWAYS renders +// a note. +// • This surfaces the PR's terminal fields from a payload the poller already fetches (ci-poller.js's +// `fetchHeadSha` GETs the full `/pulls/{n}` body, :155-163, and discards all but `head.sha`) via a pure +// extractor — no second API call, and no behavioral change to the existing fetch. + +import { renderRejectionMessage } from "./rejection-templates.js"; + +/** Per-PR terminal outcome for a rejected (closed-without-merge) PR. A poller adds this to its own outcome + * vocabulary alongside ready / needs-work / open. */ +export const DISENGAGED_OUTCOME = "disengaged"; + +/** + * Pull the terminal-outcome fields from a `GET /pulls/{n}` payload the poller already has. Pure — no API call. + * Missing/malformed fields normalize to null/false so a partial payload never throws here. + * @param {unknown} prPayload + * @returns {{ state: string | null, merged: boolean, mergedAt: string | null, closedAt: string | null }} + */ +export function extractPrOutcomeFields(prPayload) { + const p = prPayload && typeof prPayload === "object" ? prPayload : {}; + return { + state: typeof p.state === "string" ? p.state : null, + merged: p.merged === true, + mergedAt: typeof p.merged_at === "string" ? p.merged_at : null, + closedAt: typeof p.closed_at === "string" ? p.closed_at : null, + }; +} + +/** + * True when a PR is closed WITHOUT a merge — the rejection this state machine acts on. A merged PR (even though + * GitHub also marks it `state: "closed"`) is NOT a rejection. Pure. + * @param {{ state?: string | null, merged?: boolean }} fields + */ +export function isRejectedPr(fields) { + const f = fields && typeof fields === "object" ? fields : {}; + return f.state === "closed" && f.merged !== true; +} + +/** + * Classify a detected rejection into one of the rejection-reason buckets from the available signal. + * Precedence: an explicit gate close outranks a duplicate signal (the gate is the more specific, actionable + * cause). With neither signal, defaults to `maintainer_close_no_reason` (the documented zero-signal fallback). + * Pure. + * @param {{ gateClosed?: boolean, supersededByDuplicate?: boolean }} [signal] + * @returns {"gate_close" | "superseded_by_duplicate" | "maintainer_close_no_reason"} + */ +export function classifyRejectionReason(signal = {}) { + const s = signal && typeof signal === "object" ? signal : {}; + if (s.gateClosed === true) return "gate_close"; + if (s.supersededByDuplicate === true) return "superseded_by_duplicate"; + return "maintainer_close_no_reason"; +} + +/** + * The full transition. Given a PR payload, an optional gate/duplicate signal, and the render context + * (`{ repoFullName, prNumber }`), decide whether the PR is a rejection and, if so, produce the disengaged + * transition: the classified reason and the rendered courtesy note (this is `renderRejectionMessage`'s first + * real caller). Returns null when the PR is not a rejection (still open, or merged) — nothing to disengage. + * Pure and deterministic; the caller persists `{ outcome, reason, note }` via its local event ledger. + * @returns {{ outcome: string, reason: string, note: string, + * fields: ReturnType } | null} + */ +export function resolveRejection(prPayload, signal, context) { + const fields = extractPrOutcomeFields(prPayload); + if (!isRejectedPr(fields)) return null; + const reason = classifyRejectionReason(signal); + const note = renderRejectionMessage(reason, context); // throws on malformed context — a half-note never emits + return { outcome: DISENGAGED_OUTCOME, reason, note, fields }; +} diff --git a/test/unit/miner-rejection-state-machine.test.ts b/test/unit/miner-rejection-state-machine.test.ts new file mode 100644 index 0000000000..2dd2846c43 --- /dev/null +++ b/test/unit/miner-rejection-state-machine.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + DISENGAGED_OUTCOME, + extractPrOutcomeFields, + isRejectedPr, + classifyRejectionReason, + resolveRejection, +} from "../../packages/gittensory-miner/lib/rejection-state-machine.js"; + +const CONTEXT = { repoFullName: "JSONbored/gittensory", prNumber: 4278 } as const; +const closedUnmerged = { state: "closed", merged: false, merged_at: null, closed_at: "2026-07-09T18:00:00Z" }; + +describe("gittensory-miner rejection state machine (#4278)", () => { + it("extracts terminal-outcome fields from a full PR payload", () => { + expect(extractPrOutcomeFields(closedUnmerged)).toEqual({ + state: "closed", + merged: false, + mergedAt: null, + closedAt: "2026-07-09T18:00:00Z", + }); + }); + + it("normalizes missing/malformed payload fields to null/false without throwing", () => { + expect(extractPrOutcomeFields(undefined)).toEqual({ state: null, merged: false, mergedAt: null, closedAt: null }); + expect(extractPrOutcomeFields({ state: 42, merged: "yes" })).toEqual({ + state: null, + merged: false, + mergedAt: null, + closedAt: null, + }); + }); + + it("detects closed-without-merge as a rejection, but not a merged or open PR", () => { + expect(isRejectedPr({ state: "closed", merged: false })).toBe(true); + expect(isRejectedPr({ state: "closed", merged: true })).toBe(false); // merged PRs are also state:closed + expect(isRejectedPr({ state: "open", merged: false })).toBe(false); + expect(isRejectedPr(undefined)).toBe(false); + }); + + it("classifies each reason bucket, defaulting to maintainer_close_no_reason with no signal", () => { + expect(classifyRejectionReason({ gateClosed: true })).toBe("gate_close"); + expect(classifyRejectionReason({ supersededByDuplicate: true })).toBe("superseded_by_duplicate"); + expect(classifyRejectionReason({})).toBe("maintainer_close_no_reason"); + expect(classifyRejectionReason()).toBe("maintainer_close_no_reason"); // zero-signal fallback + }); + + it("prefers the gate cause when both gate and duplicate signals are present", () => { + expect(classifyRejectionReason({ gateClosed: true, supersededByDuplicate: true })).toBe("gate_close"); + }); + + it("resolveRejection drives the renderer and returns the disengaged transition for each reason", () => { + for (const [signal, reason] of [ + [{ gateClosed: true }, "gate_close"], + [{ supersededByDuplicate: true }, "superseded_by_duplicate"], + [{}, "maintainer_close_no_reason"], + ] as const) { + const result = resolveRejection(closedUnmerged, signal, CONTEXT); + expect(result).not.toBeNull(); + expect(result?.outcome).toBe(DISENGAGED_OUTCOME); + expect(result?.reason).toBe(reason); + expect(result?.note).toContain("JSONbored/gittensory"); + expect(result?.note).toContain("#4278"); + expect(result?.note).not.toMatch(/\{[^}]+\}/); // renderer left no unresolved placeholder + } + }); + + it("resolveRejection returns null for a PR that is not a rejection (open or merged)", () => { + expect(resolveRejection({ state: "open", merged: false }, {}, CONTEXT)).toBeNull(); + expect(resolveRejection({ state: "closed", merged: true }, {}, CONTEXT)).toBeNull(); + }); + + it("exposes 'disengaged' as the per-PR outcome constant", () => { + expect(DISENGAGED_OUTCOME).toBe("disengaged"); + }); +});