zz_

app/cli/commands/repo-submit.ts

zzmq.dev@gmail.com/zz / app / cli / commands / repo-submit.ts @ 5e5720fb 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();
  }
}