| 1 | //! 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 | |
| 18 | use std::path::Path; |
| 19 | |
| 20 | use anyhow::{Context, Result, bail}; |
| 21 | use serde_json::{Value, json}; |
| 22 | |
| 23 | use crate::report::Reporter; |
| 24 | use crate::{WORKDIR, auth_option, env, harness}; |
| 25 | |
| 26 | const PLAN_FILE: &str = "/work/plan.json"; |
| 27 | |
| 28 | const INSTRUCTIONS: &str = "You are planning work for a team of coding agents. The repository is checked out in the current directory. \ |
| 29 | Read enough of it to understand how it is built and tested. Do not modify it. |
| 30 | |
| 31 | Split the outcome below into issues. Each issue will be given to a separate agent that sees only that issue and the repository, \ |
| 32 | and 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. \ |
| 38 | Do 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, \ |
| 41 | make the later one depend on the earlier. Otherwise leave them independent, so that they are worked on at the same time. |
| 42 | |
| 43 | Write 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; \ |
| 60 | it is empty for an issue that can start at once. An issue may only depend on issues before it. Then finish. |
| 61 | |
| 62 | The outcome wanted:"; |
| 63 | |
| 64 | fn 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. |
| 91 | fn 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 | |
| 97 | pub 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)] |
| 137 | mod 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 | } |