app/cli/commands/repo-submit.ts
zzmq.dev@gmail.com/zz / app / cli / commands / repo-submit.ts
@ 153aad69
Download
Ladder Summary
- file The module implements the **`zz repo submit`** CLI command: it determines the files changed in the current working directory (either staged or the diff of the last commit), reads their contents, resolves the user (or a managed agent) to obtain an API token, and then posts a `changeset_review` ask (and a dependent `submit_changeset` ask) to the remote repository via the engine’s remote‑tool API, cleaning up the temporary identity token on exit. **Exported symbols** - `interface RepoSubmitOptions` – optional `message` and `as` fields for the command. - `async function repoSubmit(slug: string, laneId: string, askId: string, opts: RepoSubmitOptions): Promise<void>` – performs the whole submit workflow.
// `zz repo submit <owner/repo> <lane-id> <ask-id>`: the human-contributor
// counterpart to agent/scriptedWork.ts's runContributorWorkAsk -- posts
// a changeset_review + a dependent submit_changeset ask, then completes
// the work ask, exactly the same protocol sequence an LLM-driven agent
// follows via commit_changeset/ask_post/ask_answer, just reading real
// file content off disk (what you `git add`ed) instead of generating
// it. No local clone convention assumed: run this from inside whatever
// directory `zz repo clone` (or a plain `git clone` with your own
// token) put the repo in -- it operates on the current working
// directory's git index, same as any other git-adjacent CLI command.
//
// This does NOT commit anything itself -- posting a changeset_review
// only proposes one. A Maintainer still has to `zz review` and approve
// it before a coordinator's `zz work` run actually merges it (calls
// commit_changeset for real). That two-step is the point: a raw push
// bypasses per-changeset review entirely, which is exactly what this
// path exists to go through instead.
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolveIdentity, releaseIdentity } from "../lib/identity.js";
import { setActiveToken } from "../lib/api.js";
import { callRemoteTool } from "../lib/remoteTools.js";
import type { AskEntry, Lane } from "../../engine/types.js";
function git(args: string[]): string {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
export interface RepoSubmitOptions {
message?: string;
/** --as <name>: submit as a managed agent instead of yourself -- see WorkOptions.as (work.ts) / lib/identity.ts for the full resolution order. */
as?: string;
}
export async function repoSubmit(slug: string, laneId: string, askId: string, opts: RepoSubmitOptions): Promise<void> {
const identity = await resolveIdentity(opts.as);
setActiveToken(identity.token);
// --as mints a fresh token for this one-shot command -- clean it up
// however it ends, including Ctrl+C mid-flight (no TUI/raw-mode
// here, so a real SIGINT reaches this handler).
let cleanedUp = false;
const cleanup = async (): Promise<void> => {
if (cleanedUp) return;
cleanedUp = true;
await releaseIdentity(identity);
};
process.on("SIGINT", () => void cleanup().then(() => process.exit(0)));
process.on("SIGTERM", () => void cleanup().then(() => process.exit(0)));
try {
// Staged-but-uncommitted is the primary case, but committing first
// (`git add && git commit`, then this) is the more natural thing
// for a human to actually do -- confirmed live: the demo run
// sheet's own Stage 1 steps do exactly that, and it silently found
// "nothing staged" before this fallback existed. Falls back to the
// last commit's own diff against its parent, using the parent (not
// HEAD) as baseCommit, so either workflow produces the same
// changeset_review/submit_changeset payload.
let changed = git(["diff", "--cached", "--name-only"])
.split("\n")
.filter(Boolean);
let baseCommit: string;
if (changed.length > 0) {
baseCommit = git(["rev-parse", "HEAD"]);
} else {
let parent: string;
try {
parent = git(["rev-parse", "HEAD~1"]);
} catch {
console.error("Nothing staged, and HEAD has no parent commit to diff against. `git add` the files you changed (or commit them), then run this again.");
process.exitCode = 1;
return;
}
changed = git(["diff", "--name-only", parent, "HEAD"]).split("\n").filter(Boolean);
if (changed.length === 0) {
console.error("Nothing staged, and the last commit didn't change anything. `git add` the files you changed (or commit them), then run this again.");
process.exitCode = 1;
return;
}
baseCommit = parent;
}
const files = changed.map((path) => ({ path, content: readFileSync(path, "utf8") }));
const message = opts.message ?? `Changes to ${changed.join(", ")}`;
const lane = (await callRemoteTool(slug, "lane_get", { lane_id: laneId })) as Lane | null;
if (!lane) throw new Error(`no such lane "${laneId}" in ${slug}`);
// The ask being submitted against usually lives in laneId's own
// origin, not laneId itself -- `zz ask claim` opens a fresh Lane
// ("my workstream") for the work, but claiming doesn't move the
// original ask into it. Check laneId directly first (the plain
// case: no separate work Lane was ever opened), then its origin.
// lane.origin.repo is the artifacts_name the server injected into
// the lane_propose call that created this lane (repoMeta.ts's own
// internal repo key), not the "owner/repo" slug -- compare against
// lane.repo (the same artifacts_name, on this same Lane record),
// not slug, or this never matches for the single-repo (no shard)
// case repoSubmit is actually used for.
let askLaneId = laneId;
let theAsk = lane.history.find((e): e is AskEntry => e.type === "ASK" && e.id === askId);
if (!theAsk && lane.origin && lane.origin.repo === lane.repo) {
const originLane = (await callRemoteTool(slug, "lane_get", { lane_id: lane.origin.lane })) as Lane | null;
theAsk = originLane?.history.find((e): e is AskEntry => e.type === "ASK" && e.id === askId);
if (theAsk) askLaneId = lane.origin.lane;
}
if (!theAsk) throw new Error(`no such ask "${askId}" in ${slug}/${laneId}${lane.origin ? ` or its origin ${lane.origin.lane}` : ""}`);
const taskDescription = (theAsk.payload as { description?: string } | undefined)?.description ?? JSON.stringify(theAsk.payload);
const review = (await callRemoteTool(slug, "ask_post", {
lane_id: laneId,
kind: "changeset_review",
from: identity.username,
payload: { taskDescription, files, message, summary: message },
depends_on: [],
})) as AskEntry;
// Not askId here when it lives in a different lane (askLaneId !==
// laneId): depends_on resolution only ever checks within the one
// lane an ask's own history lives in (see agent/tools.ts's
// ask_claim, which checks `lane!.history`, never a cross-lane
// lookup) -- a cross-lane id here could never resolve. Harmless to
// drop: askId gets marked complete directly below regardless of
// this dependency list.
await callRemoteTool(slug, "ask_post", {
lane_id: laneId,
kind: "submit_changeset",
from: identity.username,
payload: { files, message, summary: message, baseCommit },
depends_on: askLaneId === laneId ? [askId, review.id] : [review.id],
});
await callRemoteTool(slug, "ask_answer", {
lane_id: askLaneId,
ask_id: askId,
actor: identity.username,
action: "complete",
payload: { summary: message },
});
console.log(`Submitted ${changed.length} file(s) for review (changeset_review ${review.id}) and marked ${askId} complete.`);
console.log(`Next: a Maintainer needs to \`zz review\` and approve it; once granted, a coordinator's \`zz work\` run merges it for real.`);
} finally {
await cleanup();
}
}