open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
258 lines (244 loc) • 9.87 kB
text/typescript
/**
* 2B/D32: GitHub PR ↔ review_state mapping.
*
* Memories ride into review inside a branch + PR (dedicated memory PR or
* together with code). The PR's lifecycle on GitHub is the team's review
* signal; this module reads it back and maps it onto each memory's own
* review_state:
*
* PR merged → proposed/approved → published
* PR reviewDecision == APPROVED → proposed → approved (approved_by = reviewer login)
* PR reviewDecision == CHANGES_REQUESTED → suggestion only, printed for the human
*
* Per-memory independence: every memory on the branch keeps its own state.
* A locally `rejected` memory is never flipped by a PR approval, and a
* changes-requested review never auto-rejects — the human runs `promote`.
*
* Report by default; `--apply` performs the mapped transitions locally
* (file rewrite + index, never a push — inside the D27 local-git line).
* Needs the GitHub CLI (`gh`) authenticated on the user's machine.
*/
import { execFileSync } from "node:child_process";
import * as fs from "node:fs";
import * as path from "node:path";
import { loadConfig } from "./config.ts";
import { projectRoot } from "./paths.ts";
import { parse as parseMemory, type ReviewState } from "./store/markdown.ts";
import { iterInRepoMemoryFiles } from "./store/markdown.ts";
import { promoteMemory, currentAuthor } from "./review.ts";
function fail(msg: string): never {
throw new Error(`[open-memex] ${msg}`);
}
function sh(cmd: string, args: string[], cwd: string): string {
try {
return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
} catch (e) {
const err = e as { stderr?: string; message?: string };
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
}
}
export interface PrInfo {
number: number;
url: string;
state: "open" | "closed"; // GitHub PR state
merged: boolean;
mergedAt: string | null;
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
approvedBy: string[];
changesRequestedBy: string[];
base: string;
head: string;
title: string;
}
interface GhPrJson {
number: number;
url: string;
state: string;
mergedAt: string | null;
isMerged?: boolean;
reviewDecision?: string | null;
baseRefName: string;
headRefName: string;
title: string;
reviews?: Array<{ author: { login: string }; state: string; submittedAt: string }>;
}
/** Read the PR for a branch (default: current branch) via the GitHub CLI. */
export function getPrInfo(branch?: string): PrInfo | null {
const root = projectRoot();
// `gh` must exist and be authenticated on the user's machine.
try {
execFileSync("gh", ["--version"], { stdio: ["ignore", "pipe", "ignore"] });
} catch {
fail("the GitHub CLI (`gh`) is not installed — install it and run `gh auth login` first.");
}
const ref = branch ?? sh("git", ["branch", "--show-current"], root);
if (!ref) fail("not on a branch — check out the memory branch first.");
let raw: string;
try {
raw = sh("gh", ["pr", "view", ref, "--json",
"number,url,state,mergedAt,reviewDecision,baseRefName,headRefName,title,reviews"], root);
} catch {
return null; // no PR for this branch yet
}
let j: GhPrJson;
try {
j = JSON.parse(raw) as GhPrJson;
} catch {
fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
}
const approvedBy: string[] = [];
const changesRequestedBy: string[] = [];
// Latest review per author wins.
const latest = new Map<string, { state: string; at: string }>();
for (const r of j.reviews ?? []) {
const login = r.author?.login;
if (!login) continue;
const prev = latest.get(login);
if (!prev || r.submittedAt >= prev.at) latest.set(login, { state: r.state, at: r.submittedAt });
}
for (const [login, r] of latest) {
if (r.state === "APPROVED") approvedBy.push(login);
else if (r.state === "CHANGES_REQUESTED") changesRequestedBy.push(login);
}
return {
number: j.number,
url: j.url,
state: j.state === "OPEN" ? "open" : "closed",
merged: !!j.mergedAt,
mergedAt: j.mergedAt,
reviewDecision: (j.reviewDecision as PrInfo["reviewDecision"]) ?? null,
approvedBy,
changesRequestedBy,
base: j.baseRefName,
head: j.headRefName,
title: j.title,
};
}
export interface MemoryPrMapping {
memoryId: string;
from: ReviewState;
/** Null = no automatic transition; see reason. */
suggestedTo: ReviewState | null;
reason: string;
approvedBy: string | null;
}
export interface PrStatus {
branch: string;
pr: PrInfo | null;
mappings: MemoryPrMapping[];
}
/** Map the branch PR's state onto each in-repo memory's review_state. */
export function getPrStatus(branch?: string): PrStatus {
const root = projectRoot();
const cfg = loadConfig();
const current = branch ?? sh("git", ["branch", "--show-current"], root);
const pr = getPrInfo(current);
const mappings: MemoryPrMapping[] = [];
for (const fp of iterInRepoMemoryFiles(root, cfg.memoryDir)) {
const raw = fs.readFileSync(fp, "utf8");
const parsed = parseMemory(raw);
if (!parsed) continue;
const from = parsed.fm.review_state ?? "draft";
const id = parsed.fm.id;
if (!pr) {
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `no PR for branch ${current} yet`, approvedBy: null });
continue;
}
if (from === "published") {
mappings.push({ memoryId: id, from, suggestedTo: null, reason: "already published", approvedBy: null });
continue;
}
if (from === "rejected") {
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `stays rejected — a PR ${pr.merged ? "merge" : "approval"} never overrides a human rejection`, approvedBy: null });
continue;
}
if (pr.merged) {
if (from === "proposed" || from === "approved") {
mappings.push({ memoryId: id, from, suggestedTo: "published", reason: `PR #${pr.number} merged`, approvedBy: null });
} else {
mappings.push({ memoryId: id, from, suggestedTo: null, reason: `PR #${pr.number} merged, but memory is "${from}"`, approvedBy: null });
}
continue;
}
if (pr.reviewDecision === "APPROVED" && from === "proposed") {
const by = pr.approvedBy[0] ?? null;
mappings.push({
memoryId: id, from, suggestedTo: "approved",
reason: `PR #${pr.number} approved${by ? ` by @${by}` : ""}`, approvedBy: by,
});
continue;
}
if (pr.reviewDecision === "CHANGES_REQUESTED") {
const by = pr.changesRequestedBy[0] ?? null;
mappings.push({
memoryId: id, from, suggestedTo: null,
reason: `PR #${pr.number} has changes requested${by ? ` by @${by}` : ""} — run \`open-memex promote ${id} --reject --note "..."\` yourself if you agree`,
approvedBy: null,
});
continue;
}
mappings.push({
memoryId: id, from, suggestedTo: null,
reason: pr.state === "open" ? `PR #${pr.number} still awaiting review` : `PR #${pr.number} is closed without merge`,
approvedBy: null,
});
}
return { branch: current, pr, mappings };
}
export interface PrApplyResult {
memoryId: string;
from: ReviewState;
to: ReviewState;
by: string;
}
/** Apply the mapped transitions locally (no push — D27). Advances step by
* step through the ladder, so a merged PR moves proposed → approved →
* published with each step in the audit trail. */
export function applyPrStatus(st: PrStatus): PrApplyResult[] {
const out: PrApplyResult[] = [];
const actor = currentAuthor();
const order: ReviewState[] = ["draft", "proposed", "approved", "published"];
for (const m of st.mappings) {
if (!m.suggestedTo || order.indexOf(m.from) >= order.indexOf(m.suggestedTo)) continue;
const by = m.approvedBy ?? actor;
const note = m.suggestedTo === "published" && st.pr
? `PR #${st.pr.number} merged`
: m.reason;
let cur = m.from;
while (order.indexOf(cur) < order.indexOf(m.suggestedTo)) {
const r = promoteMemory(m.memoryId, { by, note });
cur = r.to;
out.push({ memoryId: m.memoryId, from: r.from, to: r.to, by });
}
}
return out;
}
export function formatPrStatus(st: PrStatus): string {
const lines: string[] = [];
lines.push(`branch: ${st.branch}`);
if (!st.pr) {
lines.push(`PR: none yet — push the branch and open one, then re-run.`);
} else {
const p = st.pr;
lines.push(`PR #${p.number}: ${p.title}`);
lines.push(` ${p.url}`);
lines.push(` state: ${p.merged ? `merged${p.mergedAt ? ` at ${p.mergedAt}` : ""}` : p.state}` +
(p.reviewDecision ? `, reviews: ${p.reviewDecision}` : ""));
if (p.approvedBy.length > 0) lines.push(` approved by: ${p.approvedBy.map((l) => "@" + l).join(", ")}`);
if (p.changesRequestedBy.length > 0) lines.push(` changes requested by: ${p.changesRequestedBy.map((l) => "@" + l).join(", ")}`);
}
if (st.mappings.length === 0) {
lines.push(`no in-repo memories on this branch.`);
} else {
lines.push(`memories (${st.mappings.length}):`);
for (const m of st.mappings) {
const arrow = m.suggestedTo ? ` → [${m.suggestedTo}]` : "";
lines.push(` ${m.memoryId.slice(0, 8)} [${m.from}]${arrow} ${m.reason}`);
}
if (st.mappings.some((m) => m.suggestedTo)) {
lines.push(`run with --apply to perform the mapped transitions locally (no push).`);
}
}
return lines.join("\n");
}