From 09b0dd65eb49581a2b52a0738f634c3b659666f2 Mon Sep 17 00:00:00 2001 From: Jacob Stephens Date: Tue, 29 Sep 2026 23:35:16 +0000 Subject: [PATCH 1/2] Run a linear Spec's Tickets into a Spec branch, then open a ready Spec PR An issue with sub-issues is now a Spec: thirdshift picks and pushes its Spec branch, runs each ready Ticket in dependency order as a child thirdshift, a Merge run into the Spec branch told so by a hidden --spec-branch argument, relays its stderr with a #: prefix, and opens the Spec PR into the Base branch once every Ticket has landed. The fake gh serves the Tickets query and the fake claude picks scripts by the issue a session is for. Co-Authored-By: Claude Opus 5.5 --- src/args.rs | 24 +++- src/github.rs | 121 ++++++++++++++++- src/issue.rs | 10 ++ src/main.rs | 38 +++++- src/notification.rs | 12 +- src/run.rs | 60 ++++++--- src/spec_run.rs | 133 +++++++++++++++++++ tests/continuation.rs | 10 +- tests/fakes/claude | 38 +++++- tests/fakes/gh | 45 ++++++- tests/spec_run.rs | 302 ++++++++++++++++++++++++++++++++++++++++++ tests/support/mod.rs | 53 ++++++++ 12 files changed, 802 insertions(+), 44 deletions(-) create mode 100644 src/spec_run.rs create mode 100644 tests/spec_run.rs diff --git a/src/args.rs b/src/args.rs index cc873d8d3..c04f331ee 100644 --- a/src/args.rs +++ b/src/args.rs @@ -2,7 +2,7 @@ use std::mem::discriminant; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use crate::issue::IssueUrl; use crate::notification::NotificationAsk; @@ -19,6 +19,12 @@ pub enum Command { Run(RunArgs), } +/// The hidden argument a Spec run starts each Ticket's Run with, followed by +/// the Spec branch: it makes the Run a Merge run into the Spec branch that +/// sends no Run notification and leaves the Launch directory alone. Not in +/// help. +pub const SPEC_BRANCH: &str = "--spec-branch"; + /// A Run's arguments. pub struct RunArgs { pub issue: IssueUrl, @@ -28,6 +34,8 @@ pub struct RunArgs { /// What `email` or `no-email` asked for, if either was given; without /// one, the User config decides. pub email: Option, + /// The Spec branch, given with [`SPEC_BRANCH`] to a Ticket's Run. + pub spec_branch: Option, } /// Parse the arguments after the program name. `help`, `version`, `update`, @@ -59,6 +67,7 @@ pub fn parse(args: &[String]) -> Result { let mut issue = None; let mut goal = None; let mut email = None; + let mut spec_branch = None; let mut args = args.iter().peekable(); while let Some(arg) = args.next() { match arg.as_str() { @@ -73,6 +82,12 @@ pub fn parse(args: &[String]) -> Result { "no-email" | "--no-email" => { ask_once(&mut email, NotificationAsk::Skip, arg, EMAIL_FLAGS)? } + SPEC_BRANCH => { + if spec_branch.is_some() { + bail!("repeated argument: {arg}"); + } + spec_branch = Some(args.next().context("missing Spec branch")?.clone()); + } _ => { if issue.is_some() { bail!("unexpected argument after the Issue URL: {arg}"); @@ -84,7 +99,12 @@ pub fn parse(args: &[String]) -> Result { let Some(issue) = issue else { bail!("missing Issue URL"); }; - Ok(Command::Run(RunArgs { issue, goal, email })) + Ok(Command::Run(RunArgs { + issue, + goal, + email, + spec_branch, + })) } const MERGE_FLAGS: &str = "merge and no-merge"; diff --git a/src/github.rs b/src/github.rs index 0813c8da5..dd859c8b9 100644 --- a/src/github.rs +++ b/src/github.rs @@ -40,6 +40,94 @@ pub fn issue_title(issue: &IssueUrl) -> Result { .to_string()) } +/// A Spec's sub-issue, as a Spec run reads it. +pub struct Ticket { + pub number: u64, + pub is_open: bool, + pub labels: Vec, + /// It has sub-issues of its own. + pub has_sub_issues: bool, + /// The numbers of the open issues it is blocked by, in the Spec or not. + pub open_blockers: Vec, +} + +/// Every sub-issue of `issue`, open or closed, with its labels, whether it +/// has sub-issues of its own and the issues it is blocked by: its GitHub +/// "blocked by" links, never the text of its body. +const TICKETS_QUERY: &str = "\ +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + issue(number: $number) { + subIssues(first: 100) { + nodes { + number + state + labels(first: 100) { nodes { name } } + subIssues { totalCount } + blockedBy(first: 100) { nodes { number state } } + } + } + } + } +}"; + +/// The sub-issues of `issue`, its Tickets if it has any, in one query. +pub fn tickets(issue: &IssueUrl) -> Result> { + let json = gh_json(&[ + "api", + "graphql", + "-f", + &format!("query={TICKETS_QUERY}"), + "-f", + &format!("owner={}", issue.owner), + "-f", + &format!("repo={}", issue.repo), + "-F", + &format!("number={}", issue.number), + ])?; + let nodes = |value: &Value, what: &str| -> Result> { + value["nodes"] + .as_array() + .cloned() + .with_context(|| format!("gh api graphql output has no {what}")) + }; + let is_open = |node: &Value| -> Result { + Ok(node["state"] + .as_str() + .context("gh api graphql output has an issue with no state")? + == "OPEN") + }; + let number = |node: &Value| { + node["number"] + .as_u64() + .context("gh api graphql output has an issue with no number") + }; + nodes( + &json["data"]["repository"]["issue"]["subIssues"], + "subIssues", + )? + .iter() + .map(|node| { + let mut open_blockers = Vec::new(); + for blocker in nodes(&node["blockedBy"], "blockedBy")? { + if is_open(&blocker)? { + open_blockers.push(number(&blocker)?); + } + } + Ok(Ticket { + number: number(node)?, + is_open: is_open(node)?, + labels: nodes(&node["labels"], "labels")? + .iter() + .filter_map(|label| label["name"].as_str().map(String::from)) + .collect(), + has_sub_issues: node["subIssues"]["totalCount"].as_u64().unwrap_or(0) > 0, + open_blockers, + }) + }) + .collect() +} + /// Close `issue` with `comment`. pub fn close_issue(issue: &IssueUrl, comment: &str) -> Result<()> { gh(&[ @@ -169,6 +257,31 @@ pub fn pull_requests_with_head_prefix(issue: &IssueUrl, prefix: &str) -> Result< .collect() } +/// Open a pull request, ready for review, from `head` into `base`, and +/// return its URL. +pub fn create_pr( + issue: &IssueUrl, + head: &str, + base: &str, + title: &str, + body: &str, +) -> Result { + gh_stdout(&[ + "pr", + "create", + "--repo", + &issue.repo_slug(), + "--head", + head, + "--base", + base, + "--title", + title, + "--body", + body, + ]) +} + /// Mark the pull request whose head is `branch` ready for review. pub fn mark_ready(issue: &IssueUrl, branch: &str) -> Result<()> { gh(&["pr", "ready", branch, "--repo", &issue.repo_slug()]) @@ -329,6 +442,12 @@ pub fn checks_on(issue: &IssueUrl, sha: &str) -> Result> { /// Run `gh `, failing with its stderr if it exits non-zero. fn gh(args: &[&str]) -> Result<()> { + gh_stdout(args).map(drop) +} + +/// Run `gh ` and return its trimmed stdout, failing with its stderr if +/// it exits non-zero. +fn gh_stdout(args: &[&str]) -> Result { let output = Command::new("gh") .args(args) .output() @@ -340,7 +459,7 @@ fn gh(args: &[&str]) -> Result<()> { String::from_utf8_lossy(&output.stderr).trim() ); } - Ok(()) + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } /// Every item of the `field` list of the GitHub API's `path`, across all its diff --git a/src/issue.rs b/src/issue.rs index 3c69b29eb..030919fd8 100644 --- a/src/issue.rs +++ b/src/issue.rs @@ -33,6 +33,16 @@ impl IssueUrl { } } + /// Issue `number` in the same repository. + pub fn sibling(&self, number: u64) -> IssueUrl { + IssueUrl { + url: format!("https://github.com/{}/issues/{number}", self.repo_slug()), + owner: self.owner.clone(), + repo: self.repo.clone(), + number, + } + } + /// `owner/repo`, as `gh --repo` takes it. pub fn repo_slug(&self) -> String { format!("{}/{}", self.owner, self.repo) diff --git a/src/main.rs b/src/main.rs index d16c1af52..311a4a353 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,6 +19,7 @@ mod prompt; mod prompts_page; mod run; mod session; +mod spec_run; mod update; mod worktree; @@ -28,6 +29,7 @@ use std::process::ExitCode; use args::{Command, RunArgs}; use config::UserConfig; use notification::{NotificationAsk, RunNotification}; +use run::Goal; const HELP: &str = "\ thirdshift turns a GitHub issue into a ready-for-review pull request, or a merged one, unattended. @@ -78,7 +80,12 @@ The Resend API key comes only from the RESEND_API_KEY environment variable: fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); - let RunArgs { issue, goal, email } = match args::parse(&args) { + let RunArgs { + issue, + goal, + email, + spec_branch, + } = match args::parse(&args) { Ok(Command::Help) => { print!("{HELP}"); return ExitCode::SUCCESS; @@ -104,13 +111,22 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let goal = goal.unwrap_or(config.default_goal()); + // A Ticket's Run in a Spec run is always a Merge run, and leaves the Run + // notification and the Launch directory to the Spec run. + let (goal, email, launch_pull) = match spec_branch { + Some(_) => (Goal::Merged, NotificationAsk::Skip, false), + None => ( + goal.unwrap_or(config.default_goal()), + email.unwrap_or(config.email.default_ask()), + config.launch_pull, + ), + }; // First, so no interrupt can end the Run once its notification is checked. if let Err(error) = interrupt::install() { progress::step(format_args!("{error:#}")); return ExitCode::FAILURE; } - let notification = match email.unwrap_or(config.email.default_ask()) { + let notification = match email { NotificationAsk::Send(to) => RunNotification::new(to, &config.email, &issue).map(Some), NotificationAsk::Skip => Ok(None), }; @@ -121,11 +137,21 @@ fn main() -> ExitCode { return ExitCode::FAILURE; } }; - let ended = run::run(&issue, goal, &config.logs_dir, config.launch_pull); + let ended = run::run( + &issue, + goal, + &config.logs_dir, + launch_pull, + spec_branch.as_deref(), + ); let code = match &ended { Ok(reached) => { // Also on stderr, so the outcome shows even when stdout is captured. - progress::step(format_args!("PR {} is {}", reached.pr_url, goal.outcome())); + progress::step(format_args!( + "PR {} is {}", + reached.pr_url, + reached.goal.outcome() + )); print_pr_url(&reached.pr_url); ExitCode::SUCCESS } @@ -141,7 +167,7 @@ fn main() -> ExitCode { } }; if let Some(notification) = notification { - notification.send(&ended, goal); + notification.send(&ended); } code } diff --git a/src/notification.rs b/src/notification.rs index d99f6671c..c462cadb8 100644 --- a/src/notification.rs +++ b/src/notification.rs @@ -13,7 +13,7 @@ use crate::github; use crate::host; use crate::issue::IssueUrl; use crate::progress; -use crate::run::{Goal, Reached}; +use crate::run::Reached; /// What a Run asks about its Run notification, by its command or, without /// `email` or `no-email`, by the User config. @@ -54,15 +54,15 @@ impl RunNotification { }) } - /// Send the notification for the Run that `ended`, whose goal was `goal`. - /// A failed send is only a warning: it never changes the Run's outcome. - pub fn send(self, ended: &Result, goal: Goal) { + /// Send the notification for the Run that `ended`. A failed send is only + /// a warning: it never changes the Run's outcome. + pub fn send(self, ended: &Result) { let (outcome, pr_url, cause, log) = match ended { Ok(reached) => ( - goal.outcome(), + reached.goal.outcome(), Some(reached.pr_url.as_str()), None, - Some(reached.log.as_path()), + reached.log.as_deref(), ), Err(failed) => { let (outcome, cause) = if failed.interrupted { diff --git a/src/run.rs b/src/run.rs index 0c0fdfbce..4a0a33e10 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,4 +1,5 @@ -//! One Run: from an Issue URL to a checked PR, or to a Failed run. +//! One Run: from an Issue URL to a checked PR, or to a Failed run. An issue +//! with sub-issues is a Spec instead, handed to a Spec run. use std::path::{Path, PathBuf}; @@ -17,6 +18,7 @@ use crate::preflight; use crate::progress; use crate::prompt; use crate::session::{Logs, Sessions}; +use crate::spec_run; use crate::worktree::{Merge, Worktree}; /// Where a Run takes its PR: ready for review, or, in a Merge run, merged. @@ -37,11 +39,13 @@ impl Goal { } } -/// A Run that reached its goal. +/// A Run, or a Spec run, that reached its goal. pub struct Reached { pub pr_url: String, - /// The most recent session's log. - pub log: PathBuf, + /// The goal reached. + pub goal: Goal, + /// The most recent session's log, if a session was started. + pub log: Option, } /// Take `issue` to a ready PR, or in a Merge run a merged one. Any failure @@ -51,19 +55,32 @@ pub struct Reached { /// that a Failed run whose work did not reach origin keeps the worktree and /// branch. With `launch_pull`, the Launch directory's checkout of the /// Base branch is first brought up to date with origin. +/// +/// With `spec_branch`, this is a Ticket's Run in a Spec run, and the Spec +/// branch stands in for the checked-out branch as the Base branch. Otherwise +/// an issue with sub-issues is a Spec, taken on by a Spec run instead, whose +/// Spec branch is picked like an Issue branch. pub fn run( issue: &IssueUrl, goal: Goal, logs_dir: &Path, launch_pull: bool, + spec_branch: Option<&str>, ) -> Result { let timestamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); let launch = Git::new(std::env::current_dir().context("no current directory")?); preflight::check(&launch, issue)?; - let checked_out = launch - .run(&["symbolic-ref", "--quiet", "--short", "HEAD"]) - .ok(); + let tickets = match spec_branch { + Some(_) => Vec::new(), + None => github::tickets(issue)?, + }; + let checked_out = match spec_branch { + Some(spec_branch) => Some(spec_branch.to_string()), + None => launch + .run(&["symbolic-ref", "--quiet", "--short", "HEAD"]) + .ok(), + }; let selection = branch::select(&launch, issue)?; let branch = selection.branch().to_string(); let base = selection.base_branch(checked_out.as_deref())?; @@ -75,15 +92,20 @@ pub fn run( if interrupt::requested() { return Err(anyhow!("interrupted").into()); } - let (worktree, prompt) = match &selection { - Selection::Fresh { .. } => ( - Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?, - prompt::fresh(issue, &base, &branch), - ), - Selection::Continuation { pr, .. } => ( - Worktree::continue_existing(&launch, &issue.repo, &branch, &base)?, - prompt::continuation(issue, &base, &branch, pr.as_ref().map(|pr| pr.url.as_str())), - ), + let worktree = match &selection { + Selection::Fresh { .. } => Worktree::create_fresh(&launch, &issue.repo, &branch, &base)?, + Selection::Continuation { .. } => { + Worktree::continue_existing(&launch, &issue.repo, &branch, &base)? + } + }; + if !tickets.is_empty() { + return spec_run::run(issue, tickets, worktree, &base); + } + let prompt = match &selection { + Selection::Fresh { .. } => prompt::fresh(issue, &base, &branch), + Selection::Continuation { pr, .. } => { + prompt::continuation(issue, &base, &branch, pr.as_ref().map(|pr| pr.url.as_str())) + } }; let logs = Logs { issue, @@ -92,7 +114,11 @@ pub fn run( }; let mut log = logs.path("implement"); match implement(issue, &worktree, &base, &prompt, goal, &logs, &mut log) { - Ok(pr_url) => Ok(Reached { pr_url, log }), + Ok(pr_url) => Ok(Reached { + pr_url, + goal, + log: Some(log), + }), Err(error) => Err(failed_run::fail(issue, worktree, &base, &log, error)), } } diff --git a/src/spec_run.rs b/src/spec_run.rs new file mode 100644 index 000000000..3c9099ffa --- /dev/null +++ b/src/spec_run.rs @@ -0,0 +1,133 @@ +//! A Spec run: each of a Spec's Tickets, in dependency order, taken by a +//! Ticket's Run, a Merge run into the Spec branch in a child `thirdshift` +//! (ADR-0006), then the Spec PR from the Spec branch into the Base branch. + +use std::collections::HashSet; +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; + +use anyhow::{Context, Result, bail}; + +use crate::args; +use crate::failed_run::FailedRun; +use crate::github::{self, Ticket}; +use crate::interrupt; +use crate::issue::IssueUrl; +use crate::progress; +use crate::run::{Goal, Reached}; +use crate::worktree::Worktree; + +/// The triage labels that make an open Ticket an Unready Ticket. +const UNREADY_LABELS: [&str; 4] = ["ready-for-human", "needs-info", "wontfix", "needs-triage"]; + +/// Take the Spec `spec`, whose Tickets were last read as `tickets`, from +/// its Spec branch, checked out in `worktree`, to a Spec PR into `base`, +/// ready for review. The Spec branch is pushed before any Ticket starts, and +/// the Tickets run one at a time, the graph read again after each. A Ticket +/// that fails ends the Spec run. The worktree is cleaned up when this +/// returns. +pub fn run( + spec: &IssueUrl, + tickets: Vec, + worktree: Worktree, + base: &str, +) -> Result { + let pr_url = land_tickets(spec, tickets, &worktree) + .and_then(|()| open_spec_pr(spec, worktree.branch(), base))?; + Ok(Reached { + pr_url, + goal: Goal::ReadyForReview, + log: None, + }) +} + +/// Push the Spec branch, then run each ready Ticket, lowest number first, +/// until none is ready. Fails if a Ticket fails, or if any is left open. +fn land_tickets(spec: &IssueUrl, mut tickets: Vec, worktree: &Worktree) -> Result<()> { + worktree.push()?; + let mut started = HashSet::new(); + while let Some(ticket) = next_ready(&tickets, &started) { + if interrupt::requested() { + bail!("interrupted"); + } + started.insert(ticket); + run_ticket(spec, ticket, worktree.branch())?; + tickets = github::tickets(spec)?; + } + let open: Vec = tickets + .iter() + .filter(|ticket| ticket.is_open) + .map(|ticket| format!("#{}", ticket.number)) + .collect(); + if !open.is_empty() { + bail!("no Ticket is ready, and {} still open", open.join(", ")); + } + Ok(()) +} + +/// The lowest-numbered Ticket that is open, not an Unready Ticket, has no +/// sub-issues, has every blocker closed and hasn't been `started` yet. +fn next_ready(tickets: &[Ticket], started: &HashSet) -> Option { + tickets + .iter() + .filter(|ticket| { + ticket.is_open + && !ticket.has_sub_issues + && ticket.open_blockers.is_empty() + && !started.contains(&ticket.number) + && !ticket + .labels + .iter() + .any(|label| UNREADY_LABELS.contains(&label.as_str())) + }) + .map(|ticket| ticket.number) + .min() +} + +/// Run Ticket `number` of `spec` as a child `thirdshift`, a Merge run into +/// `spec_branch` from the same Launch directory, relaying its stderr with a +/// `#: ` prefix. Fails unless it exits 0. +fn run_ticket(spec: &IssueUrl, number: u64, spec_branch: &str) -> Result<()> { + progress::step(format_args!("starting #{number}")); + let ticket = spec.sibling(number); + let mut child = Command::new(std::env::current_exe().context("no thirdshift executable")?) + .args([args::SPEC_BRANCH, spec_branch, &ticket.url]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .with_context(|| format!("could not start the Run for #{number}"))?; + let stderr = child.stderr.take().context("no stderr from the Run")?; + for line in BufReader::new(stderr).lines() { + let line = line.with_context(|| format!("could not read the Run for #{number}"))?; + let line = line.strip_prefix("thirdshift: ").unwrap_or(&line); + progress::step(format_args!("#{number}: {line}")); + } + let status = child + .wait() + .with_context(|| format!("could not wait for the Run for #{number}"))?; + if !status.success() { + bail!("#{number} failed"); + } + progress::step(format_args!("#{number} landed")); + Ok(()) +} + +/// The Spec PR from `spec_branch` into `base`, ready for review: the open +/// one if there is one, else a new one titled from the Spec that closes it. +fn open_spec_pr(spec: &IssueUrl, spec_branch: &str, base: &str) -> Result { + if let Some(pr) = github::pull_request_for(spec, spec_branch)?.filter(|pr| pr.is_open()) { + if pr.is_draft { + github::mark_ready(spec, spec_branch)?; + } + return Ok(pr.url); + } + progress::step(format_args!("opening the Spec PR into {base}")); + let title = github::issue_title(spec)?; + let body = format!( + "The work on Spec #{number}, gathered from its Tickets on {spec_branch}.\n\n\ + Closes #{number}\n", + number = spec.number + ); + github::create_pr(spec, spec_branch, base, &title, &body) +} diff --git a/tests/continuation.rs b/tests/continuation.rs index 44a6ad294..16a8a5bb8 100644 --- a/tests/continuation.rs +++ b/tests/continuation.rs @@ -282,18 +282,18 @@ fn selection_asks_github_once_for_the_pr_history() { scenario.run(&[&scenario.issue_url(7)]); - // Pre-flight's `issue view` comes first; everything after it and before - // the post-session `pr view` is selection. The agent here makes no gh - // calls of its own. + // Pre-flight's `issue view` comes first, then the `api graphql` that finds + // no sub-issues; everything after it and before the post-session + // `pr view` is selection. The agent here makes no gh calls of its own. let subcommands: Vec = scenario .gh_calls() .iter() .map(|argv| argv[..2].join(" ")) .take_while(|subcommand| subcommand != "pr view") .collect(); - assert_eq!(subcommands, vec!["issue view", "pr list"]); + assert_eq!(subcommands, vec!["issue view", "api graphql", "pr list"]); assert!( - scenario.gh_calls()[1] + scenario.gh_calls()[2] .windows(2) .any(|w| w == ["--state", "all"]), "gh calls: {:?}", diff --git a/tests/fakes/claude b/tests/fakes/claude index e0d7dd18c..c6a1ec0b3 100644 --- a/tests/fakes/claude +++ b/tests/fakes/claude @@ -7,9 +7,15 @@ progress there, and a snapshot of the --plugin-dir contents (the directory is gone by the time a test looks). It then runs a per-test bash script in the working directory, emits a couple -of stream-json lines, and exits with the script's exit code. The script for the -n-th call (1-based) is $FAKE_CLAUDE_SCRIPT with `.` appended if that file -exists, else $FAKE_CLAUDE_SCRIPT itself. +of stream-json lines, and exits with the script's exit code. A session's issue +is the first Issue URL in its prompt, and its count the number of sessions so +far whose prompt names that issue, this one included. The script is the first +of these files that exists: + + $FAKE_CLAUDE_SCRIPT.issue-. + $FAKE_CLAUDE_SCRIPT.issue- + $FAKE_CLAUDE_SCRIPT. for the n-th call overall (1-based) + $FAKE_CLAUDE_SCRIPT The `init` line carries session id `fake-session-` for the n-th call. Lines the script appends to the file named by $FAKE_CLAUDE_AFTER_RESULT are emitted @@ -19,6 +25,7 @@ ends. import json import os +import re import subprocess import sys @@ -33,6 +40,27 @@ def plugin_snapshot(plugin_dir): return files +ISSUE_URL = re.compile(r"https://github\.com/[^/\s]+/[^/\s]+/issues/(\d+)") + + +def issue_of(prompt): + """The number of the first Issue URL in `prompt`, if any.""" + match = ISSUE_URL.search(prompt or "") + return match.group(1) if match else None + + +def script_for(records): + """The script for the session just recorded last in `records`.""" + script = os.environ["FAKE_CLAUDE_SCRIPT"] + issue = issue_of(records[-1]["prompt"]) + candidates = [] + if issue is not None: + count = sum(1 for record in records if issue_of(record["prompt"]) == issue) + candidates += [f"{script}.issue-{issue}.{count}", f"{script}.issue-{issue}"] + candidates.append(f"{script}.{len(records)}") + return next((path for path in candidates if os.path.exists(path)), script) + + def main(): argv = sys.argv[1:] plugin_dir = argv[argv.index("--plugin-dir") + 1] if "--plugin-dir" in argv else None @@ -72,9 +100,7 @@ def main(): "session_id": f"fake-session-{len(records)}", } print(json.dumps(init), flush=True) - script = os.environ["FAKE_CLAUDE_SCRIPT"] - if os.path.exists(f"{script}.{len(records)}"): - script = f"{script}.{len(records)}" + script = script_for(records) # Beside the record, not in $TMPDIR, which tests expect to be left empty. after_result = f"{record_path}.after-result.{len(records)}" env = dict(os.environ, FAKE_CLAUDE_AFTER_RESULT=after_result) diff --git a/tests/fakes/gh b/tests/fakes/gh index aceedc778..7de2c9423 100644 --- a/tests/fakes/gh +++ b/tests/fakes/gh @@ -16,7 +16,10 @@ State lives in the JSON file named by $FAKE_GH_STATE: "refuse_merges"?: {"times", "error"}, "after_merge"?: "