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
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ guardrail violation) it produces no output and never partially applies changes.
| [`release-train`](loops/release-train/README.md) | Rolling Release PR (version + changelog) from conventional commits | deterministic | PR |
| [`license-sbom-drift`](loops/license-sbom-drift/README.md) | Flag dependency licenses outside the allowlist | deterministic | PR |

Plus a **long-horizon** loop — the
[Experiment Lifecycle Orchestrator](loops/experiment/README.md) — built on the
`longrun` primitives (durable state, approval gates, resumable plans).
Plus two **long-horizon** loops on the `longrun` primitives (durable state,
approval gates, resumable advancement): the
[Experiment Lifecycle Orchestrator](loops/experiment/README.md) and the
[Codemod Campaign](loops/codemod-campaign/README.md) (throttled cross-PR
migration with a ledger + pilot gate).

🌐 **[Project site](https://dcca.github.io/loopy/)** · See the
[roadmap](openspec/roadmap.md) for the backlog and the
Expand Down
42 changes: 42 additions & 0 deletions loops/codemod-campaign/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# codemod-campaign loop

Drives a deterministic **codemod across a codebase in throttled batches of PRs**,
tracked to completion against a durable ledger. The flagship complex loop — it
uses all of loopy's long-horizon primitives: **`StateStore`** (the campaign
ledger), a human **`Gate`** (pilot-batch approval), and resumable advancement.

It is **not** a single-shot `Loop`; it advances a campaign via
`advanceCampaign(...)`, called repeatedly (e.g. on a schedule). Each call does at
most one batch, reconciles against real PR state first, and is idempotent.

## How it advances

| On each `advanceCampaign` | What happens |
|---------------------------|--------------|
| **reconcile** | Read each open batch PR's state; merged → files become `migrated`, closed → files return to the pool. External PR state is the source of truth. |
| **complete?** | No remaining targets and no open batches → `completed`. |
| **throttle?** | Open PRs ≥ `maxOpenPrs` → `waiting` (don't pile on). |
| **select** | Take the next `batchSize` remaining files. |
| **apply + test** | Run the codemod on the batch; run the test/build boundary. Red → `failed`, no PR. |
| **pilot gate** | The first batch is **blocked** on human approval (review the codemod's shape once). |
| **open** | Open a batch PR; record it in the ledger with its files. |

## Boundaries (injected, testable)

- `services.codemod(files)` — the pure transform → `FileChange[]`
- `services.targets.targets()` — files still needing the transform
- `services.runner()` — run tests/build after a batch
- `services.prs` — `open(...)` a batch PR and read `state(prNumber)`
- a `StateStore` carries the ledger across runs

## Guardrails / anti-patterns

- **Campaign runaway** → `maxOpenPrs` cap + one batch per run.
- **Stale state** → reconcile against real PR state every run; closed PRs return files to the pool.
- **Partial-rollout corruption** → only `migrated` on confirmed merge; failed batches open no PR.
- **Gate bypass** → no PRs open until the pilot batch is approved.

## Status

Programmatic for now (the `CampaignPrs` boundary is injected). A GitHub-backed
`CampaignPrs` adapter and a `loopy campaign` CLI verb are planned follow-ups.
126 changes: 126 additions & 0 deletions loops/codemod-campaign/hooks/campaign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import type { FileChange } from "../../../src/core/index.js";

/** Pure transform over the given files; returns the file changes to apply. */
export type Codemod = (files: string[]) => Promise<FileChange[]>;

/** Supplies the files that still match the campaign's "needs transform" predicate. */
export interface TargetSource {
targets(): Promise<string[]>;
}

/** Runs the project's tests/build after a batch is applied. */
export type TestRunner = () => Promise<{ ok: boolean; diagnostics?: string }>;

export type PrState = "open" | "merged" | "closed";

/** The PR operations a campaign needs (create batch PRs; read their state). */
export interface CampaignPrs {
state(prNumber: number): Promise<PrState>;
open(input: {
branch: string;
title: string;
body: string;
changes: FileChange[];
}): Promise<{ number: number }>;
}

export interface OpenBatch {
prNumber: number;
branch: string;
files: string[];
}

/** Durable campaign ledger, persisted in the StateStore across runs. */
export interface CampaignLedger {
migrated: string[];
open: OpenBatch[];
mergedBatches: number;
failedBatches: number;
batchesOpened: number;
pilotApproved: boolean;
}

export interface CampaignSpec {
/** files per batch PR */
batchSize: number;
/** maximum concurrent open batch PRs (throttle) */
maxOpenPrs: number;
/** PR title prefix */
title: string;
}

export function emptyLedger(): CampaignLedger {
return {
migrated: [],
open: [],
mergedBatches: 0,
failedBatches: 0,
batchesOpened: 0,
pilotApproved: false,
};
}

/** Files still needing work: not migrated and not already in an open batch. */
export function remainingTargets(all: string[], ledger: CampaignLedger): string[] {
const done = new Set(ledger.migrated);
const inFlight = new Set(ledger.open.flatMap((b) => b.files));
return all.filter((f) => !done.has(f) && !inFlight.has(f)).sort();
}

export function selectBatch(remaining: string[], batchSize: number): string[] {
return remaining.slice(0, Math.max(1, batchSize));
}

/**
* Reconcile the ledger against real PR state: merged batches' files become
* migrated; closed batches are dropped (their files return to the pool); open
* batches stay. External PR state is the source of truth.
*/
export async function reconcile(
ledger: CampaignLedger,
prs: CampaignPrs,
): Promise<CampaignLedger> {
const stillOpen: OpenBatch[] = [];
const migrated = new Set(ledger.migrated);
let mergedBatches = ledger.mergedBatches;

for (const batch of ledger.open) {
const state = await prs.state(batch.prNumber);
if (state === "merged") {
for (const f of batch.files) migrated.add(f);
mergedBatches++;
} else if (state === "open") {
stillOpen.push(batch);
}
// "closed" → drop; files return to the pool to be retried later.
}

return { ...ledger, migrated: [...migrated].sort(), open: stillOpen, mergedBatches };
}

/** Total targets across the campaign's lifetime (migrated + in-flight + remaining). */
export function campaignTotal(all: string[], ledger: CampaignLedger): number {
const inFlight = ledger.open.reduce((n, b) => n + b.files.length, 0);
return ledger.migrated.length + inFlight + remainingTargets(all, ledger).length;
}

export function renderBurndown(
all: string[],
ledger: CampaignLedger,
dateIso: string,
): string {
const total = campaignTotal(all, ledger);
const remaining = remainingTargets(all, ledger).length;
const inFlight = ledger.open.reduce((n, b) => n + b.files.length, 0);
const pct = total > 0 ? Math.round((ledger.migrated.length / total) * 100) : 100;
return [
`## Codemod campaign burndown — ${dateIso}`,
"",
`- Progress: **${ledger.migrated.length}/${total}** files migrated (${pct}%)`,
`- In flight: ${inFlight} file(s) across ${ledger.open.length} open PR(s)`,
`- Remaining: ${remaining} file(s)`,
`- Batches: ${ledger.batchesOpened} opened · ${ledger.mergedBatches} merged · ${ledger.failedBatches} failed`,
"",
"_Generated by loopy codemod-campaign. Throttled + ledger-tracked; pilot batch is human-gated._",
].join("\n");
}
160 changes: 160 additions & 0 deletions loops/codemod-campaign/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { createGate, type Gate, type StateStore } from "../../src/core/index.js";
import {
campaignTotal,
emptyLedger,
reconcile,
remainingTargets,
renderBurndown,
selectBatch,
type CampaignLedger,
type CampaignPrs,
type CampaignSpec,
type Codemod,
type TargetSource,
type TestRunner,
} from "./hooks/campaign.js";

export type {
Codemod,
TargetSource,
TestRunner,
CampaignPrs,
CampaignLedger,
CampaignSpec,
OpenBatch,
PrState,
} from "./hooks/campaign.js";
export {
emptyLedger,
remainingTargets,
selectBatch,
reconcile,
renderBurndown,
campaignTotal,
} from "./hooks/campaign.js";

export interface CampaignServices {
codemod: Codemod;
targets: TargetSource;
runner: TestRunner;
prs: CampaignPrs;
}

export type CampaignStatus =
| "completed"
| "waiting"
| "blocked"
| "failed"
| "batch-opened";

export interface CampaignResult {
campaignId: string;
status: CampaignStatus;
reason?: string;
gateId?: string;
pr?: { number: number };
ledger: CampaignLedger;
burndown: string;
}

/**
* Advance a codemod campaign by at most one batch. Resumable and idempotent:
* the ledger (in the StateStore) is reconciled against real PR state every run,
* batches are throttled by `maxOpenPrs`, and the pilot batch is human-gated.
*
* Call repeatedly (e.g. on a schedule). Returns:
* - `completed` — nothing left and no open batches
* - `waiting` — throttled (open-PR cap) or batches in flight, nothing to do now
* - `blocked` — awaiting the pilot-batch approval gate
* - `failed` — the batch failed tests/build (no PR opened)
* - `batch-opened` — a new batch PR was opened
*/
export async function advanceCampaign(
campaignId: string,
spec: CampaignSpec,
services: CampaignServices,
store: StateStore,
options?: { now?: () => Date; gate?: Gate },
): Promise<CampaignResult> {
const now = options?.now ?? (() => new Date());
const gate = options?.gate ?? createGate(store, options);
const key = `campaign:${campaignId}`;

let ledger = (await store.load<CampaignLedger>(key)) ?? emptyLedger();
ledger = await reconcile(ledger, services.prs);

const all = await services.targets.targets();
const dateIso = now().toISOString().slice(0, 10);
const done = (status: CampaignStatus, extra: Partial<CampaignResult> = {}) => ({
campaignId,
status,
ledger,
burndown: renderBurndown(all, ledger, dateIso),
...extra,
});

const remaining = remainingTargets(all, ledger);

// Completion: nothing left and nothing in flight.
if (remaining.length === 0 && ledger.open.length === 0) {
await store.save(key, ledger);
return done("completed");
}
// Throttle: at the open-PR cap.
if (ledger.open.length >= spec.maxOpenPrs) {
await store.save(key, ledger);
return done("waiting", { reason: "open-PR cap reached" });
}
// Nothing selectable now, but batches are still in flight.
if (remaining.length === 0) {
await store.save(key, ledger);
return done("waiting", { reason: "batches in flight" });
}

const batch = selectBatch(remaining, spec.batchSize);
const changes = await services.codemod(batch);

const test = await services.runner();
if (!test.ok) {
ledger = { ...ledger, failedBatches: ledger.failedBatches + 1 };
await store.save(key, ledger);
return done("failed", { reason: test.diagnostics ?? "batch failed tests/build" });
}

// Pilot gate: review the codemod's shape once, before opening any PRs.
if (!ledger.pilotApproved) {
const gateId = `${campaignId}:pilot`;
const req = await gate.require(
gateId,
`Approve the pilot batch of campaign "${campaignId}" (${batch.length} file(s); ` +
`${campaignTotal(all, ledger)} total).`,
);
if (req.status === "pending") {
await store.save(key, ledger);
return done("blocked", { gateId });
}
if (req.status === "rejected") {
await store.save(key, ledger);
return done("blocked", { gateId, reason: "pilot batch rejected" });
}
ledger = { ...ledger, pilotApproved: true };
}

const n = ledger.batchesOpened + 1;
const branch = `loopy/campaign-${campaignId}-${n}`;
const burndown = renderBurndown(all, ledger, dateIso);
const pr = await services.prs.open({
branch,
title: `${spec.title} (batch ${n})`,
body: burndown,
changes,
});

ledger = {
...ledger,
batchesOpened: n,
open: [...ledger.open, { prNumber: pr.number, branch, files: batch }],
};
await store.save(key, ledger);
return done("batch-opened", { pr });
}
31 changes: 31 additions & 0 deletions loops/codemod-campaign/playbook.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Codemod Campaign Playbook

This loop drives a **deterministic** codemod across a codebase as a staged,
throttled, ledger-tracked campaign of PRs. The transform itself is the injected
`codemod` boundary (jscodeshift/ts-morph/OpenRewrite-style); the loop handles
batching, throttling, reconciliation, the pilot gate, and burndown.

## The campaign contract

1. **Reconcile** the ledger against real PR state (merged/closed/open) — external
state is the source of truth.
2. **Throttle**: never exceed `maxOpenPrs` concurrent batch PRs.
3. **Batch**: take the next `batchSize` files that still match the predicate.
4. **Apply + verify**: run the codemod, then the test/build boundary; a red batch
opens no PR.
5. **Pilot gate**: a human approves the first batch (the codemod's shape) before
any PRs are opened.
6. **Open** a batch PR and record it in the ledger; repeat on the next run.

## Designing the codemod (the injected boundary)

- Make it **idempotent** — re-running on the same file must be a no-op.
- Keep batches **small and reviewable**; one concern per campaign.
- The transform must leave the tree **green** (the runner gates this).

## Guardrails (enforced, not optional)

- Open-PR cap + one batch per run (no runaway).
- Reconcile every run; closed PRs return their files to the pool.
- Files become "migrated" only when their batch PR is **merged**.
- No PRs until the pilot batch is approved at the Gate.
Loading
Loading