open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
216 lines (215 loc) • 8.87 kB
JavaScript
/**
* 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 { loadConfig } from "./config.js";
import { projectRoot } from "./paths.js";
import { parse as parseMemory } from "./store/markdown.js";
import { iterInRepoMemoryFiles } from "./store/markdown.js";
import { promoteMemory, currentAuthor } from "./review.js";
function fail(msg) {
throw new Error(`[open-memex] ${msg}`);
}
function sh(cmd, args, cwd) {
try {
return execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
}
catch (e) {
const err = e;
const detail = (err.stderr ?? err.message ?? "").trim().split("\n")[0];
fail(`${cmd} ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
}
}
/** Read the PR for a branch (default: current branch) via the GitHub CLI. */
export function getPrInfo(branch) {
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;
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;
try {
j = JSON.parse(raw);
}
catch {
fail(`could not parse \`gh pr view\` output for branch ${ref}.`);
}
const approvedBy = [];
const changesRequestedBy = [];
// Latest review per author wins.
const latest = new Map();
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 ?? null,
approvedBy,
changesRequestedBy,
base: j.baseRefName,
head: j.headRefName,
title: j.title,
};
}
/** Map the branch PR's state onto each in-repo memory's review_state. */
export function getPrStatus(branch) {
const root = projectRoot();
const cfg = loadConfig();
const current = branch ?? sh("git", ["branch", "--show-current"], root);
const pr = getPrInfo(current);
const mappings = [];
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 };
}
/** 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) {
const out = [];
const actor = currentAuthor();
const order = ["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) {
const lines = [];
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");
}