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
41 changes: 41 additions & 0 deletions src/adopt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { isAdoptable, extractPrUrl, LIVE_IMPORT_LEXICONS } from "./adopt.ts";

describe("isAdoptable", () => {
it("is true for a foreign node on a live-import lexicon", () => {
for (const lexicon of LIVE_IMPORT_LEXICONS) {
expect(isAdoptable({ lexicon, attrs: { _status: "foreign" } })).toBe(true);
}
});

it("is false for a managed or pending node (nothing to adopt)", () => {
expect(isAdoptable({ lexicon: "aws", attrs: { _status: "managed" } })).toBe(false);
expect(isAdoptable({ lexicon: "aws", attrs: { _status: "pending" } })).toBe(false);
expect(isAdoptable({ lexicon: "aws" })).toBe(false); // no overlay status
});

it("is false for a foreign node with no live-import path", () => {
expect(isAdoptable({ lexicon: "gitlab", attrs: { _status: "foreign" } })).toBe(false);
expect(isAdoptable({ lexicon: "helm", attrs: { _status: "foreign" } })).toBe(false);
expect(isAdoptable({ attrs: { _status: "foreign" } })).toBe(false); // no lexicon
});
});

describe("extractPrUrl", () => {
it("pulls a GitHub PR URL from an outcome line", () => {
expect(extractPrUrl(" [outcome] PR=https://github.com/acme/infra/pull/42")).toBe(
"https://github.com/acme/infra/pull/42",
);
});

it("pulls a GitLab merge-request URL", () => {
expect(extractPrUrl("opened https://gitlab.com/acme/infra/-/merge_requests/7 for review")).toBe(
"https://gitlab.com/acme/infra/-/merge_requests/7",
);
});

it("returns undefined for a line with no PR URL", () => {
expect(extractPrUrl("[phase] Reconcile")).toBeUndefined();
expect(extractPrUrl("https://github.com/acme/infra/tree/main")).toBeUndefined();
});
});
30 changes: 30 additions & 0 deletions src/adopt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* Adopt eligibility + PR-link extraction (#8). Adopt is the cloud→code gesture:
* a *foreign* node (provisioned, not declared) is pulled back into typed source
* by triggering the project's ReconcileOp, which opens a PR. behold never writes
* source — a human merges the PR.
*
* Two rules live here so the server is the source of truth (the SPA gates purely
* on data it returns): which substrates can be adopted, and how to pull the PR
* URL out of the Op's now-line output.
*/

/** Lexicons chant can live-import (regenerate typed source from the cloud). Adopt
* is offered only for these — other substrates have no cloud→code path. */
export const LIVE_IMPORT_LEXICONS = ["aws", "azure", "gcp", "k8s"] as const;

const LIVE = new Set<string>(LIVE_IMPORT_LEXICONS);

/** A node is adoptable when overlay marks it foreign (provisioned, undeclared)
* and its lexicon has a live-import path. Pure — unit-tested. */
export function isAdoptable(node: { lexicon?: string; attrs?: Record<string, unknown> }): boolean {
return node.attrs?._status === "foreign" && !!node.lexicon && LIVE.has(node.lexicon);
}

/** Pull a GitHub/GitLab PR (or MR) URL out of an Op output line, if present. The
* ReconcileOp surfaces the opened PR as an outcome (`[outcome] PR=…`, chant #841);
* behold turns it into a link. Pure — unit-tested. */
export function extractPrUrl(line: string): string | undefined {
const m = line.match(/https?:\/\/[^\s"'<>]+?\/(?:pull|pulls|merge_requests)\/\d+/);
return m ? m[0] : undefined;
}
19 changes: 17 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { dirname, join, relative } from "node:path";
import { graphIr, runChantStream, runChantRaw, type GraphOptions } from "./chant.ts";
import { renderGraph } from "./render.ts";
import { discoverOps } from "./ops.ts";
import { LIVE_IMPORT_LEXICONS, extractPrUrl } from "./adopt.ts";
import { Broadcaster, watchSource } from "./events.ts";
import { startDriftPoll } from "./poll.ts";
import { FrameBuffer } from "./frames.ts";
Expand Down Expand Up @@ -76,7 +77,15 @@ export function createApp(
// trigger. behold NEVER applies — it runs `chant run <op>` on the executor and
// streams the phases as the now-line. It holds no apply creds.
let running: { name: string; op: ReturnType<typeof runChantStream> } | null = null;
app.get("/api/ops", (c) => c.json({ ops: discoverOps(cfg.projectDir), running: running?.name ?? null }));
app.get("/api/ops", (c) =>
c.json({
ops: discoverOps(cfg.projectDir),
running: running?.name ?? null,
// The substrates Adopt is offered on — the SPA gates the per-node button on
// this so the "which lexicons live-import" truth stays server-side.
adoptLexicons: LIVE_IMPORT_LEXICONS,
}),
);

app.post("/api/ops/:name/run", (c) => {
const name = c.req.param("name");
Expand All @@ -85,7 +94,13 @@ export function createApp(
}
if (running) return c.json({ error: `an Op is already running (${running.name})` }, 409);
broadcaster.emit("op", `▶ chant run ${name}`);
const op = runChantStream(["run", name], cfg.projectDir, (line) => broadcaster.emit("op", line));
const op = runChantStream(["run", name], cfg.projectDir, (line) => {
broadcaster.emit("op", line);
// A ReconcileOp opens a PR and surfaces the URL as an outcome (chant #841);
// lift it to a `pr` event so the SPA can link the opened PR.
const pr = extractPrUrl(line);
if (pr) broadcaster.emit("pr", pr);
});
running = { name, op };
void op.done.then((code) => {
broadcaster.emit("op", `■ ${name} exited ${code}`);
Expand Down
51 changes: 48 additions & 3 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,19 @@ function inspect(node) {
add(k, typeof v === "object" ? JSON.stringify(v) : String(v));
}
panel.appendChild(dl);

// A foreign node on a live-import substrate can be pulled into typed source:
// Adopt triggers the ReconcileOp (cloud → code), which opens a reviewable PR.
// behold never writes source — a human merges. Managed/pending nodes and
// substrates with no live-import path show nothing.
if (adoptable(node)) {
const b = button("Adopt", "", () => runOp(adopt.reconcile.name));
b.title = `Reconcile ${node.id} into source via ${adopt.reconcile.name} (opens a PR)`;
const wrap = document.createElement("p");
wrap.style.marginTop = "12px";
wrap.appendChild(b);
panel.appendChild(wrap);
}
}

function wire(ir) {
Expand Down Expand Up @@ -98,15 +111,47 @@ function signal(name, gate) {
.then((r) => r.json())
.then((j) => j.error && nowline("✗ " + j.error));
}
// Adopt is a per-node gesture (a *foreign* node → ReconcileOp → PR), so it lives
// in the inspect panel, not the global bar. Stash the reconcile op + the
// live-import lexicons the server allows so inspect() can gate the button.
let adopt = { reconcile: null, lexicons: [] };
function adoptable(node) {
return (
adopt.reconcile &&
node.attrs &&
node.attrs._status === "foreign" &&
adopt.lexicons.includes(node.lexicon)
);
}

async function initActions() {
const bar = document.getElementById("actions");
const { ops } = await fetch("/api/ops").then((r) => r.json()).catch(() => ({ ops: [] }));
const { ops, adoptLexicons } = await fetch("/api/ops")
.then((r) => r.json())
.catch(() => ({ ops: [], adoptLexicons: [] }));
const apply = ops.find((o) => o.kind === "apply");
const reconcile = ops.find((o) => o.kind === "reconcile");
adopt = { reconcile: ops.find((o) => o.kind === "reconcile") ?? null, lexicons: adoptLexicons ?? [] };
if (apply) {
bar.appendChild(button("Sync", "", () => runOp(apply.name)));
if (apply.gate) bar.appendChild(button("Approve", "approve", () => signal(apply.name, apply.gate)));
}
if (reconcile) bar.appendChild(button("Adopt", "", () => runOp(reconcile.name)));
}
initActions();

// The opened PR (chant #841 surfaces it as a ReconcileOp outcome). Link it in the
// now-line and pin it in the header so the review target is one click away.
events.addEventListener("pr", (e) => {
const url = e.data;
nowline("→ opened PR: " + url);
let slot = document.getElementById("pr-link");
if (!slot) {
slot = document.createElement("a");
slot.id = "pr-link";
slot.target = "_blank";
slot.rel = "noopener";
slot.style.cssText = "color:var(--managed);text-decoration:none;font-size:12px;align-self:center";
document.getElementById("actions").after(slot);
}
slot.href = url;
slot.textContent = "PR opened →";
});
5 changes: 3 additions & 2 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@
#graph svg { max-width: none; }
.sel rect, .sel path, .sel > rect { stroke: var(--pending) !important; stroke-width: 2.5 !important; }
.err { padding: 24px; color: var(--foreign); }
#actions button { background: var(--panel); color: var(--fg); border: 1px solid var(--line);
#actions button, #inspect button { background: var(--panel); color: var(--fg); border: 1px solid var(--line);
border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer; }
#actions button:hover { border-color: var(--pending); }
#actions button:hover, #inspect button:hover { border-color: var(--pending); }
#actions button.approve { border-color: var(--managed); color: var(--managed); }
#inspect button { border-color: var(--foreign); color: var(--foreign); }
#nowline { grid-column: 1 / 3; margin: 0; max-height: 160px; overflow: auto; display: none;
background: #010409; border-top: 1px solid var(--line); padding: 8px 12px; font: 11px/1.5 ui-monospace, monospace;
color: var(--fg); white-space: pre-wrap; }
Expand Down
Loading