Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
147 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Money in billing's messages reads to the cent, and the deploy reads migrations again after a failure1//! Turns an outcome someone wrote into a plan: the issues that would get
2//! there, what done means for each, which files each will touch, and which
3//! must land before which.
4//!
5//! The agent reads the repository to do it, so the plan is about the code
6//! as it is and not a guess. It writes the plan to a file as JSON and this
7//! program posts it to g1t, where a person reads and edits it before
8//! anything is opened. The agent never holds the credential that reports
9//! the plan, and changes nothing.
10//!
11//! Configuration comes from the environment:
12//!
13//! - `G1T_API`, `PLAN_ID`, `PLAN_TOKEN`: where and how to report.
14//! - `GIT_REMOTE`: the repository to plan for.
15//! - `G1T_USER`, `G1T_TOKEN`: to read it, if it is private.
16//! - `PROMPT`: the outcome wanted.
17
18use std::path::Path;
19
20use anyhow::{Context, Result, bail};
21use serde_json::{Value, json};
22
23use crate::report::Reporter;
24use crate::{WORKDIR, auth_option, env, harness};
25
26pub(crate) const PLAN_FILE: &str = "/work/plan.json";
27
28const INSTRUCTIONS: &str = "You are planning work for a team of coding agents. The repository is checked out in the current directory. \
29Read enough of it to understand how it is built and tested. Do not modify it.
30
31Split the outcome below into issues. Each issue will be given to a separate agent that sees only that issue and the repository, \
32and each will be merged on its own. So:
33
34- Make each issue one coherent change that can be merged by itself and leaves the project working.
35- Write the body for someone with no other context: what to change, where, and why. Name the files and functions involved.
36- Prefer several small issues to one large one, but do not split a change that only makes sense whole. At most 12 issues.
37- Say what done means for each, in plain words a reviewer can confirm: a few short points, such as what a command prints or what a page shows. \
38Do not list the project's tests: its workflows run them on every pull request.
39- List the files each issue will most likely change.
40- Two agents working at once must not edit the same code. If two issues would change the same file, or one needs what another adds, \
41make the later one depend on the earlier. Otherwise leave them independent, so that they are worked on at the same time.
42
43Write the plan to /work/plan.json as JSON with exactly this shape:
44
45{
46 \"summary\": \"Two or three sentences: how you split the outcome and why in this order.\",
47 \"issues\": [
48 {
49 \"title\": \"One line, as an instruction\",
50 \"body\": \"Markdown.\",
51 \"labels\": [\"feature\"],
52 \"done\": [\"`greet ana` prints Hello, ana!\"],
53 \"files\": [\"src/lib.rs\"],
54 \"depends_on\": [1]
55 }
56 ]
57}
58
59`labels` are from: bug, feature, docs, chore. `depends_on` holds the positions, counting from 1, of earlier issues in this list that must be merged first; \
60it is empty for an issue that can start at once. An issue may only depend on issues before it. Then finish.
61
62The outcome wanted:";
63
64fn plan() -> Result<Value> {
65 let remote = env("GIT_REMOTE")?;
66 let auth = auth_option(&env("G1T_USER")?, &env("G1T_TOKEN")?);
67 let workdir = Path::new(WORKDIR);
68
69 std::fs::create_dir_all("/work")?;
70 crate::clone::clone(Path::new("/work"), &auth, &[], &remote, WORKDIR).context("could not clone the repository")?;
71
72 let prompt = format!("{INSTRUCTIONS}\n\n{}", env("PROMPT")?);
73 // A plan has no session; what matters is the plan.
74 let mut reporter = Reporter::silent();
75 let answer = harness::run_claude(workdir, &prompt, &mut reporter)?;
76
77 let written = std::fs::read_to_string(PLAN_FILE).unwrap_or_default();
78 let plan: Value = serde_json::from_str(&written)
79 .ok()
80 .filter(|plan: &Value| plan["issues"].is_array())
81 // The agent may have answered with the plan instead of writing it.
82 .or_else(|| embedded_json(&answer).filter(|plan| plan["issues"].is_array()))
83 .context("the agent did not produce a plan")?;
84 if plan["issues"].as_array().is_some_and(Vec::is_empty) {
85 bail!("the agent proposed no issues");
86 }
87 Ok(plan)
88}
89
90/// The JSON object in a piece of text, from its first brace to its last.
91fn embedded_json(text: &str) -> Option<Value> {
92 let start = text.find('{')?;
93 let end = text.rfind('}')?;
94 serde_json::from_str(text.get(start..=end)?).ok()
95}
96
97pub fn main() -> i32 {
98 let (Ok(api), Ok(id), Ok(token)) = (env("G1T_API"), env("PLAN_ID"), env("PLAN_TOKEN")) else {
99 eprintln!("g1t-runner: G1T_API, PLAN_ID and PLAN_TOKEN must be set");
100 return 2;
101 };
102 let secrets: Vec<String> = [
103 "G1T_TOKEN",
104 "PLAN_TOKEN",
105 "ANTHROPIC_API_KEY",
106 "BILLING_TOKEN",
107 ]
108 .iter()
109 .filter_map(|name| std::env::var(name).ok())
110 .filter(|secret| !secret.is_empty())
111 .collect();
112 let redact = |text: String| {
113 secrets
114 .iter()
115 .fold(text, |text, secret| text.replace(secret, "[redacted]"))
116 };
117
118 let outcome = plan();
119 let report = match &outcome {
120 Ok(plan) => plan.clone(),
121 Err(error) => json!({ "error": format!("{error:#}") }),
122 };
123 // Whatever the agent wrote passes through here, so nothing it could
124 // have read from its environment leaves in a plan. The plan's own token
125 // is added afterwards: it is what authorises the report.
126 let mut report: Value =
127 serde_json::from_str(&redact(report.to_string())).unwrap_or_else(|_| json!({}));
128 report["token"] = token.into();
129 if let Err(error) = ureq::post(&format!("{api}/plans/{id}")).send_json(report) {
130 eprintln!("g1t-runner: could not report the plan: {error:#}");
131 return 1;
132 }
133 i32::from(outcome.is_err())
134}
135
136#[cfg(test)]
137mod tests {
138 use super::embedded_json;
139
140 #[test]
141 fn a_plan_is_found_inside_an_answer() {
142 let answer = "Here is the plan:\n```json\n{\"summary\": \"x\", \"issues\": []}\n```\nDone.";
143 let plan = embedded_json(answer).unwrap();
144 assert_eq!(plan["summary"], "x");
145 assert!(embedded_json("no plan here").is_none());
146 }
147}

This file's history is long; its oldest lines are credited to the oldest commit read.