From 415fa7484247a754316e7160ab56d0d188d40663 Mon Sep 17 00:00:00 2001 From: Miguel Vila Date: Mon, 19 Oct 2026 15:48:09 -0500 Subject: [PATCH 1/7] Allow flows to open draft pull requests Add PrReadiness (Ready | Draft) to GitHubTool.createPr, openPrFromBranch and openPrIfGitHub. Draft passes --draft to gh pr create. An existing open PR for the branch is reused unchanged. --- docs/api/tools.md | 9 ++--- docs/authoring/pull-requests.md | 10 ++++-- .../main/scala/orca/pr/openPrFromBranch.scala | 12 +++++-- .../main/scala/orca/pr/openPrIfGitHub.scala | 21 ++++++++---- .../scala/orca/pr/OpenPrFromBranchTest.scala | 34 +++++++++++++++++++ .../scala/orca/pr/OpenPrIfGitHubTest.scala | 34 +++++++++++++++++++ flow/src/test/scala/orca/pr/PrFakes.scala | 19 ++++++++--- runner/src/main/scala/orca/exports.scala | 1 + .../scala/orca/runner/FlowLifecycleTest.scala | 7 +++- .../main/scala/orca/tools/GitHubTool.scala | 33 ++++++++++++------ .../scala/orca/testkit/StubGitHubTool.scala | 5 +-- .../scala/orca/tools/OsGitHubToolTest.scala | 28 +++++++++++++++ 12 files changed, 179 insertions(+), 34 deletions(-) diff --git a/docs/api/tools.md b/docs/api/tools.md index 9cb549fa2..17252a2ee 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -62,7 +62,7 @@ and `issue` an `IssueHandle`. |---|---| | `availability()` | probes whether a PR can be opened from this checkout | | `prHandle(ref)` | a `PrHandle` from a PR URL or `owner/repo#N`; `Either[String, PrHandle]` | -| `createPr(title, body)` | opens a PR; `Either[PrCreateFailed, PrHandle]` | +| `createPr(title, body, readiness?)` | opens a PR, as a draft with `PrReadiness.Draft`; `Either[PrCreateFailed, PrHandle]` | | `updatePr(pr, title, body)` | replaces a PR's title and body | | `readIssue(issue)`, `readIssueComments(issue)`, `readPrComments(pr)` | read an issue, its comments, or a PR's comments | | `writeComment(pr, body)`, `writeComment(issue, body)` | posts a comment | @@ -74,9 +74,10 @@ and `issue` an `IssueHandle`. returns `Either[BuildWaitFailed, BuildStatus]`. `createPr` is idempotent by branch: if a PR is already open for the branch, it -returns that one. `upsertComment` finds a prior comment carrying `marker` and -edits it in place, posting a new one only when there is none. `updatePr` is -harmless to re-run. This idempotency is what makes a resumed stage safe, see +returns that one as it is: `readiness` applies only to a PR this call opens, and +never changes the state of an existing one. `upsertComment` finds a prior +comment carrying `marker` and edits it in place, posting a new one only when there is none. `updatePr` +is harmless to re-run. This idempotency is what makes a resumed stage safe, see [Pull requests](../authoring/pull-requests.md). ## `fs` diff --git a/docs/authoring/pull-requests.md b/docs/authoring/pull-requests.md index da7d49a44..f6b033596 100644 --- a/docs/authoring/pull-requests.md +++ b/docs/authoring/pull-requests.md @@ -9,7 +9,7 @@ There are two calls that open a PR, and one that only writes its description. ### `openPrIfGitHub` -`openPrIfGitHub(summarisingAgent, openFindings, title?, body?, context?, instructions?): Option[PrHandle]` +`openPrIfGitHub(summarisingAgent, openFindings, title?, body?, context?, instructions?, readiness?): Option[PrHandle]` pushes the branch, summarises the change and opens the PR, provided that `gh.availability` says the checkout is on a reachable GitHub. Otherwise it emits one step saying why and returns `None`; the run then finishes. This is @@ -23,7 +23,7 @@ included. ### `openPrFromBranch` -`openPrFromBranch(summarisingAgent, openFindings, title?, body?, context?, instructions?): PrHandle` +`openPrFromBranch(summarisingAgent, openFindings, title?, body?, context?, instructions?, readiness?): PrHandle` runs the same three stages, but fails the run when there is no GitHub remote or `gh` login. @@ -39,6 +39,12 @@ Both open calls create stages, so they cannot be called inside a `stage(...)` body. Call them at the top level of the flow. ``` +### Draft PRs + +Both open calls take `readiness: PrReadiness = PrReadiness.Ready`. Pass +`PrReadiness.Draft` to open the PR as a draft. It applies only to a PR the run +opens: a PR already open for the branch is reused with its state unchanged. + ### Findings, title, body and context `openFindings` is the final review's diff --git a/flow/src/main/scala/orca/pr/openPrFromBranch.scala b/flow/src/main/scala/orca/pr/openPrFromBranch.scala index dd57eda3d..14b13d7b6 100644 --- a/flow/src/main/scala/orca/pr/openPrFromBranch.scala +++ b/flow/src/main/scala/orca/pr/openPrFromBranch.scala @@ -3,7 +3,7 @@ package orca.pr import orca.{FlowContext, FlowControl, OutsideStage, fail, gh, git, tracedStage} import orca.agents.Agent import orca.review.OpenFindings -import orca.tools.PrHandle +import orca.tools.{PrHandle, PrReadiness} import ox.either.orThrow @@ -37,6 +37,10 @@ import ox.either.orThrow * findings it left unfixed. The same section is printed to the run output, * also when opening the PR fails. * + * `readiness` opens the PR as a draft with [[orca.tools.PrReadiness.Draft]]. + * It applies only to a PR this run opens: a PR that already exists for the + * branch is returned as it is. + * * `gh.createPr` is idempotent by head branch: a re-run that already opened the * PR gets the existing handle back rather than failing. Returns that handle, * recorded as the run's published work through [[recordOpenedPr]] inside the @@ -52,7 +56,8 @@ def openPrFromBranch( title: PrSummary => String = _.title, body: PrSummary => String = _.body, context: Option[String] = None, - instructions: String = PrPrompts.Summarise + instructions: String = PrPrompts.Summarise, + readiness: PrReadiness = PrReadiness.Ready )(using FlowContext, FlowControl, OutsideStage): PrHandle = summon[FlowControl].assertAtFlowBody("openPrFromBranch(...)") reportOpenFindings(openFindings) @@ -72,7 +77,8 @@ def openPrFromBranch( val pr = gh .createPr( title = title(summary), - body = bodyWithOpenFindings(body(summary), openFindings) + body = bodyWithOpenFindings(body(summary), openFindings), + readiness = readiness ) .orThrow recordOpened(pr) diff --git a/flow/src/main/scala/orca/pr/openPrIfGitHub.scala b/flow/src/main/scala/orca/pr/openPrIfGitHub.scala index 56881c2ca..7d4a2b3af 100644 --- a/flow/src/main/scala/orca/pr/openPrIfGitHub.scala +++ b/flow/src/main/scala/orca/pr/openPrIfGitHub.scala @@ -15,7 +15,7 @@ import orca.agents.Agent import orca.events.OrcaEvent import orca.progress.ThrowawayBranch import orca.review.OpenFindings -import orca.tools.{GitHubAvailability, NoDefaultBase, PrHandle} +import orca.tools.{GitHubAvailability, NoDefaultBase, PrHandle, PrReadiness} import orca.util.TextUtil import scala.util.control.NonFatal @@ -45,7 +45,8 @@ def openPrIfGitHub( title: PrSummary => String = _.title, body: PrSummary => String = _.body, context: Option[String] = None, - instructions: String = PrPrompts.Summarise + instructions: String = PrPrompts.Summarise, + readiness: PrReadiness = PrReadiness.Ready )(using ctx: FlowContext, control: FlowControl, @@ -62,7 +63,8 @@ def openPrIfGitHub( // threaded through the legs below. body = summary => bodyWithOpenFindings(body(summary), openFindings), context = context, - instructions = instructions + instructions = instructions, + readiness = readiness ).fold(skipped, Some(_)) /** [[openPrFromBranch]]'s three stages, the two remote-facing legs under @@ -82,7 +84,8 @@ private def pushThenCreate( title: PrSummary => String, body: PrSummary => String, context: Option[String], - instructions: String + instructions: String, + readiness: PrReadiness )(using FlowContext, FlowControl): Either[String, PrHandle] = for push <- gatedStage(PushStage)(preFlight(base))(_ => pushBestEffort()) @@ -90,7 +93,7 @@ private def pushThenCreate( summary <- summarise(summarisingAgent, base, context, instructions).left .map(baseStopReason) create = tracedStage(CreateStage)( - createBestEffort(title(summary), body(summary)) + createBestEffort(title(summary), body(summary), readiness) ) pr <- create.value.outcome.left.map(refusalLine(_, create)) yield pr @@ -147,7 +150,11 @@ private def pushBestEffort()(using FlowContext, WorkspaceWrite): PushResult = /** [[openPrFromBranch]]'s create with the `gh pr create` under [[bestEffort]], * as [[pushBestEffort]] is for the push. */ -private def createBestEffort(title: String, body: String)(using +private def createBestEffort( + title: String, + body: String, + readiness: PrReadiness +)(using FlowContext, FlowControl, WorkspaceWrite @@ -155,7 +162,7 @@ private def createBestEffort(title: String, body: String)(using bestEffort( "could not open a PR", "open it yourself from the pushed branch" - )(gh.createPr(title = title, body = body)) match + )(gh.createPr(title = title, body = body, readiness = readiness)) match case Left(reason) => CreateResult.Refused(reason) // Outside `bestEffort`, so a log that cannot be written fails the run. case Right(pr) => recordOpened(pr) diff --git a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala index 20d2e27a2..5e3894bb4 100644 --- a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala +++ b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala @@ -10,6 +10,7 @@ import orca.tools.{ GitHubAvailability, PrCreateFailed, PrHandle, + PrReadiness, PushFailure } import orca.events.{OrcaEvent, OrcaListener} @@ -101,6 +102,39 @@ class OpenPrFromBranchTest extends FunSuite: List("Push branch", "Generate PR title and description", "Open PR") ) + test("openPrFromBranch passes the requested readiness to gh.createPr"): + def readinessFor(requested: Option[PrReadiness]): List[PrReadiness] = + val (dir, store) = seededPrRepo() + val readinesses = new ConcurrentLinkedQueue[PrReadiness]() + val run = prRun( + dir, + store, + _ => (), + new ConcurrentLinkedQueue[String](), + prReadinesses = readinesses + ) + val agent = new StubSummariser().agent + requested match + case Some(r) => + val _ = openPrFromBranch(agent, OpenFindings.empty, readiness = r)( + using + run.context, + run.control, + summon[OutsideStage] + ) + case None => + val _ = openPrFromBranch(agent, OpenFindings.empty)(using + run.context, + run.control, + summon[OutsideStage] + ) + readinesses.asScala.toList + assertEquals(readinessFor(None), List(PrReadiness.Ready)) + assertEquals( + readinessFor(Some(PrReadiness.Draft)), + List(PrReadiness.Draft) + ) + test("openPrFromBranch reached inside a stage is refused before it pushes"): // A helper taking only FlowControl carries OutsideStage past the compile // check; the stage open around it is caught at run time. diff --git a/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala b/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala index 398c7f3c2..d66e45478 100644 --- a/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala +++ b/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala @@ -9,6 +9,7 @@ import orca.tools.{ NoDefaultBase, PrCreateFailed, PrHandle, + PrReadiness, PushFailure } import orca.{OrcaFlowException, OutsideStage, WorkspaceWrite} @@ -215,6 +216,39 @@ class OpenPrIfGitHubTest extends FunSuite: r.steps ) + test("openPrIfGitHub passes the requested readiness to gh.createPr"): + def readinessFor(requested: Option[PrReadiness]): List[PrReadiness] = + val (dir, store) = seededPrRepo() + val readinesses = new ConcurrentLinkedQueue[PrReadiness]() + val run = prRun( + dir, + store, + _ => (), + new ConcurrentLinkedQueue[String](), + availability = available, + prReadinesses = readinesses + ) + val agent = new StubSummariser().agent + requested match + case Some(r) => + val _ = openPrIfGitHub(agent, OpenFindings.empty, readiness = r)(using + run.context, + run.control, + summon[OutsideStage] + ) + case None => + val _ = openPrIfGitHub(agent, OpenFindings.empty)(using + run.context, + run.control, + summon[OutsideStage] + ) + readinesses.asScala.toList + assertEquals(readinessFor(None), List(PrReadiness.Ready)) + assertEquals( + readinessFor(Some(PrReadiness.Draft)), + List(PrReadiness.Draft) + ) + test("open findings reach the body of the PR this step opens"): // The step every code-producing built-in flow ends with, so the section // has to survive the best-effort path too, not only openPrFromBranch's. diff --git a/flow/src/test/scala/orca/pr/PrFakes.scala b/flow/src/test/scala/orca/pr/PrFakes.scala index 6bdab0ead..ef10587b5 100644 --- a/flow/src/test/scala/orca/pr/PrFakes.scala +++ b/flow/src/test/scala/orca/pr/PrFakes.scala @@ -10,6 +10,7 @@ import orca.tools.{ OsGitTool, PrCreateFailed, PrHandle, + PrReadiness, PushFailure, RuntimeGit } @@ -62,13 +63,19 @@ private[pr] class RecordingGh( calls: ConcurrentLinkedQueue[String], availabilityAnswer: => GitHubAvailability, createPrAnswer: => Either[PrCreateFailed, PrHandle] = Right(samplePr), - bodies: ConcurrentLinkedQueue[String] + bodies: ConcurrentLinkedQueue[String], + readinesses: ConcurrentLinkedQueue[PrReadiness] ) extends StubGitHubTool: override def availability(): GitHubAvailability = calls.add("availability"): Unit availabilityAnswer - override def createPr(title: String, body: String)(using WorkspaceWrite) = + override def createPr( + title: String, + body: String, + readiness: PrReadiness + )(using WorkspaceWrite) = calls.add("createPr"): Unit + readinesses.add(readiness): Unit bodies.add(body): Unit createPrAnswer @@ -154,7 +161,9 @@ private[pr] def prRun( push: => Either[PushFailure, Unit] = Right(()), base: => Either[NoDefaultBase, String] = Right("main"), prBodies: ConcurrentLinkedQueue[String] = - new ConcurrentLinkedQueue[String]() + new ConcurrentLinkedQueue[String](), + prReadinesses: ConcurrentLinkedQueue[PrReadiness] = + new ConcurrentLinkedQueue[PrReadiness]() ): TestRun = TestRun( new TestFlowControl( @@ -168,7 +177,9 @@ private[pr] def prRun( wiredGit = Some( new RecordingGit(new OsGitTool(dir), calls, branchDiff, push, base) ), - wiredGh = Some(new RecordingGh(calls, availability, createPr, prBodies)) + wiredGh = Some( + new RecordingGh(calls, availability, createPr, prBodies, prReadinesses) + ) ), dir ) diff --git a/runner/src/main/scala/orca/exports.scala b/runner/src/main/scala/orca/exports.scala index d9ecb5740..e103799e7 100644 --- a/runner/src/main/scala/orca/exports.scala +++ b/runner/src/main/scala/orca/exports.scala @@ -119,6 +119,7 @@ export orca.tools.{ IssueHandle, NoDefaultBase, PrHandle, + PrReadiness, PushFailure } // Agent-override surface: the wiring an override factory receives, plus each diff --git a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala index 3e6bc42da..118857cc7 100644 --- a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala +++ b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala @@ -36,6 +36,7 @@ import orca.tools.{ GitHubAvailability, GitHubTool, OsGitTool, + PrReadiness, RuntimeGit, UntrackedFiles, Worktrees @@ -1955,7 +1956,11 @@ class FlowLifecycleTest extends munit.FunSuite: private class StubGh extends StubGitHubTool: override def availability(): GitHubAvailability = GitHubAvailability.Available(host = "github.com", owner = "a", repo = "w") - override def createPr(title: String, body: String)(using WorkspaceWrite) = + override def createPr( + title: String, + body: String, + readiness: PrReadiness + )(using WorkspaceWrite) = Right(handoffPr) /** A claude whose structured call answers with a fixed [[PrSummary]] — what diff --git a/tools/src/main/scala/orca/tools/GitHubTool.scala b/tools/src/main/scala/orca/tools/GitHubTool.scala index e839c2959..be4fec8e4 100644 --- a/tools/src/main/scala/orca/tools/GitHubTool.scala +++ b/tools/src/main/scala/orca/tools/GitHubTool.scala @@ -124,6 +124,12 @@ enum CheckState: case Failure case Unknown(raw: String) +/** Whether [[GitHubTool.createPr]] opens a PR ready for review or as a draft. + */ +enum PrReadiness: + case Ready + case Draft + /** Recoverable [[GitHubTool.createPr]] failure modes. Common when re-running a * flow against an already-pushed branch. Other gh failures (auth, network) * remain thrown. @@ -217,8 +223,16 @@ trait GitHubTool: * included. Refusals with a recovery path come back as `Left`s; throws on * detached HEAD, on gh output carrying no PR URL, and on system failures * (auth, network). - */ - def createPr(title: String, body: String)(using + * + * `readiness` applies only to a PR this call opens (`gh pr create --draft`). + * When an open PR already exists for the branch, it is returned as it is: + * neither draft nor ready state is changed. + */ + def createPr( + title: String, + body: String, + readiness: PrReadiness = PrReadiness.Ready + )(using WorkspaceWrite ): Either[PrCreateFailed, PrHandle] @@ -466,7 +480,7 @@ private[orca] class OsGitHubTool( ) catch case NonFatal(e) => Left(cannotRunGh(e)) - def createPr(title: String, body: String)(using + def createPr(title: String, body: String, readiness: PrReadiness)(using ws: WorkspaceWrite ): Either[PrCreateFailed, PrHandle] = ws.check("gh.createPr") @@ -480,15 +494,12 @@ private[orca] class OsGitHubTool( // its progress log would be misread as "branch not pushed". Bare branch // name, no `owner:` prefix, matching `findOpenPr`. val head = headBranchForPr() + val draftFlag = readiness match + case PrReadiness.Draft => Seq("--draft") + case PrReadiness.Ready => Nil val result = runGhResult( - "pr", - "create", - "--title", - title, - "--body", - body, - "--head", - head + Seq("pr", "create", "--title", title, "--body", body, "--head", head) ++ + draftFlag: _* ) if result.exitCode == 0 then val output = result.stdout.trim diff --git a/tools/src/test/scala/orca/testkit/StubGitHubTool.scala b/tools/src/test/scala/orca/testkit/StubGitHubTool.scala index 2c6cca529..c34759aad 100644 --- a/tools/src/test/scala/orca/testkit/StubGitHubTool.scala +++ b/tools/src/test/scala/orca/testkit/StubGitHubTool.scala @@ -10,7 +10,8 @@ import orca.tools.{ IssueHandle, PrCreateFailed, BuildWaitFailed, - PrHandle + PrHandle, + PrReadiness } import scala.concurrent.duration.FiniteDuration @@ -24,7 +25,7 @@ class StubGitHubTool extends GitHubTool: throw new NotImplementedError(s"$endpoint is not wired in this stub") def availability(): GitHubAvailability = nyi("availability") - def createPr(title: String, body: String)(using + def createPr(title: String, body: String, readiness: PrReadiness)(using WorkspaceWrite ): Either[PrCreateFailed, PrHandle] = nyi("createPr") def updatePr(pr: PrHandle, title: String, body: String)(using diff --git a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala index 016cce2c6..4b65720fa 100644 --- a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala +++ b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala @@ -403,6 +403,34 @@ class OsGitHubToolTest extends munit.FunSuite: assert(args.containsSlice(Seq("--body", "hello"))) assert(args.containsSlice(Seq("--head", "feat"))) + test("createPr passes --draft only for a draft PR"): + def createArgs(readiness: PrReadiness): Seq[String] = + val cli = createPrRunner( + CliResult(0, "https://github.com/acme/widgets/pull/42\n", "") + ) + val _ = new OsGitHubTool(cli) + .createPr("feat: hi", "hello", readiness) + .orThrow + cli.calls.last.args + assert(createArgs(PrReadiness.Draft).contains("--draft")) + assert(!createArgs(PrReadiness.Ready).contains("--draft")) + + test("createPr leaves an existing PR alone, whatever the readiness"): + val cli = createPrRunner( + CliResult(1, "", "a pull request for branch \"feat\" already exists"), + CliResult( + 0, + "[{\"number\":42,\"url\":\"https://github.com/acme/widgets/pull/42\"}]", + "" + ) + ) + val pr = new OsGitHubTool(cli) + .createPr("feat: hi", "hello", PrReadiness.Draft) + .orThrow + assertEquals(pr, samplePr) + assert(!cli.calls.exists(_.args.containsSlice(Seq("pr", "ready")))) + assert(!cli.calls.exists(_.args.containsSlice(Seq("pr", "edit")))) + test("createPr parses the PR URL returned by gh"): val cli = createPrRunner( CliResult(0, "https://github.com/acme/widgets/pull/42\n", "") From 6fc521a4bb88eb54b403e1ddce0231f6fbdb3946 Mon Sep 17 00:00:00 2001 From: Miguel Vila Date: Tue, 20 Oct 2026 14:32:20 -0500 Subject: [PATCH 2/7] Tighten draft PR tests and docs Pin PrReadiness in the flow-script compile canary. Assert the exact gh calls when createPr reuses an existing PR. Keep one flow-level readiness test, in OpenPrFromBranchTest, with its setup in a helper. Reflow the createPr paragraph in docs/api/tools.md. --- docs/api/tools.md | 6 +- .../scala/orca/pr/OpenPrFromBranchTest.scala | 75 +++++++++++-------- .../scala/orca/pr/OpenPrIfGitHubTest.scala | 34 --------- .../scala/flowtests/FlowCompilesTest.scala | 16 ++++ .../scala/orca/tools/OsGitHubToolTest.scala | 11 ++- 5 files changed, 73 insertions(+), 69 deletions(-) diff --git a/docs/api/tools.md b/docs/api/tools.md index 17252a2ee..d912b87bc 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -76,9 +76,9 @@ returns `Either[BuildWaitFailed, BuildStatus]`. `createPr` is idempotent by branch: if a PR is already open for the branch, it returns that one as it is: `readiness` applies only to a PR this call opens, and never changes the state of an existing one. `upsertComment` finds a prior -comment carrying `marker` and edits it in place, posting a new one only when there is none. `updatePr` -is harmless to re-run. This idempotency is what makes a resumed stage safe, see -[Pull requests](../authoring/pull-requests.md). +comment carrying `marker` and edits it in place, posting a new one only when +there is none. `updatePr` is harmless to re-run. This idempotency is what makes +a resumed stage safe, see [Pull requests](../authoring/pull-requests.md). ## `fs` diff --git a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala index 5e3894bb4..f8083e64e 100644 --- a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala +++ b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala @@ -1,7 +1,15 @@ package orca.pr import munit.FunSuite -import orca.{BoundedDiff, OrcaFlowException, OutsideStage, interceptReported} +import orca.{ + BoundedDiff, + FlowContext, + FlowControl, + OrcaFlowException, + OutsideStage, + interceptReported +} +import orca.agents.Agent import orca.plan.Title import orca.progress.PublishedWork import orca.review.{FindingId, OpenFinding, OpenFindings, OpenReason} @@ -75,6 +83,25 @@ class OpenPrFromBranchTest extends FunSuite: steps.asScala.toList ) + /** The readiness each `gh.createPr` got while `open` ran over a fresh seeded + * repo, given a summariser to pass on. + */ + private def createPrReadinesses( + open: Agent[?] => (FlowContext, FlowControl) ?=> Any + ): List[PrReadiness] = + val (dir, store) = seededPrRepo() + val readinesses = new ConcurrentLinkedQueue[PrReadiness]() + val run = prRun( + dir, + store, + _ => (), + new ConcurrentLinkedQueue[String](), + prReadinesses = readinesses + ) + import run.given + val _ = open(new StubSummariser().agent) + readinesses.asScala.toList + private val oneOpen = OpenFindings( List( OpenFinding( @@ -102,36 +129,24 @@ class OpenPrFromBranchTest extends FunSuite: List("Push branch", "Generate PR title and description", "Open PR") ) - test("openPrFromBranch passes the requested readiness to gh.createPr"): - def readinessFor(requested: Option[PrReadiness]): List[PrReadiness] = - val (dir, store) = seededPrRepo() - val readinesses = new ConcurrentLinkedQueue[PrReadiness]() - val run = prRun( - dir, - store, - _ => (), - new ConcurrentLinkedQueue[String](), - prReadinesses = readinesses - ) - val agent = new StubSummariser().agent - requested match - case Some(r) => - val _ = openPrFromBranch(agent, OpenFindings.empty, readiness = r)( - using - run.context, - run.control, - summon[OutsideStage] - ) - case None => - val _ = openPrFromBranch(agent, OpenFindings.empty)(using - run.context, - run.control, - summon[OutsideStage] - ) - readinesses.asScala.toList - assertEquals(readinessFor(None), List(PrReadiness.Ready)) + test("openPrFromBranch opens a ready PR by default and a draft on request"): assertEquals( - readinessFor(Some(PrReadiness.Draft)), + createPrReadinesses(agent => + openPrFromBranch( + summarisingAgent = agent, + openFindings = OpenFindings.empty + ) + ), + List(PrReadiness.Ready) + ) + assertEquals( + createPrReadinesses(agent => + openPrFromBranch( + summarisingAgent = agent, + openFindings = OpenFindings.empty, + readiness = PrReadiness.Draft + ) + ), List(PrReadiness.Draft) ) diff --git a/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala b/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala index d66e45478..398c7f3c2 100644 --- a/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala +++ b/flow/src/test/scala/orca/pr/OpenPrIfGitHubTest.scala @@ -9,7 +9,6 @@ import orca.tools.{ NoDefaultBase, PrCreateFailed, PrHandle, - PrReadiness, PushFailure } import orca.{OrcaFlowException, OutsideStage, WorkspaceWrite} @@ -216,39 +215,6 @@ class OpenPrIfGitHubTest extends FunSuite: r.steps ) - test("openPrIfGitHub passes the requested readiness to gh.createPr"): - def readinessFor(requested: Option[PrReadiness]): List[PrReadiness] = - val (dir, store) = seededPrRepo() - val readinesses = new ConcurrentLinkedQueue[PrReadiness]() - val run = prRun( - dir, - store, - _ => (), - new ConcurrentLinkedQueue[String](), - availability = available, - prReadinesses = readinesses - ) - val agent = new StubSummariser().agent - requested match - case Some(r) => - val _ = openPrIfGitHub(agent, OpenFindings.empty, readiness = r)(using - run.context, - run.control, - summon[OutsideStage] - ) - case None => - val _ = openPrIfGitHub(agent, OpenFindings.empty)(using - run.context, - run.control, - summon[OutsideStage] - ) - readinesses.asScala.toList - assertEquals(readinessFor(None), List(PrReadiness.Ready)) - assertEquals( - readinessFor(Some(PrReadiness.Draft)), - List(PrReadiness.Draft) - ) - test("open findings reach the body of the PR this step opens"): // The step every code-producing built-in flow ends with, so the section // has to survive the best-effort path too, not only openPrFromBranch's. diff --git a/runner/src/test/scala/flowtests/FlowCompilesTest.scala b/runner/src/test/scala/flowtests/FlowCompilesTest.scala index 5b8b9a79e..30cd233be 100644 --- a/runner/src/test/scala/flowtests/FlowCompilesTest.scala +++ b/runner/src/test/scala/flowtests/FlowCompilesTest.scala @@ -353,6 +353,22 @@ object FlowCanary: case Left(e) => throw e case Right(_) => () + /** Draft PRs: `PrReadiness` resolves from `import orca.*` alone and both open + * calls take it. + */ + def draftPrSurface(): Unit = + flow(OrcaArgs()): + val _: Option[PrHandle] = openPrIfGitHub( + summarisingAgent = claude.haiku, + openFindings = OpenFindings.empty, + readiness = PrReadiness.Draft + ) + val _: PrHandle = openPrFromBranch( + summarisingAgent = claude.haiku, + openFindings = OpenFindings.empty, + readiness = PrReadiness.Draft + ) + /** Planning grid surface; exercised across `flows/`. Pins the full `mode × * operation` grid: every cell returns `WithChat[]` where the result * is `Plan` (`from`) or `Triage` (`triage`). diff --git a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala index 4b65720fa..0b684a9b6 100644 --- a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala +++ b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala @@ -428,8 +428,15 @@ class OsGitHubToolTest extends munit.FunSuite: .createPr("feat: hi", "hello", PrReadiness.Draft) .orThrow assertEquals(pr, samplePr) - assert(!cli.calls.exists(_.args.containsSlice(Seq("pr", "ready")))) - assert(!cli.calls.exists(_.args.containsSlice(Seq("pr", "edit")))) + // Only the create and the lookup reach gh: nothing edits the found PR. + assertEquals( + cli.calls.map(_.args.take(3)), + List( + Seq("git", "symbolic-ref", "--quiet"), + Seq("gh", "pr", "create"), + Seq("gh", "pr", "list") + ) + ) test("createPr parses the PR URL returned by gh"): val cli = createPrRunner( From 6b34a3110190ebf1a9330467833ec29c7b92532c Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 00:26:01 +0000 Subject: [PATCH 3/7] Test that openPrIfGitHub passes readiness on; use * varargs --- .../test/scala/orca/pr/OpenPrFromBranchTest.scala | 13 ++++++++++++- tools/src/main/scala/orca/tools/GitHubTool.scala | 4 ++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala index f8083e64e..814cbfc18 100644 --- a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala +++ b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala @@ -96,6 +96,7 @@ class OpenPrFromBranchTest extends FunSuite: store, _ => (), new ConcurrentLinkedQueue[String](), + availability = GitHubAvailability.Available("github.com", "acme", "w"), prReadinesses = readinesses ) import run.given @@ -129,7 +130,7 @@ class OpenPrFromBranchTest extends FunSuite: List("Push branch", "Generate PR title and description", "Open PR") ) - test("openPrFromBranch opens a ready PR by default and a draft on request"): + test("openPr* open a ready PR by default and a draft on request"): assertEquals( createPrReadinesses(agent => openPrFromBranch( @@ -149,6 +150,16 @@ class OpenPrFromBranchTest extends FunSuite: ), List(PrReadiness.Draft) ) + assertEquals( + createPrReadinesses(agent => + openPrIfGitHub( + summarisingAgent = agent, + openFindings = OpenFindings.empty, + readiness = PrReadiness.Draft + ) + ), + List(PrReadiness.Draft) + ) test("openPrFromBranch reached inside a stage is refused before it pushes"): // A helper taking only FlowControl carries OutsideStage past the compile diff --git a/tools/src/main/scala/orca/tools/GitHubTool.scala b/tools/src/main/scala/orca/tools/GitHubTool.scala index be4fec8e4..916198c53 100644 --- a/tools/src/main/scala/orca/tools/GitHubTool.scala +++ b/tools/src/main/scala/orca/tools/GitHubTool.scala @@ -498,8 +498,8 @@ private[orca] class OsGitHubTool( case PrReadiness.Draft => Seq("--draft") case PrReadiness.Ready => Nil val result = runGhResult( - Seq("pr", "create", "--title", title, "--body", body, "--head", head) ++ - draftFlag: _* + (Seq("pr", "create", "--title", title, "--body", body, "--head", head) ++ + draftFlag)* ) if result.exitCode == 0 then val output = result.stdout.trim From 165e59a5c4f05a05fae115051b337115b4304114 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 00:40:46 +0000 Subject: [PATCH 4/7] Add --draft flag that open PR calls follow by default --- docs/authoring/pull-requests.md | 7 +-- docs/using/shell.md | 1 + flow/src/main/scala/orca/FlowContext.scala | 7 ++- .../main/scala/orca/pr/openPrFromBranch.scala | 19 ++++--- .../main/scala/orca/pr/openPrIfGitHub.scala | 4 +- .../src/test/scala/orca/TestFlowContext.scala | 3 +- .../scala/orca/pr/OpenPrFromBranchTest.scala | 51 ++++++++++++------- flow/src/test/scala/orca/pr/PrFakes.scala | 6 ++- runner/src/main/scala/orca/OrcaArgs.scala | 9 +++- runner/src/main/scala/orca/RawArgs.scala | 21 ++++++-- runner/src/main/scala/orca/flowEntry.scala | 1 + .../orca/runner/DefaultFlowContext.scala | 3 +- .../scala/flowtests/FlowCompilesTest.scala | 6 +-- .../scala/orca/OrcaArgsPropertyTest.scala | 3 +- runner/src/test/scala/orca/OrcaArgsTest.scala | 10 ++++ 15 files changed, 105 insertions(+), 46 deletions(-) diff --git a/docs/authoring/pull-requests.md b/docs/authoring/pull-requests.md index f6b033596..391937a8e 100644 --- a/docs/authoring/pull-requests.md +++ b/docs/authoring/pull-requests.md @@ -41,9 +41,10 @@ body. Call them at the top level of the flow. ### Draft PRs -Both open calls take `readiness: PrReadiness = PrReadiness.Ready`. Pass -`PrReadiness.Draft` to open the PR as a draft. It applies only to a PR the run -opens: a PR already open for the branch is reused with its state unchanged. +Both open calls open a draft when the run gets `--draft`, and a ready PR +otherwise. A flow can override this with `readiness = Some(PrReadiness.Draft)` +or `Some(PrReadiness.Ready)`. It applies only to a PR the run opens: a PR +already open for the branch is reused with its state unchanged. ### Findings, title, body and context diff --git a/docs/using/shell.md b/docs/using/shell.md index 4c3cfad84..557725269 100644 --- a/docs/using/shell.md +++ b/docs/using/shell.md @@ -84,6 +84,7 @@ job. The commands are: | `--skip-branch` | continue on the current branch instead of creating one | | `--keep-changes` | leave uncommitted files in place instead of stashing them | | `--worktree` | run in a git worktree of this repository instead of the checkout | +| `--draft` | open the run's PR as a draft | | `--honor-pin` | use the flow's own pinned Orca version | | `--verbose` | print a stack trace on abort | diff --git a/flow/src/main/scala/orca/FlowContext.scala b/flow/src/main/scala/orca/FlowContext.scala index f7086a0a1..289c52b8a 100644 --- a/flow/src/main/scala/orca/FlowContext.scala +++ b/flow/src/main/scala/orca/FlowContext.scala @@ -3,7 +3,7 @@ package orca import orca.events.OrcaEvent import orca.tools.FsTool import orca.tools.{GitTool, RuntimeGit} -import orca.tools.GitHubTool +import orca.tools.{GitHubTool, PrReadiness} import orca.agents.Agent import orca.review.ReviewerCatalog @@ -71,5 +71,10 @@ trait FlowContext extends AgentSet: def userPrompt: String + /** How the run opens its PR when the flow does not say: `Draft` under + * `--draft`. + */ + def prReadiness: PrReadiness + /** The library's event sink. Flows use `display`, `fail` and `.announce`. */ private[orca] def emit(event: OrcaEvent): Unit diff --git a/flow/src/main/scala/orca/pr/openPrFromBranch.scala b/flow/src/main/scala/orca/pr/openPrFromBranch.scala index 14b13d7b6..adc97e15a 100644 --- a/flow/src/main/scala/orca/pr/openPrFromBranch.scala +++ b/flow/src/main/scala/orca/pr/openPrFromBranch.scala @@ -37,9 +37,10 @@ import ox.either.orThrow * findings it left unfixed. The same section is printed to the run output, * also when opening the PR fails. * - * `readiness` opens the PR as a draft with [[orca.tools.PrReadiness.Draft]]. - * It applies only to a PR this run opens: a PR that already exists for the - * branch is returned as it is. + * `readiness` says whether the PR opens as a draft; when omitted, the run's + * [[orca.FlowContext.prReadiness]] (`--draft`) decides. It applies only to a + * PR this run opens: a PR that already exists for the branch is returned as it + * is. * * `gh.createPr` is idempotent by head branch: a re-run that already opened the * PR gets the existing handle back rather than failing. Returns that handle, @@ -57,9 +58,13 @@ def openPrFromBranch( body: PrSummary => String = _.body, context: Option[String] = None, instructions: String = PrPrompts.Summarise, - readiness: PrReadiness = PrReadiness.Ready -)(using FlowContext, FlowControl, OutsideStage): PrHandle = - summon[FlowControl].assertAtFlowBody("openPrFromBranch(...)") + readiness: Option[PrReadiness] = None +)(using + ctx: FlowContext, + control: FlowControl, + outside: OutsideStage +): PrHandle = + control.assertAtFlowBody("openPrFromBranch(...)") reportOpenFindings(openFindings) // A refusal throws inside its stage, so it is never recorded and a resume // retries it. A recorded `Refused` is one [[openPrIfGitHub]] wrote. @@ -78,7 +83,7 @@ def openPrFromBranch( .createPr( title = title(summary), body = bodyWithOpenFindings(body(summary), openFindings), - readiness = readiness + readiness = readiness.getOrElse(ctx.prReadiness) ) .orThrow recordOpened(pr) diff --git a/flow/src/main/scala/orca/pr/openPrIfGitHub.scala b/flow/src/main/scala/orca/pr/openPrIfGitHub.scala index 7d4a2b3af..f93061948 100644 --- a/flow/src/main/scala/orca/pr/openPrIfGitHub.scala +++ b/flow/src/main/scala/orca/pr/openPrIfGitHub.scala @@ -46,7 +46,7 @@ def openPrIfGitHub( body: PrSummary => String = _.body, context: Option[String] = None, instructions: String = PrPrompts.Summarise, - readiness: PrReadiness = PrReadiness.Ready + readiness: Option[PrReadiness] = None )(using ctx: FlowContext, control: FlowControl, @@ -64,7 +64,7 @@ def openPrIfGitHub( body = summary => bodyWithOpenFindings(body(summary), openFindings), context = context, instructions = instructions, - readiness = readiness + readiness = readiness.getOrElse(ctx.prReadiness) ).fold(skipped, Some(_)) /** [[openPrFromBranch]]'s three stages, the two remote-facing legs under diff --git a/flow/src/test/scala/orca/TestFlowContext.scala b/flow/src/test/scala/orca/TestFlowContext.scala index a55a774f1..aa88564c2 100644 --- a/flow/src/test/scala/orca/TestFlowContext.scala +++ b/flow/src/test/scala/orca/TestFlowContext.scala @@ -13,7 +13,7 @@ import orca.agents.{ import orca.review.ReviewerCatalog import orca.tools.FsTool import orca.tools.RuntimeGit -import orca.tools.GitHubTool +import orca.tools.{GitHubTool, PrReadiness} /** FlowContext stub for unit-testing stage/fail and other helpers. Tool and * agent accessors are lazy so merely constructing the context doesn't throw; @@ -23,6 +23,7 @@ import orca.tools.GitHubTool class TestFlowContext( dispatcher: EventDispatcher, val userPrompt: String = "", + val prReadiness: PrReadiness = PrReadiness.Ready, val workDir: os.Path = orca.testkit.TempDirs.dir(), val stackSettings: StackSettings = StackSettings.empty, val reviewerCatalog: ReviewerCatalog = ReviewerCatalog.builtIn, diff --git a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala index 814cbfc18..f43d9f106 100644 --- a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala +++ b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala @@ -84,9 +84,9 @@ class OpenPrFromBranchTest extends FunSuite: ) /** The readiness each `gh.createPr` got while `open` ran over a fresh seeded - * repo, given a summariser to pass on. + * repo of a run with `runReadiness`, given a summariser to pass on. */ - private def createPrReadinesses( + private def createPrReadinesses(runReadiness: PrReadiness)( open: Agent[?] => (FlowContext, FlowControl) ?=> Any ): List[PrReadiness] = val (dir, store) = seededPrRepo() @@ -97,7 +97,8 @@ class OpenPrFromBranchTest extends FunSuite: _ => (), new ConcurrentLinkedQueue[String](), availability = GitHubAvailability.Available("github.com", "acme", "w"), - prReadinesses = readinesses + prReadinesses = readinesses, + runReadiness = runReadiness ) import run.given val _ = open(new StubSummariser().agent) @@ -130,35 +131,47 @@ class OpenPrFromBranchTest extends FunSuite: List("Push branch", "Generate PR title and description", "Open PR") ) - test("openPr* open a ready PR by default and a draft on request"): - assertEquals( - createPrReadinesses(agent => - openPrFromBranch( - summarisingAgent = agent, - openFindings = OpenFindings.empty - ) - ), - List(PrReadiness.Ready) - ) + test("both open calls follow the run's readiness by default"): + for runReadiness <- List(PrReadiness.Ready, PrReadiness.Draft) do + assertEquals( + createPrReadinesses(runReadiness)(agent => + openPrFromBranch( + summarisingAgent = agent, + openFindings = OpenFindings.empty + ) + ), + List(runReadiness) + ) + assertEquals( + createPrReadinesses(runReadiness)(agent => + openPrIfGitHub( + summarisingAgent = agent, + openFindings = OpenFindings.empty + ) + ), + List(runReadiness) + ) + + test("an explicit readiness overrides the run's"): assertEquals( - createPrReadinesses(agent => + createPrReadinesses(PrReadiness.Draft)(agent => openPrFromBranch( summarisingAgent = agent, openFindings = OpenFindings.empty, - readiness = PrReadiness.Draft + readiness = Some(PrReadiness.Ready) ) ), - List(PrReadiness.Draft) + List(PrReadiness.Ready) ) assertEquals( - createPrReadinesses(agent => + createPrReadinesses(PrReadiness.Draft)(agent => openPrIfGitHub( summarisingAgent = agent, openFindings = OpenFindings.empty, - readiness = PrReadiness.Draft + readiness = Some(PrReadiness.Ready) ) ), - List(PrReadiness.Draft) + List(PrReadiness.Ready) ) test("openPrFromBranch reached inside a stage is refused before it pushes"): diff --git a/flow/src/test/scala/orca/pr/PrFakes.scala b/flow/src/test/scala/orca/pr/PrFakes.scala index ef10587b5..10c5d8f7d 100644 --- a/flow/src/test/scala/orca/pr/PrFakes.scala +++ b/flow/src/test/scala/orca/pr/PrFakes.scala @@ -148,7 +148,7 @@ private[pr] def seededPrRepo( /** A run over `dir`/`store` whose `git`/`gh` record into `calls` and whose * events reach `listener`. `availability` is only reached by a helper that * probes, so it defaults to refusing. `prBodies` collects what `createPr` was - * given, for a test that reads it back. + * given, for a test that reads it back. `runReadiness` stands for `--draft`. */ private[pr] def prRun( dir: os.Path, @@ -163,7 +163,8 @@ private[pr] def prRun( prBodies: ConcurrentLinkedQueue[String] = new ConcurrentLinkedQueue[String](), prReadinesses: ConcurrentLinkedQueue[PrReadiness] = - new ConcurrentLinkedQueue[PrReadiness]() + new ConcurrentLinkedQueue[PrReadiness](), + runReadiness: PrReadiness = PrReadiness.Ready ): TestRun = TestRun( new TestFlowControl( @@ -174,6 +175,7 @@ private[pr] def prRun( new TestFlowContext( new EventDispatcher(List(listener)), "p", + prReadiness = runReadiness, wiredGit = Some( new RecordingGit(new OsGitTool(dir), calls, branchDiff, push, base) ), diff --git a/runner/src/main/scala/orca/OrcaArgs.scala b/runner/src/main/scala/orca/OrcaArgs.scala index 75b911ad6..fc8ea9e4c 100644 --- a/runner/src/main/scala/orca/OrcaArgs.scala +++ b/runner/src/main/scala/orca/OrcaArgs.scala @@ -2,13 +2,15 @@ package orca import mainargs.ParserForClass import orca.gitref.BranchName +import orca.tools.PrReadiness /** Parsed command-line arguments for the `orca` entry point. */ case class OrcaArgs( userPrompt: String = "", verbose: Boolean = false, target: RunTarget = RunTarget.NewBranch(Uncommitted.Stash), - branch: Option[BranchName] = None + branch: Option[BranchName] = None, + prReadiness: PrReadiness = PrReadiness.Ready ): /** The argv [[OrcaArgs.parse]] reads back as this value; the shell passes it * to a flow child. A `branch` with a [[RunTarget.CurrentBranch]] target @@ -32,7 +34,10 @@ case class OrcaArgs( Seq("--skip-branch", "--keep-changes") case RunTarget.Worktree => Seq("--worktree") val branchArgv = branch.toList.flatMap(name => Seq("--branch", name.value)) - promptArgv ++ verboseArgv ++ targetArgv ++ branchArgv + val draftArgv = prReadiness match + case PrReadiness.Draft => Seq("--draft") + case PrReadiness.Ready => Nil + promptArgv ++ verboseArgv ++ targetArgv ++ branchArgv ++ draftArgv object OrcaArgs: diff --git a/runner/src/main/scala/orca/RawArgs.scala b/runner/src/main/scala/orca/RawArgs.scala index 38ad5c662..8135653b8 100644 --- a/runner/src/main/scala/orca/RawArgs.scala +++ b/runner/src/main/scala/orca/RawArgs.scala @@ -3,6 +3,7 @@ package orca import mainargs.{Flag, ParserForClass, arg} import orca.gitref.BranchName import orca.progress.FeatureBranch +import orca.tools.PrReadiness /** The argv shape mainargs parses, shared by a flow's own argv * ([[OrcaArgs.parse]]) and `orca run`, so each option is declared once. It @@ -29,7 +30,9 @@ private[orca] case class RawArgs( @arg(doc = "name of the branch to create for this run (default: derived from the prompt); not with --skip-branch" ) - branch: Option[String] + branch: Option[String], + @arg(doc = "open the run's pull request as a draft") + draft: Flag ): /** Refuses a prompt given both positionally and with `--prompt`, an invalid * `--branch`, and a refused flag pair. @@ -51,7 +54,15 @@ private[orca] case class RawArgs( keepChanges = keepChanges.value, branch = branchName ) - yield CheckedArgs(givenPrompt, verbose.value, target, branchName) + prReadiness = + if draft.value then PrReadiness.Draft else PrReadiness.Ready + yield CheckedArgs( + givenPrompt, + verbose.value, + target, + branchName, + prReadiness + ) private[orca] object RawArgs: given ParserForClass[RawArgs] = ParserForClass[RawArgs] @@ -63,12 +74,14 @@ private[orca] case class CheckedArgs( givenPrompt: Option[String], verbose: Boolean, target: RunTarget, - branch: Option[BranchName] + branch: Option[BranchName], + prReadiness: PrReadiness ): def withPrompt(userPrompt: String): OrcaArgs = OrcaArgs( userPrompt = userPrompt, verbose = verbose, target = target, - branch = branch + branch = branch, + prReadiness = prReadiness ) diff --git a/runner/src/main/scala/orca/flowEntry.scala b/runner/src/main/scala/orca/flowEntry.scala index 6fc51f476..9e8f0e063 100644 --- a/runner/src/main/scala/orca/flowEntry.scala +++ b/runner/src/main/scala/orca/flowEntry.scala @@ -453,6 +453,7 @@ private def runInContext( ) val ctx = new DefaultFlowContext( userPrompt = args.userPrompt, + prReadiness = args.prReadiness, workDir = workDir, dispatcher = dispatcher, planningAgent = resolvedRoles.planning, diff --git a/runner/src/main/scala/orca/runner/DefaultFlowContext.scala b/runner/src/main/scala/orca/runner/DefaultFlowContext.scala index 5d166c62d..6023198a5 100644 --- a/runner/src/main/scala/orca/runner/DefaultFlowContext.scala +++ b/runner/src/main/scala/orca/runner/DefaultFlowContext.scala @@ -2,7 +2,7 @@ package orca.runner import orca.{FlowContext, StackSettings} import orca.review.ReviewerCatalog -import orca.tools.{FsTool, GitHubTool, RuntimeGit} +import orca.tools.{FsTool, GitHubTool, PrReadiness, RuntimeGit} import orca.agents.Agent import orca.events.{OrcaEvent, OrcaListener} @@ -13,6 +13,7 @@ import orca.events.{OrcaEvent, OrcaListener} */ private[orca] class DefaultFlowContext( val userPrompt: String, + val prReadiness: PrReadiness, val workDir: os.Path, dispatcher: OrcaListener, // The three role agents (ADR 0020), resolved by `runFlow`. diff --git a/runner/src/test/scala/flowtests/FlowCompilesTest.scala b/runner/src/test/scala/flowtests/FlowCompilesTest.scala index 30cd233be..267894392 100644 --- a/runner/src/test/scala/flowtests/FlowCompilesTest.scala +++ b/runner/src/test/scala/flowtests/FlowCompilesTest.scala @@ -354,19 +354,19 @@ object FlowCanary: case Right(_) => () /** Draft PRs: `PrReadiness` resolves from `import orca.*` alone and both open - * calls take it. + * calls take it, overriding `--draft`. */ def draftPrSurface(): Unit = flow(OrcaArgs()): val _: Option[PrHandle] = openPrIfGitHub( summarisingAgent = claude.haiku, openFindings = OpenFindings.empty, - readiness = PrReadiness.Draft + readiness = Some(PrReadiness.Draft) ) val _: PrHandle = openPrFromBranch( summarisingAgent = claude.haiku, openFindings = OpenFindings.empty, - readiness = PrReadiness.Draft + readiness = Some(PrReadiness.Draft) ) /** Planning grid surface; exercised across `flows/`. Pins the full `mode × diff --git a/runner/src/test/scala/orca/OrcaArgsPropertyTest.scala b/runner/src/test/scala/orca/OrcaArgsPropertyTest.scala index a41fc80cd..29e15390c 100644 --- a/runner/src/test/scala/orca/OrcaArgsPropertyTest.scala +++ b/runner/src/test/scala/orca/OrcaArgsPropertyTest.scala @@ -35,7 +35,8 @@ class OrcaArgsPropertyTest extends ScalaCheckSuite: target <- targetGen branch <- if target.skipBranch then Gen.const(None) else Gen.option(branchGen) - yield OrcaArgs(prompt, verbose, target, branch) + prReadiness <- Gen.oneOf(PrReadiness.values.toSeq) + yield OrcaArgs(prompt, verbose, target, branch, prReadiness) property("parse reads toArgv back as the same args"): forAll(argsGen): args => diff --git a/runner/src/test/scala/orca/OrcaArgsTest.scala b/runner/src/test/scala/orca/OrcaArgsTest.scala index a96a8e438..ff2368768 100644 --- a/runner/src/test/scala/orca/OrcaArgsTest.scala +++ b/runner/src/test/scala/orca/OrcaArgsTest.scala @@ -103,6 +103,16 @@ class OrcaArgsTest extends munit.FunSuite: case Left(msg) => assert(msg.nonEmpty) case Right(r) => fail(s"expected parse failure, got $r") + test("--draft makes the run's PR a draft; absent, a ready one"): + assertEquals( + OrcaArgs.parse(Seq("--draft", "x")).map(_.prReadiness), + Right(PrReadiness.Draft) + ) + assertEquals( + OrcaArgs.parse(Seq("x")).map(_.prReadiness), + Right(PrReadiness.Ready) + ) + test("--prompt carries a prompt that starts with '-'"): assertEquals( OrcaArgs.parse(Seq("--prompt", "- add X")).map(_.userPrompt), From c38349bd6f5aa5b93df8190f5f3441a3498300f3 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 05:03:49 +0000 Subject: [PATCH 5/7] Keep --draft on resume and for direct gh.createPr calls --- docs/api/tools.md | 2 +- docs/authoring/pull-requests.md | 6 +- flow/src/main/scala/orca/FlowContext.scala | 7 +- .../main/scala/orca/pr/openPrFromBranch.scala | 20 +++--- .../main/scala/orca/pr/openPrIfGitHub.scala | 6 +- .../scala/orca/progress/ProgressLog.scala | 7 +- .../src/test/scala/orca/TestFlowContext.scala | 3 +- .../scala/orca/pr/OpenPrFromBranchTest.scala | 64 +++++++++---------- flow/src/test/scala/orca/pr/PrFakes.scala | 12 ++-- runner/src/main/scala/orca/flowEntry.scala | 8 ++- .../orca/runner/DefaultFlowContext.scala | 3 +- .../scala/orca/runner/FlowLifecycle.scala | 3 +- .../scala/orca/runner/FlowLifecycleTest.scala | 26 +++++++- .../orca/shell/menu/ResumeDetector.scala | 7 +- .../main/scala/orca/shell/menu/RunMenu.scala | 3 +- .../orca/shell/menu/ResumeDetectorTest.scala | 17 ++++- .../scala/orca/shell/menu/RunMenuTest.scala | 11 ++++ skills/orca/SKILL.md | 1 + .../main/scala/orca/tools/GitHubTool.scala | 24 ++++--- .../scala/orca/testkit/StubGitHubTool.scala | 6 +- .../scala/orca/tools/OsGitHubToolTest.scala | 36 +++++++---- 21 files changed, 170 insertions(+), 102 deletions(-) diff --git a/docs/api/tools.md b/docs/api/tools.md index d912b87bc..f20e6f1e9 100644 --- a/docs/api/tools.md +++ b/docs/api/tools.md @@ -62,7 +62,7 @@ and `issue` an `IssueHandle`. |---|---| | `availability()` | probes whether a PR can be opened from this checkout | | `prHandle(ref)` | a `PrHandle` from a PR URL or `owner/repo#N`; `Either[String, PrHandle]` | -| `createPr(title, body, readiness?)` | opens a PR, as a draft with `PrReadiness.Draft`; `Either[PrCreateFailed, PrHandle]` | +| `createPr(title, body, readiness?)` | opens a PR, as a draft with `Some(PrReadiness.Draft)` or, when omitted, under `--draft`; `Either[PrCreateFailed, PrHandle]` | | `updatePr(pr, title, body)` | replaces a PR's title and body | | `readIssue(issue)`, `readIssueComments(issue)`, `readPrComments(pr)` | read an issue, its comments, or a PR's comments | | `writeComment(pr, body)`, `writeComment(issue, body)` | posts a comment | diff --git a/docs/authoring/pull-requests.md b/docs/authoring/pull-requests.md index 391937a8e..cf87b9199 100644 --- a/docs/authoring/pull-requests.md +++ b/docs/authoring/pull-requests.md @@ -41,9 +41,9 @@ body. Call them at the top level of the flow. ### Draft PRs -Both open calls open a draft when the run gets `--draft`, and a ready PR -otherwise. A flow can override this with `readiness = Some(PrReadiness.Draft)` -or `Some(PrReadiness.Ready)`. It applies only to a PR the run opens: a PR +Both open calls, like `gh.createPr`, open a draft when the run gets `--draft`, +and a ready PR otherwise. A flow can override this with +`readiness = Some(PrReadiness.Draft)` or `Some(PrReadiness.Ready)`. It applies only to a PR the run opens: a PR already open for the branch is reused with its state unchanged. ### Findings, title, body and context diff --git a/flow/src/main/scala/orca/FlowContext.scala b/flow/src/main/scala/orca/FlowContext.scala index 289c52b8a..f7086a0a1 100644 --- a/flow/src/main/scala/orca/FlowContext.scala +++ b/flow/src/main/scala/orca/FlowContext.scala @@ -3,7 +3,7 @@ package orca import orca.events.OrcaEvent import orca.tools.FsTool import orca.tools.{GitTool, RuntimeGit} -import orca.tools.{GitHubTool, PrReadiness} +import orca.tools.GitHubTool import orca.agents.Agent import orca.review.ReviewerCatalog @@ -71,10 +71,5 @@ trait FlowContext extends AgentSet: def userPrompt: String - /** How the run opens its PR when the flow does not say: `Draft` under - * `--draft`. - */ - def prReadiness: PrReadiness - /** The library's event sink. Flows use `display`, `fail` and `.announce`. */ private[orca] def emit(event: OrcaEvent): Unit diff --git a/flow/src/main/scala/orca/pr/openPrFromBranch.scala b/flow/src/main/scala/orca/pr/openPrFromBranch.scala index adc97e15a..9e4411fae 100644 --- a/flow/src/main/scala/orca/pr/openPrFromBranch.scala +++ b/flow/src/main/scala/orca/pr/openPrFromBranch.scala @@ -38,14 +38,12 @@ import ox.either.orThrow * also when opening the PR fails. * * `readiness` says whether the PR opens as a draft; when omitted, the run's - * [[orca.FlowContext.prReadiness]] (`--draft`) decides. It applies only to a - * PR this run opens: a PR that already exists for the branch is returned as it - * is. + * `--draft` decides. * * `gh.createPr` is idempotent by head branch: a re-run that already opened the - * PR gets the existing handle back rather than failing. Returns that handle, - * recorded as the run's published work through [[recordOpenedPr]] inside the - * create stage. + * PR gets the existing handle back, its draft state untouched, rather than + * failing. Returns that handle, recorded as the run's published work through + * [[recordOpenedPr]] inside the create stage. * * Refused inside a stage — at compile time where the call sits in a stage * body, at run time where it is reached through a helper: opening the PR is a @@ -60,11 +58,11 @@ def openPrFromBranch( instructions: String = PrPrompts.Summarise, readiness: Option[PrReadiness] = None )(using - ctx: FlowContext, - control: FlowControl, - outside: OutsideStage + FlowContext, + FlowControl, + OutsideStage ): PrHandle = - control.assertAtFlowBody("openPrFromBranch(...)") + summon[FlowControl].assertAtFlowBody("openPrFromBranch(...)") reportOpenFindings(openFindings) // A refusal throws inside its stage, so it is never recorded and a resume // retries it. A recorded `Refused` is one [[openPrIfGitHub]] wrote. @@ -83,7 +81,7 @@ def openPrFromBranch( .createPr( title = title(summary), body = bodyWithOpenFindings(body(summary), openFindings), - readiness = readiness.getOrElse(ctx.prReadiness) + readiness = readiness ) .orThrow recordOpened(pr) diff --git a/flow/src/main/scala/orca/pr/openPrIfGitHub.scala b/flow/src/main/scala/orca/pr/openPrIfGitHub.scala index f93061948..cc9b82d72 100644 --- a/flow/src/main/scala/orca/pr/openPrIfGitHub.scala +++ b/flow/src/main/scala/orca/pr/openPrIfGitHub.scala @@ -64,7 +64,7 @@ def openPrIfGitHub( body = summary => bodyWithOpenFindings(body(summary), openFindings), context = context, instructions = instructions, - readiness = readiness.getOrElse(ctx.prReadiness) + readiness = readiness ).fold(skipped, Some(_)) /** [[openPrFromBranch]]'s three stages, the two remote-facing legs under @@ -85,7 +85,7 @@ private def pushThenCreate( body: PrSummary => String, context: Option[String], instructions: String, - readiness: PrReadiness + readiness: Option[PrReadiness] )(using FlowContext, FlowControl): Either[String, PrHandle] = for push <- gatedStage(PushStage)(preFlight(base))(_ => pushBestEffort()) @@ -153,7 +153,7 @@ private def pushBestEffort()(using FlowContext, WorkspaceWrite): PushResult = private def createBestEffort( title: String, body: String, - readiness: PrReadiness + readiness: Option[PrReadiness] )(using FlowContext, FlowControl, diff --git a/flow/src/main/scala/orca/progress/ProgressLog.scala b/flow/src/main/scala/orca/progress/ProgressLog.scala index 68cf12b47..bd9eccec0 100644 --- a/flow/src/main/scala/orca/progress/ProgressLog.scala +++ b/flow/src/main/scala/orca/progress/ProgressLog.scala @@ -4,6 +4,7 @@ import com.github.plokhotnyuk.jsoniter_scala.core.JsonValueCodec import orca.StagePath import orca.gitref.{BranchName, CommitHash, Head} import orca.json.{JsonData, RawJson, given} +import orca.tools.PrReadiness /** Whether orca minted [[ProgressHeader.branch]] itself or bound to a * pre-existing one. Gates the throwaway-branch auto-delete @@ -25,7 +26,8 @@ enum BranchMode derives JsonData: * `userPrompt` is the full prompt: the shell's "Resume interrupted run" offer * relaunches the run byte-identically (ADR 0021 §3 amendment), and * `RecoveryCheck` refuses a log whose prompt is not the current one. `flow` is - * `None` for a run started outside the shell. + * `None` for a run started outside the shell. `prReadiness` is the run's + * `--draft`, which that relaunch passes on. * * `startingCommit` is the commit HEAD pointed at when the run bound its branch * — the diff base for a review of everything the whole run changed. @@ -38,7 +40,8 @@ case class ProgressHeader( branchMode: BranchMode, userPrompt: String, flow: Option[FlowSource], - startingCommit: CommitHash + startingCommit: CommitHash, + prReadiness: PrReadiness = PrReadiness.Ready ) derives JsonData: /** Where the run started, which a successful run may hand HEAD back to. */ def startingHead: Head = diff --git a/flow/src/test/scala/orca/TestFlowContext.scala b/flow/src/test/scala/orca/TestFlowContext.scala index aa88564c2..a55a774f1 100644 --- a/flow/src/test/scala/orca/TestFlowContext.scala +++ b/flow/src/test/scala/orca/TestFlowContext.scala @@ -13,7 +13,7 @@ import orca.agents.{ import orca.review.ReviewerCatalog import orca.tools.FsTool import orca.tools.RuntimeGit -import orca.tools.{GitHubTool, PrReadiness} +import orca.tools.GitHubTool /** FlowContext stub for unit-testing stage/fail and other helpers. Tool and * agent accessors are lazy so merely constructing the context doesn't throw; @@ -23,7 +23,6 @@ import orca.tools.{GitHubTool, PrReadiness} class TestFlowContext( dispatcher: EventDispatcher, val userPrompt: String = "", - val prReadiness: PrReadiness = PrReadiness.Ready, val workDir: os.Path = orca.testkit.TempDirs.dir(), val stackSettings: StackSettings = StackSettings.empty, val reviewerCatalog: ReviewerCatalog = ReviewerCatalog.builtIn, diff --git a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala index f43d9f106..705fb6a4c 100644 --- a/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala +++ b/flow/src/test/scala/orca/pr/OpenPrFromBranchTest.scala @@ -84,21 +84,20 @@ class OpenPrFromBranchTest extends FunSuite: ) /** The readiness each `gh.createPr` got while `open` ran over a fresh seeded - * repo of a run with `runReadiness`, given a summariser to pass on. + * repo, given a summariser to pass on. */ - private def createPrReadinesses(runReadiness: PrReadiness)( + private def createPrReadinesses( open: Agent[?] => (FlowContext, FlowControl) ?=> Any - ): List[PrReadiness] = + ): List[Option[PrReadiness]] = val (dir, store) = seededPrRepo() - val readinesses = new ConcurrentLinkedQueue[PrReadiness]() + val readinesses = new ConcurrentLinkedQueue[Option[PrReadiness]]() val run = prRun( dir, store, _ => (), new ConcurrentLinkedQueue[String](), availability = GitHubAvailability.Available("github.com", "acme", "w"), - prReadinesses = readinesses, - runReadiness = runReadiness + prReadinesses = readinesses ) import run.given val _ = open(new StubSummariser().agent) @@ -131,47 +130,46 @@ class OpenPrFromBranchTest extends FunSuite: List("Push branch", "Generate PR title and description", "Open PR") ) - test("both open calls follow the run's readiness by default"): - for runReadiness <- List(PrReadiness.Ready, PrReadiness.Draft) do - assertEquals( - createPrReadinesses(runReadiness)(agent => - openPrFromBranch( - summarisingAgent = agent, - openFindings = OpenFindings.empty - ) - ), - List(runReadiness) - ) - assertEquals( - createPrReadinesses(runReadiness)(agent => - openPrIfGitHub( - summarisingAgent = agent, - openFindings = OpenFindings.empty - ) - ), - List(runReadiness) - ) + test("both open calls leave the readiness to gh by default"): + assertEquals( + createPrReadinesses(agent => + openPrFromBranch( + summarisingAgent = agent, + openFindings = OpenFindings.empty + ) + ), + List(None) + ) + assertEquals( + createPrReadinesses(agent => + openPrIfGitHub( + summarisingAgent = agent, + openFindings = OpenFindings.empty + ) + ), + List(None) + ) - test("an explicit readiness overrides the run's"): + test("both open calls pass an explicit readiness on"): assertEquals( - createPrReadinesses(PrReadiness.Draft)(agent => + createPrReadinesses(agent => openPrFromBranch( summarisingAgent = agent, openFindings = OpenFindings.empty, - readiness = Some(PrReadiness.Ready) + readiness = Some(PrReadiness.Draft) ) ), - List(PrReadiness.Ready) + List(Some(PrReadiness.Draft)) ) assertEquals( - createPrReadinesses(PrReadiness.Draft)(agent => + createPrReadinesses(agent => openPrIfGitHub( summarisingAgent = agent, openFindings = OpenFindings.empty, - readiness = Some(PrReadiness.Ready) + readiness = Some(PrReadiness.Draft) ) ), - List(PrReadiness.Ready) + List(Some(PrReadiness.Draft)) ) test("openPrFromBranch reached inside a stage is refused before it pushes"): diff --git a/flow/src/test/scala/orca/pr/PrFakes.scala b/flow/src/test/scala/orca/pr/PrFakes.scala index 10c5d8f7d..47bd11e9f 100644 --- a/flow/src/test/scala/orca/pr/PrFakes.scala +++ b/flow/src/test/scala/orca/pr/PrFakes.scala @@ -64,7 +64,7 @@ private[pr] class RecordingGh( availabilityAnswer: => GitHubAvailability, createPrAnswer: => Either[PrCreateFailed, PrHandle] = Right(samplePr), bodies: ConcurrentLinkedQueue[String], - readinesses: ConcurrentLinkedQueue[PrReadiness] + readinesses: ConcurrentLinkedQueue[Option[PrReadiness]] ) extends StubGitHubTool: override def availability(): GitHubAvailability = calls.add("availability"): Unit @@ -72,7 +72,7 @@ private[pr] class RecordingGh( override def createPr( title: String, body: String, - readiness: PrReadiness + readiness: Option[PrReadiness] )(using WorkspaceWrite) = calls.add("createPr"): Unit readinesses.add(readiness): Unit @@ -148,7 +148,7 @@ private[pr] def seededPrRepo( /** A run over `dir`/`store` whose `git`/`gh` record into `calls` and whose * events reach `listener`. `availability` is only reached by a helper that * probes, so it defaults to refusing. `prBodies` collects what `createPr` was - * given, for a test that reads it back. `runReadiness` stands for `--draft`. + * given, for a test that reads it back. */ private[pr] def prRun( dir: os.Path, @@ -162,9 +162,8 @@ private[pr] def prRun( base: => Either[NoDefaultBase, String] = Right("main"), prBodies: ConcurrentLinkedQueue[String] = new ConcurrentLinkedQueue[String](), - prReadinesses: ConcurrentLinkedQueue[PrReadiness] = - new ConcurrentLinkedQueue[PrReadiness](), - runReadiness: PrReadiness = PrReadiness.Ready + prReadinesses: ConcurrentLinkedQueue[Option[PrReadiness]] = + new ConcurrentLinkedQueue[Option[PrReadiness]]() ): TestRun = TestRun( new TestFlowControl( @@ -175,7 +174,6 @@ private[pr] def prRun( new TestFlowContext( new EventDispatcher(List(listener)), "p", - prReadiness = runReadiness, wiredGit = Some( new RecordingGit(new OsGitTool(dir), calls, branchDiff, push, base) ), diff --git a/runner/src/main/scala/orca/flowEntry.scala b/runner/src/main/scala/orca/flowEntry.scala index 9e8f0e063..0da0b848c 100644 --- a/runner/src/main/scala/orca/flowEntry.scala +++ b/runner/src/main/scala/orca/flowEntry.scala @@ -351,7 +351,12 @@ private def runLogged(request: RunRequest, runLog: RunEventLog)( val runtimeGit = wiring.git.getOrElse(new OsGitTool(workDir, dispatcher)) val ghTool = wiring.gh.getOrElse( - new OsGitHubTool(OsProcCliRunner, workDir, events = dispatcher) + new OsGitHubTool( + OsProcCliRunner, + workDir, + events = dispatcher, + defaultReadiness = request.args.prReadiness + ) ) val fsTool = wiring.fs.getOrElse(new OsFsTool(workDir)) runInContext( @@ -453,7 +458,6 @@ private def runInContext( ) val ctx = new DefaultFlowContext( userPrompt = args.userPrompt, - prReadiness = args.prReadiness, workDir = workDir, dispatcher = dispatcher, planningAgent = resolvedRoles.planning, diff --git a/runner/src/main/scala/orca/runner/DefaultFlowContext.scala b/runner/src/main/scala/orca/runner/DefaultFlowContext.scala index 6023198a5..5d166c62d 100644 --- a/runner/src/main/scala/orca/runner/DefaultFlowContext.scala +++ b/runner/src/main/scala/orca/runner/DefaultFlowContext.scala @@ -2,7 +2,7 @@ package orca.runner import orca.{FlowContext, StackSettings} import orca.review.ReviewerCatalog -import orca.tools.{FsTool, GitHubTool, PrReadiness, RuntimeGit} +import orca.tools.{FsTool, GitHubTool, RuntimeGit} import orca.agents.Agent import orca.events.{OrcaEvent, OrcaListener} @@ -13,7 +13,6 @@ import orca.events.{OrcaEvent, OrcaListener} */ private[orca] class DefaultFlowContext( val userPrompt: String, - val prReadiness: PrReadiness, val workDir: os.Path, dispatcher: OrcaListener, // The three role agents (ADR 0020), resolved by `runFlow`. diff --git a/runner/src/main/scala/orca/runner/FlowLifecycle.scala b/runner/src/main/scala/orca/runner/FlowLifecycle.scala index 4d8aeda50..ea85b64ad 100644 --- a/runner/src/main/scala/orca/runner/FlowLifecycle.scala +++ b/runner/src/main/scala/orca/runner/FlowLifecycle.scala @@ -434,7 +434,8 @@ object FlowLifecycle: else BranchMode.Created, userPrompt = args.userPrompt, flow = flowSource, - startingCommit = headAtBinding + startingCommit = headAtBinding, + prReadiness = args.prReadiness ) ) git.forceCommitOnly(store.path, "orca: progress log") diff --git a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala index 118857cc7..c37510a11 100644 --- a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala +++ b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala @@ -35,6 +35,7 @@ import orca.runner.terminal.TerminalInteraction import orca.tools.{ GitHubAvailability, GitHubTool, + OsGitHubTool, OsGitTool, PrReadiness, RuntimeGit, @@ -1779,7 +1780,8 @@ class FlowLifecycleTest extends munit.FunSuite: claude: ClaudeAgent = StubAgent.claude, gh: Option[GitHubTool] = None, git: Option[RuntimeGit] = None, - target: RunTarget = RunTarget.NewBranch(Uncommitted.Stash) + target: RunTarget = RunTarget.NewBranch(Uncommitted.Stash), + prReadiness: PrReadiness = PrReadiness.Ready )(body: (orca.FlowContext, orca.FlowControl) ?=> Unit): Unit = supervised: val interaction = TerminalInteraction.start( @@ -1789,7 +1791,7 @@ class FlowLifecycleTest extends munit.FunSuite: ) runFlow( FlowHarness.request( - args = OrcaArgs(prompt, target = target), + args = OrcaArgs(prompt, target = target, prReadiness = prReadiness), stackSettings = Some(StackSettings.empty), wiring = FlowWiring(claude = Some(_ => claude), gh = gh, git = git), workDir = workDir, @@ -1799,6 +1801,24 @@ class FlowLifecycleTest extends munit.FunSuite: ) )(body) + test("--draft becomes the default readiness of the run's gh"): + val workDir = GitRepo.seeded() + var seen: Option[PrReadiness] = None + runFlowForTest(workDir, "draft-gh", prReadiness = PrReadiness.Draft): + seen = summon[orca.FlowContext].gh match + case tool: OsGitHubTool => Some(tool.defaultReadiness) + case _ => None + assertEquals(seen, Some(PrReadiness.Draft)) + + test("--draft is recorded in the run's progress header"): + val workDir = GitRepo.seeded() + val prompt = "draft-header" + val store = ProgressStore.default(workDir, RunKey.of(prompt)) + var seen: Option[PrReadiness] = None + runFlowForTest(workDir, prompt, prReadiness = PrReadiness.Draft): + seen = store.load().map(_.header.prReadiness) + assertEquals(seen, Some(PrReadiness.Draft)) + test( "teardownSuccess is best-effort: a non-missing-file removal error does not fail an otherwise-successful run" ): @@ -1959,7 +1979,7 @@ class FlowLifecycleTest extends munit.FunSuite: override def createPr( title: String, body: String, - readiness: PrReadiness + readiness: Option[PrReadiness] )(using WorkspaceWrite) = Right(handoffPr) diff --git a/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala b/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala index 580fe1c69..12094d817 100644 --- a/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala +++ b/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala @@ -4,6 +4,7 @@ import orca.gitref.BranchName import orca.json.JsonFile import orca.progress.{FlowSource, ProgressHeader, ProgressLog, ProgressScan} import orca.shell.actions.FlowResolution +import orca.tools.PrReadiness /** An unfinished flow run, byte-identically relaunchable: the flow script and * the exact prompt that started it (ADR 0021 §3 amendment). @@ -20,7 +21,8 @@ private[menu] case class InterruptedRun( */ dir: os.Path, /** The progress log itself, under `dir`. */ - log: os.Path + log: os.Path, + prReadiness: PrReadiness = PrReadiness.Ready ) /** Detects an interrupted run for the main menu's "Resume interrupted run" @@ -69,7 +71,8 @@ private[menu] object ResumeDetector: header.userPrompt, header.branch, dir = found.dir, - log = found.path + log = found.path, + prReadiness = header.prReadiness ) ) diff --git a/shell/src/main/scala/orca/shell/menu/RunMenu.scala b/shell/src/main/scala/orca/shell/menu/RunMenu.scala index 05cd84ffc..27df05ccb 100644 --- a/shell/src/main/scala/orca/shell/menu/RunMenu.scala +++ b/shell/src/main/scala/orca/shell/menu/RunMenu.scala @@ -131,7 +131,8 @@ private[menu] object RunMenu: userPrompt = run.userPrompt, verbose = false, target = DefaultRunTarget, - branch = None + branch = None, + prReadiness = run.prReadiness ) launch( PinPolicy.Force(FallbackPolicy.Ask(ui)), diff --git a/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala b/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala index bbfb94fc5..fcf9cab8b 100644 --- a/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala +++ b/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala @@ -4,6 +4,7 @@ import orca.{OrcaDir, RunKey, WorkspaceWrite} import orca.gitref.CommitHash import orca.progress.{BranchMode, FlowSource, ProgressHeader, ProgressStore} import orca.testkit.{TempDirs, branchName} +import orca.tools.PrReadiness class ResumeDetectorTest extends munit.FunSuite: @@ -13,7 +14,8 @@ class ResumeDetectorTest extends munit.FunSuite: private def header( userPrompt: String = "fix the flaky test", - flow: Option[FlowSource] = Some(FlowSource.Catalog("implement.sc")) + flow: Option[FlowSource] = Some(FlowSource.Catalog("implement.sc")), + prReadiness: PrReadiness = PrReadiness.Ready ): ProgressHeader = ProgressHeader( startingBranch = Some(branchName("main")), @@ -21,7 +23,8 @@ class ResumeDetectorTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = userPrompt, flow = flow, - startingCommit = CommitHash.from("0" * 40).get + startingCommit = CommitHash.from("0" * 40).get, + prReadiness = prReadiness ) // The scan's own guards (symlinked log file, symlinked or missing `.orca`) @@ -49,6 +52,16 @@ class ResumeDetectorTest extends munit.FunSuite: ) ) + test("detect carries the run's recorded readiness"): + val workDir = TempDirs.dir() + ProgressStore + .default(workDir, RunKey.of("fix the flaky test")) + .writeHeader(header(prReadiness = PrReadiness.Draft)) + assertEquals( + ResumeDetector.detect(List(workDir)).map(_.prReadiness), + Some(PrReadiness.Draft) + ) + test( "detect is None for a log with no recorded flow (a run outside the shell)" ): diff --git a/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala b/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala index 4fef51bef..53e32ad9d 100644 --- a/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala +++ b/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala @@ -8,6 +8,7 @@ import orca.shell.flows.DiscoveredFlow import orca.shell.run.{LaunchResult, LaunchedFlow} import orca.shell.ui.UiOutcome import orca.testkit.{TempDirs, branchName} +import orca.tools.PrReadiness import MenuFixtures.{captured, withDumbTerminal} @@ -205,6 +206,16 @@ class RunMenuTest extends munit.FunSuite: Some("fix the flaky test\nwith detail") ) + test("resumeInterruptedRun: launches with the recorded readiness"): + val workDir = TempDirs.dir() + val _ = projectFlow(workDir, "resume-flow.sc") + val launch = resumed( + interrupted(FlowSource.Catalog("resume-flow.sc"), workDir) + .copy(prReadiness = PrReadiness.Draft), + workDir + ) + assertEquals(launch.map(_._2.prReadiness), Some(PrReadiness.Draft)) + test( "resumeInterruptedRun: the run happens in the directory its log was found in, with the default target" ): diff --git a/skills/orca/SKILL.md b/skills/orca/SKILL.md index dd852b436..5eb7f340a 100644 --- a/skills/orca/SKILL.md +++ b/skills/orca/SKILL.md @@ -107,6 +107,7 @@ Add these flags only if the user asks: this shell's. - `--branch ` — create the run's branch under this name. Refused with `--skip-branch`, and when the branch already exists. +- `--draft` — open the run's PR as a draft. ## After it runs diff --git a/tools/src/main/scala/orca/tools/GitHubTool.scala b/tools/src/main/scala/orca/tools/GitHubTool.scala index 916198c53..58d064240 100644 --- a/tools/src/main/scala/orca/tools/GitHubTool.scala +++ b/tools/src/main/scala/orca/tools/GitHubTool.scala @@ -3,6 +3,7 @@ package orca.tools import com.github.plokhotnyuk.jsoniter_scala.core.readFromString import orca.{OrcaFlowException, WorkspaceWrite} import orca.events.{OrcaEvent, OrcaListener} +import orca.json.JsonData import orca.subprocess.{CliResult, CliRunner} import orca.util.TextUtil import ox.sleep @@ -126,7 +127,7 @@ enum CheckState: /** Whether [[GitHubTool.createPr]] opens a PR ready for review or as a draft. */ -enum PrReadiness: +enum PrReadiness derives JsonData: case Ready case Draft @@ -224,14 +225,15 @@ trait GitHubTool: * detached HEAD, on gh output carrying no PR URL, and on system failures * (auth, network). * - * `readiness` applies only to a PR this call opens (`gh pr create --draft`). - * When an open PR already exists for the branch, it is returned as it is: - * neither draft nor ready state is changed. + * `readiness` applies only to a PR this call opens (`gh pr create --draft`); + * when omitted, the run's `--draft` decides. When an open PR already exists + * for the branch, it is returned as it is: neither draft nor ready state is + * changed. */ def createPr( title: String, body: String, - readiness: PrReadiness = PrReadiness.Ready + readiness: Option[PrReadiness] = None )(using WorkspaceWrite ): Either[PrCreateFailed, PrHandle] @@ -310,13 +312,17 @@ trait GitHubTool: * * `events` lets the tool publish a [[OrcaEvent.Step]] when a PR is opened so * the URL surfaces in the event log. Defaults to `OrcaListener.noop`. + * + * `defaultReadiness` is what a [[createPr]] without a `readiness` opens: the + * run's `--draft`. */ private[orca] class OsGitHubTool( cli: CliRunner, workDir: os.Path = os.pwd, pollInterval: FiniteDuration = 30.seconds, events: OrcaListener = OrcaListener.noop, - readRetry: Schedule = OsGitHubTool.defaultReadRetry + readRetry: Schedule = OsGitHubTool.defaultReadRetry, + private[orca] val defaultReadiness: PrReadiness = PrReadiness.Ready ) extends GitHubTool: import OsGitHubTool.* @@ -480,8 +486,8 @@ private[orca] class OsGitHubTool( ) catch case NonFatal(e) => Left(cannotRunGh(e)) - def createPr(title: String, body: String, readiness: PrReadiness)(using - ws: WorkspaceWrite + def createPr(title: String, body: String, readiness: Option[PrReadiness])( + using ws: WorkspaceWrite ): Either[PrCreateFailed, PrHandle] = ws.check("gh.createPr") // Inspect exit code + stderr ourselves to split the recoverable failures @@ -494,7 +500,7 @@ private[orca] class OsGitHubTool( // its progress log would be misread as "branch not pushed". Bare branch // name, no `owner:` prefix, matching `findOpenPr`. val head = headBranchForPr() - val draftFlag = readiness match + val draftFlag = readiness.getOrElse(defaultReadiness) match case PrReadiness.Draft => Seq("--draft") case PrReadiness.Ready => Nil val result = runGhResult( diff --git a/tools/src/test/scala/orca/testkit/StubGitHubTool.scala b/tools/src/test/scala/orca/testkit/StubGitHubTool.scala index c34759aad..590bf9c1f 100644 --- a/tools/src/test/scala/orca/testkit/StubGitHubTool.scala +++ b/tools/src/test/scala/orca/testkit/StubGitHubTool.scala @@ -25,7 +25,11 @@ class StubGitHubTool extends GitHubTool: throw new NotImplementedError(s"$endpoint is not wired in this stub") def availability(): GitHubAvailability = nyi("availability") - def createPr(title: String, body: String, readiness: PrReadiness)(using + def createPr( + title: String, + body: String, + readiness: Option[PrReadiness] + )(using WorkspaceWrite ): Either[PrCreateFailed, PrHandle] = nyi("createPr") def updatePr(pr: PrHandle, title: String, body: String)(using diff --git a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala index 0b684a9b6..72c249c69 100644 --- a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala +++ b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala @@ -403,17 +403,31 @@ class OsGitHubToolTest extends munit.FunSuite: assert(args.containsSlice(Seq("--body", "hello"))) assert(args.containsSlice(Seq("--head", "feat"))) + /** The `gh pr create` argv of a tool with `defaultReadiness`. */ + private def createArgs( + defaultReadiness: PrReadiness, + readiness: Option[PrReadiness] + ): Seq[String] = + val cli = createPrRunner( + CliResult(0, "https://github.com/acme/widgets/pull/42\n", "") + ) + val _ = new OsGitHubTool(cli, defaultReadiness = defaultReadiness) + .createPr("feat: hi", "hello", readiness) + .orThrow + cli.calls.last.args + test("createPr passes --draft only for a draft PR"): - def createArgs(readiness: PrReadiness): Seq[String] = - val cli = createPrRunner( - CliResult(0, "https://github.com/acme/widgets/pull/42\n", "") - ) - val _ = new OsGitHubTool(cli) - .createPr("feat: hi", "hello", readiness) - .orThrow - cli.calls.last.args - assert(createArgs(PrReadiness.Draft).contains("--draft")) - assert(!createArgs(PrReadiness.Ready).contains("--draft")) + assert( + createArgs(PrReadiness.Ready, Some(PrReadiness.Draft)).contains("--draft") + ) + assert( + !createArgs(PrReadiness.Draft, Some(PrReadiness.Ready)) + .contains("--draft") + ) + + test("createPr without a readiness opens the tool's default"): + assert(createArgs(PrReadiness.Draft, None).contains("--draft")) + assert(!createArgs(PrReadiness.Ready, None).contains("--draft")) test("createPr leaves an existing PR alone, whatever the readiness"): val cli = createPrRunner( @@ -425,7 +439,7 @@ class OsGitHubToolTest extends munit.FunSuite: ) ) val pr = new OsGitHubTool(cli) - .createPr("feat: hi", "hello", PrReadiness.Draft) + .createPr("feat: hi", "hello", Some(PrReadiness.Draft)) .orThrow assertEquals(pr, samplePr) // Only the create and the lookup reach gh: nothing edits the found PR. From d0209cd66c059d873746882fe87948dad2ea0635 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 17:47:33 +0000 Subject: [PATCH 6/7] Resume keeps the run's readiness; apply it to any gh --- docs/using/run-lifecycle.md | 3 +- .../main/scala/orca/pr/openPrFromBranch.scala | 6 +- .../scala/orca/progress/ProgressLog.scala | 6 +- .../src/test/scala/orca/FlowSessionTest.scala | 8 +- flow/src/test/scala/orca/TestRun.scala | 5 +- flow/src/test/scala/orca/pr/PrFakes.scala | 3 +- .../scala/orca/progress/ProgressLogTest.scala | 5 +- .../orca/progress/ProgressScanTest.scala | 5 +- .../orca/progress/ProgressStoreTest.scala | 5 +- .../orca/progress/RecoveryCheckTest.scala | 14 +- runner/src/main/scala/orca/flowEntry.scala | 10 +- .../scala/orca/runner/FlowLifecycle.scala | 20 ++- .../scala/orca/runner/RunGitHubTool.scala | 20 +++ .../scala/orca/runner/FlowLifecycleTest.scala | 127 +++++++++++++----- .../scala/orca/runner/RunGitHubToolTest.scala | 42 ++++++ .../orca/shell/menu/ResumeDetector.scala | 7 +- .../main/scala/orca/shell/menu/RunMenu.scala | 3 +- .../orca/shell/menu/ResumeDetectorTest.scala | 18 +-- .../scala/orca/shell/menu/RunMenuTest.scala | 11 -- .../main/scala/orca/tools/GitHubTool.scala | 8 +- .../scala/orca/tools/OsGitHubToolTest.scala | 35 ++--- 21 files changed, 230 insertions(+), 131 deletions(-) create mode 100644 runner/src/main/scala/orca/runner/RunGitHubTool.scala create mode 100644 runner/src/test/scala/orca/runner/RunGitHubToolTest.scala diff --git a/docs/using/run-lifecycle.md b/docs/using/run-lifecycle.md index f5832f0f7..c55a08d4e 100644 --- a/docs/using/run-lifecycle.md +++ b/docs/using/run-lifecycle.md @@ -88,7 +88,8 @@ A few things are worth knowing: A re-run with the same prompt finds the progress log and resumes from the first incomplete stage. If `--branch` names a different branch than the one in the -log, the run is refused. On resume, Orca prints which branch the run is bound +log, the run is refused. A resume keeps the first attempt's `--draft`, whatever +this attempt passes. On resume, Orca prints which branch the run is bound to, how many stages are already done, and that the interrupted stage's uncommitted work was dropped. Each [durable session](../authoring/talking-to-agents.md) that is resumed gets the diff --git a/flow/src/main/scala/orca/pr/openPrFromBranch.scala b/flow/src/main/scala/orca/pr/openPrFromBranch.scala index 9e4411fae..fbe3fa838 100644 --- a/flow/src/main/scala/orca/pr/openPrFromBranch.scala +++ b/flow/src/main/scala/orca/pr/openPrFromBranch.scala @@ -57,11 +57,7 @@ def openPrFromBranch( context: Option[String] = None, instructions: String = PrPrompts.Summarise, readiness: Option[PrReadiness] = None -)(using - FlowContext, - FlowControl, - OutsideStage -): PrHandle = +)(using FlowContext, FlowControl, OutsideStage): PrHandle = summon[FlowControl].assertAtFlowBody("openPrFromBranch(...)") reportOpenFindings(openFindings) // A refusal throws inside its stage, so it is never recorded and a resume diff --git a/flow/src/main/scala/orca/progress/ProgressLog.scala b/flow/src/main/scala/orca/progress/ProgressLog.scala index bd9eccec0..fbf807859 100644 --- a/flow/src/main/scala/orca/progress/ProgressLog.scala +++ b/flow/src/main/scala/orca/progress/ProgressLog.scala @@ -26,8 +26,8 @@ enum BranchMode derives JsonData: * `userPrompt` is the full prompt: the shell's "Resume interrupted run" offer * relaunches the run byte-identically (ADR 0021 §3 amendment), and * `RecoveryCheck` refuses a log whose prompt is not the current one. `flow` is - * `None` for a run started outside the shell. `prReadiness` is the run's - * `--draft`, which that relaunch passes on. + * `None` for a run started outside the shell. `prReadiness` is the first + * attempt's `--draft`, which every resume keeps. * * `startingCommit` is the commit HEAD pointed at when the run bound its branch * — the diff base for a review of everything the whole run changed. @@ -41,7 +41,7 @@ case class ProgressHeader( userPrompt: String, flow: Option[FlowSource], startingCommit: CommitHash, - prReadiness: PrReadiness = PrReadiness.Ready + prReadiness: PrReadiness ) derives JsonData: /** Where the run started, which a successful run may hand HEAD back to. */ def startingHead: Head = diff --git a/flow/src/test/scala/orca/FlowSessionTest.scala b/flow/src/test/scala/orca/FlowSessionTest.scala index 784b3ddb0..659500e7d 100644 --- a/flow/src/test/scala/orca/FlowSessionTest.scala +++ b/flow/src/test/scala/orca/FlowSessionTest.scala @@ -1,5 +1,7 @@ package orca +import orca.tools.PrReadiness + import munit.FunSuite import orca.backend.{ AgentResult, @@ -232,7 +234,8 @@ class FlowSessionTest extends FunSuite: BranchMode.Created, userPrompt = "p", flow = None, - startingCommit = orca.gitref.CommitHash.from("0" * 40).get + startingCommit = orca.gitref.CommitHash.from("0" * 40).get, + prReadiness = PrReadiness.Ready ) ) for record <- sessions do sessionStore.upsert(record) @@ -754,7 +757,8 @@ class FlowSessionTest extends FunSuite: BranchMode.Created, userPrompt = "p", flow = None, - startingCommit = orca.gitref.CommitHash.from("0" * 40).get + startingCommit = orca.gitref.CommitHash.from("0" * 40).get, + prReadiness = PrReadiness.Ready ) ) sessionStore.upsert( diff --git a/flow/src/test/scala/orca/TestRun.scala b/flow/src/test/scala/orca/TestRun.scala index d8a6491d7..990f60365 100644 --- a/flow/src/test/scala/orca/TestRun.scala +++ b/flow/src/test/scala/orca/TestRun.scala @@ -1,5 +1,7 @@ package orca +import orca.tools.PrReadiness + import orca.agents.{Agent, BackendTag} import orca.events.EventDispatcher import orca.progress.{BranchMode, ProgressHeader, ProgressStore} @@ -47,7 +49,8 @@ object TestRun: BranchMode.Created, userPrompt = userPrompt, flow = None, - startingCommit = headCommit + startingCommit = headCommit, + prReadiness = PrReadiness.Ready ) ) val context = new TestFlowContext( diff --git a/flow/src/test/scala/orca/pr/PrFakes.scala b/flow/src/test/scala/orca/pr/PrFakes.scala index 47bd11e9f..e7780c87b 100644 --- a/flow/src/test/scala/orca/pr/PrFakes.scala +++ b/flow/src/test/scala/orca/pr/PrFakes.scala @@ -140,7 +140,8 @@ private[pr] def seededPrRepo( branchMode, userPrompt = "p", flow = None, - startingCommit = startedAt + startingCommit = startedAt, + prReadiness = PrReadiness.Ready ) ) (dir, store) diff --git a/flow/src/test/scala/orca/progress/ProgressLogTest.scala b/flow/src/test/scala/orca/progress/ProgressLogTest.scala index 79af7d0dc..71128e950 100644 --- a/flow/src/test/scala/orca/progress/ProgressLogTest.scala +++ b/flow/src/test/scala/orca/progress/ProgressLogTest.scala @@ -1,5 +1,7 @@ package orca.progress +import orca.tools.PrReadiness + import com.github.plokhotnyuk.jsoniter_scala.core.{ JsonReaderException, readFromString, @@ -28,7 +30,8 @@ class ProgressLogTest extends FunSuite: userPrompt = "fix the flaky test", flow = flow, startingCommit = - CommitHash.from("0badc0ffee0ddf00d1234567890abcdef1234567").get + CommitHash.from("0badc0ffee0ddf00d1234567890abcdef1234567").get, + prReadiness = PrReadiness.Ready ) test("ProgressLog round-trips through JsonData codec"): diff --git a/flow/src/test/scala/orca/progress/ProgressScanTest.scala b/flow/src/test/scala/orca/progress/ProgressScanTest.scala index ecb40faff..4f142feaa 100644 --- a/flow/src/test/scala/orca/progress/ProgressScanTest.scala +++ b/flow/src/test/scala/orca/progress/ProgressScanTest.scala @@ -1,5 +1,7 @@ package orca.progress +import orca.tools.PrReadiness + import munit.FunSuite import orca.{RunKey, WorkspaceWrite} import orca.gitref.CommitHash @@ -17,7 +19,8 @@ class ProgressScanTest extends FunSuite: branchMode = BranchMode.Created, userPrompt = "my prompt", flow = None, - startingCommit = CommitHash.from("0" * 40).get + startingCommit = CommitHash.from("0" * 40).get, + prReadiness = PrReadiness.Ready ) test("progressLogPaths is empty when .orca doesn't exist"): diff --git a/flow/src/test/scala/orca/progress/ProgressStoreTest.scala b/flow/src/test/scala/orca/progress/ProgressStoreTest.scala index 50f7bc4a7..c4a973214 100644 --- a/flow/src/test/scala/orca/progress/ProgressStoreTest.scala +++ b/flow/src/test/scala/orca/progress/ProgressStoreTest.scala @@ -1,5 +1,7 @@ package orca.progress +import orca.tools.PrReadiness + import munit.FunSuite import orca.{RunKey, StagePath, WorkspaceWrite} import orca.gitref.CommitHash @@ -17,7 +19,8 @@ class ProgressStoreTest extends FunSuite: branchMode = BranchMode.Created, userPrompt = "my prompt", flow = None, - startingCommit = CommitHash.from("0" * 40).get + startingCommit = CommitHash.from("0" * 40).get, + prReadiness = PrReadiness.Ready ) test("writeHeader then load returns the header with empty entries"): diff --git a/flow/src/test/scala/orca/progress/RecoveryCheckTest.scala b/flow/src/test/scala/orca/progress/RecoveryCheckTest.scala index 947096776..2d73b8204 100644 --- a/flow/src/test/scala/orca/progress/RecoveryCheckTest.scala +++ b/flow/src/test/scala/orca/progress/RecoveryCheckTest.scala @@ -1,5 +1,7 @@ package orca.progress +import orca.tools.PrReadiness + import munit.FunSuite import orca.gitref.CommitHash import orca.testkit.branchName @@ -17,7 +19,8 @@ class RecoveryCheckTest extends FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = testCommit + startingCommit = testCommit, + prReadiness = PrReadiness.Ready ) assert( RecoveryCheck.validateHeader(header, prompt, Set.empty).isLeft, @@ -34,7 +37,8 @@ class RecoveryCheckTest extends FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = testCommit + startingCommit = testCommit, + prReadiness = PrReadiness.Ready ) val rejected = RecoveryCheck.validateHeader(header, prompt, Set("trunk")) assert( @@ -64,7 +68,8 @@ class RecoveryCheckTest extends FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = testCommit + startingCommit = testCommit, + prReadiness = PrReadiness.Ready ) assertEquals( RecoveryCheck.validateHeader(header, prompt, Set.empty).map(_.value), @@ -78,7 +83,8 @@ class RecoveryCheckTest extends FunSuite: branchMode = BranchMode.Created, userPrompt = "a different prompt", flow = None, - startingCommit = testCommit + startingCommit = testCommit, + prReadiness = PrReadiness.Ready ) assert( RecoveryCheck.validateHeader(header, "do the thing", Set.empty).isLeft diff --git a/runner/src/main/scala/orca/flowEntry.scala b/runner/src/main/scala/orca/flowEntry.scala index 0da0b848c..53519092d 100644 --- a/runner/src/main/scala/orca/flowEntry.scala +++ b/runner/src/main/scala/orca/flowEntry.scala @@ -34,6 +34,7 @@ import orca.runner.{ OrcaLog, RoleAgents, RoleOverrides, + RunGitHubTool, RunLogStarter, RunRequest, SetupOptions, @@ -351,12 +352,7 @@ private def runLogged(request: RunRequest, runLog: RunEventLog)( val runtimeGit = wiring.git.getOrElse(new OsGitTool(workDir, dispatcher)) val ghTool = wiring.gh.getOrElse( - new OsGitHubTool( - OsProcCliRunner, - workDir, - events = dispatcher, - defaultReadiness = request.args.prReadiness - ) + new OsGitHubTool(OsProcCliRunner, workDir, events = dispatcher) ) val fsTool = wiring.fs.getOrElse(new OsFsTool(workDir)) runInContext( @@ -465,7 +461,7 @@ private def runInContext( reviewAgent = resolvedRoles.review, wired = agents, runtimeGit = runtimeGit, - gh = ghTool, + gh = RunGitHubTool(ghTool, flowSetup.prReadiness), fs = fsTool, stackSettings = flowSetup.stackSettings, reviewerCatalog = reviewerCatalog diff --git a/runner/src/main/scala/orca/runner/FlowLifecycle.scala b/runner/src/main/scala/orca/runner/FlowLifecycle.scala index ea85b64ad..bf7172daa 100644 --- a/runner/src/main/scala/orca/runner/FlowLifecycle.scala +++ b/runner/src/main/scala/orca/runner/FlowLifecycle.scala @@ -33,7 +33,7 @@ import orca.progress.{ } import orca.settings.{AgentSettings, SettingsFile, SettingsScope} import orca.subprocess.TtyProbe -import orca.tools.{RuntimeGit, UntrackedFiles} +import orca.tools.{PrReadiness, RuntimeGit, UntrackedFiles} import org.slf4j.LoggerFactory import ox.either.orThrow @@ -137,7 +137,11 @@ object FlowLifecycle: * `--worktree`, or a resume the shell relaunched into one without the * flag. The closing summary names it. */ - worktree: Option[os.Path] + worktree: Option[os.Path], + /** What a `createPr` without a readiness opens: this run's `--draft`, or + * on resume the first attempt's. + */ + prReadiness: PrReadiness ) /** The branch half of [[FlowSetup]], resolved by whichever of @@ -152,7 +156,8 @@ object FlowLifecycle: featureBranch: FeatureBranch, startingHead: Head, branchMode: BranchMode, - startingCommit: Option[CommitHash] + startingCommit: Option[CommitHash], + prReadiness: PrReadiness ) /** Bind the run to a branch + progress log before the body runs (ADR 0018 @@ -245,7 +250,8 @@ object FlowLifecycle: // From where the run IS, not from the flag: the shell relaunches a resume // inside the worktree its log was found in WITHOUT `--worktree` (the flag // would re-derive a path), and that run has to name the tree too. - Option.when(WorktreeRun.isWorktreeRun(workDir))(workDir) + Option.when(WorktreeRun.isWorktreeRun(workDir))(workDir), + binding.prReadiness ) /** [[setup]]'s fixed inputs — the coding-role agent, git, the progress store, @@ -377,7 +383,8 @@ object FlowLifecycle: startingHead, if args.target.skipBranch then BranchMode.Reused else BranchMode.Created, - Some(headAtBinding) + Some(headAtBinding), + args.prReadiness ) /** Fresh run: resolve + create the branch, then commit the header as the @@ -495,7 +502,8 @@ object FlowLifecycle: featureBranch, header.startingHead, header.branchMode, - startingCommit + startingCommit, + header.prReadiness ) /** What a resumed run says about itself, once. `createBranch` and diff --git a/runner/src/main/scala/orca/runner/RunGitHubTool.scala b/runner/src/main/scala/orca/runner/RunGitHubTool.scala new file mode 100644 index 000000000..83f6aabfe --- /dev/null +++ b/runner/src/main/scala/orca/runner/RunGitHubTool.scala @@ -0,0 +1,20 @@ +package orca.runner + +import orca.WorkspaceWrite +import orca.tools.{GitHubTool, PrCreateFailed, PrHandle, PrReadiness} + +/** The `gh` a run's flow sees: `underlying` with a `createPr` that opens + * `runReadiness` when its caller passes no readiness. + */ +private[orca] class RunGitHubTool( + underlying: GitHubTool, + runReadiness: PrReadiness +) extends GitHubTool: + export underlying.{createPr as _, *} + + def createPr( + title: String, + body: String, + readiness: Option[PrReadiness] + )(using WorkspaceWrite): Either[PrCreateFailed, PrHandle] = + underlying.createPr(title, body, readiness.orElse(Some(runReadiness))) diff --git a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala index c37510a11..9965b26f8 100644 --- a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala +++ b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala @@ -35,7 +35,6 @@ import orca.runner.terminal.TerminalInteraction import orca.tools.{ GitHubAvailability, GitHubTool, - OsGitHubTool, OsGitTool, PrReadiness, RuntimeGit, @@ -48,6 +47,8 @@ import ox.either.orThrow import java.io.{ByteArrayOutputStream, PrintStream} import java.util.concurrent.atomic.{AtomicInteger, AtomicReference} +import java.util.concurrent.ConcurrentLinkedQueue +import scala.jdk.CollectionConverters.* import orca.testkit.{ GitRepo, PushlessGit, @@ -251,7 +252,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -324,7 +326,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = GitRepo.headCommit(workDir) + startingCommit = GitRepo.headCommit(workDir), + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -607,7 +610,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -741,7 +745,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = userPrompt, flow = flow, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) store @@ -913,7 +918,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) val _ = writeForeignLog(workDir, branch = "feat/resume-me") @@ -998,7 +1004,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, startingCommit = unreachableCommit, userPrompt = prompt, - flow = None + flow = None, + prReadiness = PrReadiness.Ready ) ) val setup = setupForSettings(workDir, Some(StackSettings.empty), prompt) @@ -1116,6 +1123,38 @@ class FlowLifecycleTest extends munit.FunSuite: "feat/x" ) + /** [[setupWithBranch]]'s twin for `--draft`: one prompt, so a second call + * resumes the first. + */ + private def setupWithReadiness( + workDir: os.Path, + prReadiness: PrReadiness + ): FlowLifecycle.FlowSetup = + val prompt = "a task with a chosen readiness" + setupForSettings( + workDir, + Some(StackSettings.empty), + prompt, + args = Some(OrcaArgs(prompt, prReadiness = prReadiness)) + ) + + test("setup: a fresh run records its --draft in the header"): + val workDir = GitRepo.seeded() + val setup = setupWithReadiness(workDir, PrReadiness.Draft) + assertEquals(setup.prReadiness, PrReadiness.Draft) + assertEquals( + setup.store.load().map(_.header.prReadiness), + Some(PrReadiness.Draft) + ) + + test("setup: a resume keeps the first attempt's readiness over its own"): + val workDir = GitRepo.seeded() + val _ = setupWithReadiness(workDir, PrReadiness.Draft) + assertEquals( + setupWithReadiness(workDir, PrReadiness.Ready).prReadiness, + PrReadiness.Draft + ) + test("setup: --branch naming the detected default branch is refused"): val workDir = GitRepo.seeded() val git = new OsGitTool(workDir): @@ -1599,7 +1638,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -1801,23 +1841,26 @@ class FlowLifecycleTest extends munit.FunSuite: ) )(body) - test("--draft becomes the default readiness of the run's gh"): - val workDir = GitRepo.seeded() - var seen: Option[PrReadiness] = None - runFlowForTest(workDir, "draft-gh", prReadiness = PrReadiness.Draft): - seen = summon[orca.FlowContext].gh match - case tool: OsGitHubTool => Some(tool.defaultReadiness) - case _ => None - assertEquals(seen, Some(PrReadiness.Draft)) - - test("--draft is recorded in the run's progress header"): + test("a createPr without a readiness opens the run's --draft"): val workDir = GitRepo.seeded() - val prompt = "draft-header" - val store = ProgressStore.default(workDir, RunKey.of(prompt)) - var seen: Option[PrReadiness] = None - runFlowForTest(workDir, prompt, prReadiness = PrReadiness.Draft): - seen = store.load().map(_.header.prReadiness) - assertEquals(seen, Some(PrReadiness.Draft)) + val readinesses = new ConcurrentLinkedQueue[Option[PrReadiness]]() + val gh = new StubGitHubTool: + override def createPr( + title: String, + body: String, + readiness: Option[PrReadiness] + )(using WorkspaceWrite) = + readinesses.add(readiness): Unit + Right(handoffPr) + runFlowForTest( + workDir, + "draft-gh", + gh = Some(gh), + prReadiness = PrReadiness.Draft + ): + given WorkspaceWrite = WorkspaceWrite.unsafe + val _ = summon[orca.FlowContext].gh.createPr("t", "b") + assertEquals(readinesses.asScala.toList, List(Some(PrReadiness.Draft))) test( "teardownSuccess is best-effort: a non-missing-file removal error does not fail an otherwise-successful run" @@ -2783,7 +2826,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Reused, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -2908,7 +2952,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -3210,7 +3255,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Reused, startingTree = StartingTree.Clean, startingCommit = startedAt, - worktree = None + worktree = None, + prReadiness = PrReadiness.Ready ) FlowLifecycle.teardownSuccess( git, @@ -3250,7 +3296,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, startingTree = StartingTree.Clean, startingCommit = startedAt, - worktree = None + worktree = None, + prReadiness = PrReadiness.Ready ) (git, workDir, setup) @@ -3376,7 +3423,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Reused, startingTree = StartingTree.Clean, startingCommit = None, - worktree = None + worktree = None, + prReadiness = PrReadiness.Ready ) TeardownPushRepo(git, remote, setup, store.path.subRelativeTo(workDir)) @@ -3477,7 +3525,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = branchMode, startingTree = StartingTree.Clean, startingCommit = startingCommit, - worktree = None + worktree = None, + prReadiness = PrReadiness.Ready ) test( @@ -3595,7 +3644,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, startingCommit = base, userPrompt = prompt, - flow = None + flow = None, + prReadiness = PrReadiness.Ready ) ) store.upsertEntry( @@ -3658,7 +3708,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = GitRepo.headCommit(workDir) + startingCommit = GitRepo.headCommit(workDir), + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -3722,7 +3773,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -3784,7 +3836,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -3842,7 +3895,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = "a different prompt", flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) @@ -3887,7 +3941,8 @@ class FlowLifecycleTest extends munit.FunSuite: branchMode = BranchMode.Created, userPrompt = prompt, flow = None, - startingCommit = unreachableCommit + startingCommit = unreachableCommit, + prReadiness = PrReadiness.Ready ) ) git.forceAdd(store.path) diff --git a/runner/src/test/scala/orca/runner/RunGitHubToolTest.scala b/runner/src/test/scala/orca/runner/RunGitHubToolTest.scala new file mode 100644 index 000000000..bde406fe2 --- /dev/null +++ b/runner/src/test/scala/orca/runner/RunGitHubToolTest.scala @@ -0,0 +1,42 @@ +package orca.runner + +import orca.WorkspaceWrite +import orca.testkit.StubGitHubTool +import orca.tools.{PrHandle, PrReadiness} + +import java.util.concurrent.ConcurrentLinkedQueue +import scala.jdk.CollectionConverters.* + +class RunGitHubToolTest extends munit.FunSuite: + given WorkspaceWrite = WorkspaceWrite.unsafe + + private val pr = + PrHandle.fromUrl("https://github.com/acme/widgets/pull/42").get + + /** The readiness the wrapped tool got for one `createPr(readiness)` through a + * run whose `--draft` is `Draft`. + */ + private def passedOn( + readiness: Option[PrReadiness] + ): List[Option[PrReadiness]] = + val seen = new ConcurrentLinkedQueue[Option[PrReadiness]]() + val underlying = new StubGitHubTool: + override def createPr( + title: String, + body: String, + readiness: Option[PrReadiness] + )(using WorkspaceWrite) = + seen.add(readiness): Unit + Right(pr) + val _ = RunGitHubTool(underlying, PrReadiness.Draft) + .createPr("t", "b", readiness) + seen.asScala.toList + + test("createPr without a readiness gets the run's"): + assertEquals(passedOn(None), List(Some(PrReadiness.Draft))) + + test("createPr with a readiness keeps it"): + assertEquals( + passedOn(Some(PrReadiness.Ready)), + List(Some(PrReadiness.Ready)) + ) diff --git a/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala b/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala index 12094d817..580fe1c69 100644 --- a/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala +++ b/shell/src/main/scala/orca/shell/menu/ResumeDetector.scala @@ -4,7 +4,6 @@ import orca.gitref.BranchName import orca.json.JsonFile import orca.progress.{FlowSource, ProgressHeader, ProgressLog, ProgressScan} import orca.shell.actions.FlowResolution -import orca.tools.PrReadiness /** An unfinished flow run, byte-identically relaunchable: the flow script and * the exact prompt that started it (ADR 0021 §3 amendment). @@ -21,8 +20,7 @@ private[menu] case class InterruptedRun( */ dir: os.Path, /** The progress log itself, under `dir`. */ - log: os.Path, - prReadiness: PrReadiness = PrReadiness.Ready + log: os.Path ) /** Detects an interrupted run for the main menu's "Resume interrupted run" @@ -71,8 +69,7 @@ private[menu] object ResumeDetector: header.userPrompt, header.branch, dir = found.dir, - log = found.path, - prReadiness = header.prReadiness + log = found.path ) ) diff --git a/shell/src/main/scala/orca/shell/menu/RunMenu.scala b/shell/src/main/scala/orca/shell/menu/RunMenu.scala index 27df05ccb..05cd84ffc 100644 --- a/shell/src/main/scala/orca/shell/menu/RunMenu.scala +++ b/shell/src/main/scala/orca/shell/menu/RunMenu.scala @@ -131,8 +131,7 @@ private[menu] object RunMenu: userPrompt = run.userPrompt, verbose = false, target = DefaultRunTarget, - branch = None, - prReadiness = run.prReadiness + branch = None ) launch( PinPolicy.Force(FallbackPolicy.Ask(ui)), diff --git a/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala b/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala index fcf9cab8b..d925c3af2 100644 --- a/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala +++ b/shell/src/test/scala/orca/shell/menu/ResumeDetectorTest.scala @@ -1,10 +1,11 @@ package orca.shell.menu +import orca.tools.PrReadiness + import orca.{OrcaDir, RunKey, WorkspaceWrite} import orca.gitref.CommitHash import orca.progress.{BranchMode, FlowSource, ProgressHeader, ProgressStore} import orca.testkit.{TempDirs, branchName} -import orca.tools.PrReadiness class ResumeDetectorTest extends munit.FunSuite: @@ -14,8 +15,7 @@ class ResumeDetectorTest extends munit.FunSuite: private def header( userPrompt: String = "fix the flaky test", - flow: Option[FlowSource] = Some(FlowSource.Catalog("implement.sc")), - prReadiness: PrReadiness = PrReadiness.Ready + flow: Option[FlowSource] = Some(FlowSource.Catalog("implement.sc")) ): ProgressHeader = ProgressHeader( startingBranch = Some(branchName("main")), @@ -24,7 +24,7 @@ class ResumeDetectorTest extends munit.FunSuite: userPrompt = userPrompt, flow = flow, startingCommit = CommitHash.from("0" * 40).get, - prReadiness = prReadiness + prReadiness = PrReadiness.Ready ) // The scan's own guards (symlinked log file, symlinked or missing `.orca`) @@ -52,16 +52,6 @@ class ResumeDetectorTest extends munit.FunSuite: ) ) - test("detect carries the run's recorded readiness"): - val workDir = TempDirs.dir() - ProgressStore - .default(workDir, RunKey.of("fix the flaky test")) - .writeHeader(header(prReadiness = PrReadiness.Draft)) - assertEquals( - ResumeDetector.detect(List(workDir)).map(_.prReadiness), - Some(PrReadiness.Draft) - ) - test( "detect is None for a log with no recorded flow (a run outside the shell)" ): diff --git a/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala b/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala index 53e32ad9d..4fef51bef 100644 --- a/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala +++ b/shell/src/test/scala/orca/shell/menu/RunMenuTest.scala @@ -8,7 +8,6 @@ import orca.shell.flows.DiscoveredFlow import orca.shell.run.{LaunchResult, LaunchedFlow} import orca.shell.ui.UiOutcome import orca.testkit.{TempDirs, branchName} -import orca.tools.PrReadiness import MenuFixtures.{captured, withDumbTerminal} @@ -206,16 +205,6 @@ class RunMenuTest extends munit.FunSuite: Some("fix the flaky test\nwith detail") ) - test("resumeInterruptedRun: launches with the recorded readiness"): - val workDir = TempDirs.dir() - val _ = projectFlow(workDir, "resume-flow.sc") - val launch = resumed( - interrupted(FlowSource.Catalog("resume-flow.sc"), workDir) - .copy(prReadiness = PrReadiness.Draft), - workDir - ) - assertEquals(launch.map(_._2.prReadiness), Some(PrReadiness.Draft)) - test( "resumeInterruptedRun: the run happens in the directory its log was found in, with the default target" ): diff --git a/tools/src/main/scala/orca/tools/GitHubTool.scala b/tools/src/main/scala/orca/tools/GitHubTool.scala index 58d064240..b660f2033 100644 --- a/tools/src/main/scala/orca/tools/GitHubTool.scala +++ b/tools/src/main/scala/orca/tools/GitHubTool.scala @@ -312,17 +312,13 @@ trait GitHubTool: * * `events` lets the tool publish a [[OrcaEvent.Step]] when a PR is opened so * the URL surfaces in the event log. Defaults to `OrcaListener.noop`. - * - * `defaultReadiness` is what a [[createPr]] without a `readiness` opens: the - * run's `--draft`. */ private[orca] class OsGitHubTool( cli: CliRunner, workDir: os.Path = os.pwd, pollInterval: FiniteDuration = 30.seconds, events: OrcaListener = OrcaListener.noop, - readRetry: Schedule = OsGitHubTool.defaultReadRetry, - private[orca] val defaultReadiness: PrReadiness = PrReadiness.Ready + readRetry: Schedule = OsGitHubTool.defaultReadRetry ) extends GitHubTool: import OsGitHubTool.* @@ -500,7 +496,7 @@ private[orca] class OsGitHubTool( // its progress log would be misread as "branch not pushed". Bare branch // name, no `owner:` prefix, matching `findOpenPr`. val head = headBranchForPr() - val draftFlag = readiness.getOrElse(defaultReadiness) match + val draftFlag = readiness.getOrElse(PrReadiness.Ready) match case PrReadiness.Draft => Seq("--draft") case PrReadiness.Ready => Nil val result = runGhResult( diff --git a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala index 72c249c69..5705877b7 100644 --- a/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala +++ b/tools/src/test/scala/orca/tools/OsGitHubToolTest.scala @@ -403,31 +403,18 @@ class OsGitHubToolTest extends munit.FunSuite: assert(args.containsSlice(Seq("--body", "hello"))) assert(args.containsSlice(Seq("--head", "feat"))) - /** The `gh pr create` argv of a tool with `defaultReadiness`. */ - private def createArgs( - defaultReadiness: PrReadiness, - readiness: Option[PrReadiness] - ): Seq[String] = - val cli = createPrRunner( - CliResult(0, "https://github.com/acme/widgets/pull/42\n", "") - ) - val _ = new OsGitHubTool(cli, defaultReadiness = defaultReadiness) - .createPr("feat: hi", "hello", readiness) - .orThrow - cli.calls.last.args - test("createPr passes --draft only for a draft PR"): - assert( - createArgs(PrReadiness.Ready, Some(PrReadiness.Draft)).contains("--draft") - ) - assert( - !createArgs(PrReadiness.Draft, Some(PrReadiness.Ready)) - .contains("--draft") - ) - - test("createPr without a readiness opens the tool's default"): - assert(createArgs(PrReadiness.Draft, None).contains("--draft")) - assert(!createArgs(PrReadiness.Ready, None).contains("--draft")) + def createArgs(readiness: Option[PrReadiness]): Seq[String] = + val cli = createPrRunner( + CliResult(0, "https://github.com/acme/widgets/pull/42\n", "") + ) + val _ = new OsGitHubTool(cli) + .createPr("feat: hi", "hello", readiness) + .orThrow + cli.calls.last.args + assert(createArgs(Some(PrReadiness.Draft)).contains("--draft")) + assert(!createArgs(Some(PrReadiness.Ready)).contains("--draft")) + assert(!createArgs(None).contains("--draft")) test("createPr leaves an existing PR alone, whatever the readiness"): val cli = createPrRunner( From 765fb4454811c9deb79be6758e486b61aa9dc268 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Wed, 21 Oct 2026 17:52:39 +0000 Subject: [PATCH 7/7] Forward prHandle through RunGitHubTool; tighten readiness tests and docs --- AGENTS.md | 2 +- .../scala/orca/runner/FlowLifecycle.scala | 6 ++--- .../scala/orca/runner/RunGitHubTool.scala | 4 ++++ .../scala/orca/runner/FlowLifecycleTest.scala | 18 ++++++++++----- .../scala/orca/runner/RunGitHubToolTest.scala | 22 +++++++------------ .../main/scala/orca/tools/GitHubTool.scala | 15 +++++-------- .../main/scala/orca/tools/PrReadiness.scala | 9 ++++++++ 7 files changed, 42 insertions(+), 34 deletions(-) create mode 100644 tools/src/main/scala/orca/tools/PrReadiness.scala diff --git a/AGENTS.md b/AGENTS.md index 41700f4f4..90f6c9527 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -305,7 +305,7 @@ Three location classes decide what survives: | Path | Class | Holds | Written by | Read by | Removed by | |---|---|---|---|---|---| -| `.orca/runs/.progress.json` | committed | `ProgressLog`: header (branches, `branchMode`, `startingCommit`, `userPrompt`, `flow`), one `StageEntry` per completed stage (`id` as `StagePath` segments, `resultJson`), `published` | `ProgressStore` (`FlowLifecycle.freshRun`, `stage.scala`'s `recordAndCommit`, `recordOpenedPr`) | `stage.scala`'s `resumeFrom`, `RecoveryCheck`, `FlowLifecycle`, shell `ResumeDetector` (header) | success teardown, in a final commit | +| `.orca/runs/.progress.json` | committed | `ProgressLog`: header (branches, `branchMode`, `startingCommit`, `userPrompt`, `flow`, `prReadiness`), one `StageEntry` per completed stage (`id` as `StagePath` segments, `resultJson`), `published` | `ProgressStore` (`FlowLifecycle.freshRun`, `stage.scala`'s `recordAndCommit`, `recordOpenedPr`) | `stage.scala`'s `resumeFrom`, `RecoveryCheck`, `FlowLifecycle`, shell `ResumeDetector` (header) | success teardown, in a final commit | | `.orca/cache/runs//events.jsonl` | cache | the run's append-only `RunEvent` log, one JSON line per event: `AttemptStarted`, `BranchBound`, `StageStarted`/`StageEnded`, `SessionMinted`/`SessionWireId` (session records), `SessionCommitted`, `Turn` (token usage and cost), `RunSucceeded`, `AttemptFinished`; a public format, documented in docs/using/output-and-files.md | `RunEventLog`: the run's `SessionStore` and an `OrcaListener` | `RunEventLog` at attempt start (session records after the last `RunSucceeded`), shell `ContinuableAttempts` → session picker / `orca continue` (attempts with no session are left out), people and scripts | pruning at attempt start, by run directory: never the current run or one with a `SessionMinted` after its last `RunSucceeded`; else newest 20 runs with a session ∪ newest 20 of any kind | | `.orca/cache/runs//.trace.log` (+ `.trace.1.log`) | cache | DEBUG trace of logger `orca`: prompts, agent output, tool calls; 4 MB roll | `OrcaLog` | people (path in the banner and in `AttemptStarted.trace`) | pruned with its run directory; across all run directories, all but the newest 40 attempts' traces at attempt start | | `.orca/cache/flow.lock` | cache | an OS file lock, held for the run; holder pid | `FlowLock` | `FlowLock` on contention | never; the OS releases the lock when the holder exits | diff --git a/runner/src/main/scala/orca/runner/FlowLifecycle.scala b/runner/src/main/scala/orca/runner/FlowLifecycle.scala index bf7172daa..bf0ba95cb 100644 --- a/runner/src/main/scala/orca/runner/FlowLifecycle.scala +++ b/runner/src/main/scala/orca/runner/FlowLifecycle.scala @@ -148,9 +148,9 @@ object FlowLifecycle: * [[SetupSession.bindBranch]]'s arms runs (corrupt log, absent log, or * resumed) before the stack-settings half is folded in. * - * `startingCommit` comes from the same arm: a fresh run records the commit - * it bound at, a resumed one reads back what its header recorded, so both - * name the state the RUN started from rather than this attempt's. + * `startingCommit` and `prReadiness` come from the same arm: a fresh run + * takes this attempt's, a resumed one reads back what its header recorded, + * so both describe the RUN rather than this attempt. */ private[orca] case class BranchBinding( featureBranch: FeatureBranch, diff --git a/runner/src/main/scala/orca/runner/RunGitHubTool.scala b/runner/src/main/scala/orca/runner/RunGitHubTool.scala index 83f6aabfe..d208de25d 100644 --- a/runner/src/main/scala/orca/runner/RunGitHubTool.scala +++ b/runner/src/main/scala/orca/runner/RunGitHubTool.scala @@ -12,6 +12,10 @@ private[orca] class RunGitHubTool( ) extends GitHubTool: export underlying.{createPr as _, *} + // `export` skips members the trait already implements. + override def prHandle(ref: String): Either[String, PrHandle] = + underlying.prHandle(ref) + def createPr( title: String, body: String, diff --git a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala index 9965b26f8..721f1a401 100644 --- a/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala +++ b/runner/src/test/scala/orca/runner/FlowLifecycleTest.scala @@ -1149,10 +1149,10 @@ class FlowLifecycleTest extends munit.FunSuite: test("setup: a resume keeps the first attempt's readiness over its own"): val workDir = GitRepo.seeded() - val _ = setupWithReadiness(workDir, PrReadiness.Draft) + val _ = setupWithReadiness(workDir, PrReadiness.Ready) assertEquals( - setupWithReadiness(workDir, PrReadiness.Ready).prReadiness, - PrReadiness.Draft + setupWithReadiness(workDir, PrReadiness.Draft).prReadiness, + PrReadiness.Ready ) test("setup: --branch naming the detected default branch is refused"): @@ -1841,8 +1841,9 @@ class FlowLifecycleTest extends munit.FunSuite: ) )(body) - test("a createPr without a readiness opens the run's --draft"): + test("a resumed run's createPr without a readiness opens its first --draft"): val workDir = GitRepo.seeded() + val prompt = "draft-gh" val readinesses = new ConcurrentLinkedQueue[Option[PrReadiness]]() val gh = new StubGitHubTool: override def createPr( @@ -1852,11 +1853,16 @@ class FlowLifecycleTest extends munit.FunSuite: )(using WorkspaceWrite) = readinesses.add(readiness): Unit Right(handoffPr) + val _ = intercept[ReportedFailure]: + runFlowForTest(workDir, prompt, prReadiness = PrReadiness.Draft): + val _ = stage("stage-one")("one-done") + val _ = stage[String]("stage-two"): + throw new RuntimeException("boom") runFlowForTest( workDir, - "draft-gh", + prompt, gh = Some(gh), - prReadiness = PrReadiness.Draft + prReadiness = PrReadiness.Ready ): given WorkspaceWrite = WorkspaceWrite.unsafe val _ = summon[orca.FlowContext].gh.createPr("t", "b") diff --git a/runner/src/test/scala/orca/runner/RunGitHubToolTest.scala b/runner/src/test/scala/orca/runner/RunGitHubToolTest.scala index bde406fe2..6cd527429 100644 --- a/runner/src/test/scala/orca/runner/RunGitHubToolTest.scala +++ b/runner/src/test/scala/orca/runner/RunGitHubToolTest.scala @@ -13,12 +13,7 @@ class RunGitHubToolTest extends munit.FunSuite: private val pr = PrHandle.fromUrl("https://github.com/acme/widgets/pull/42").get - /** The readiness the wrapped tool got for one `createPr(readiness)` through a - * run whose `--draft` is `Draft`. - */ - private def passedOn( - readiness: Option[PrReadiness] - ): List[Option[PrReadiness]] = + test("createPr with a readiness keeps it over the run's"): val seen = new ConcurrentLinkedQueue[Option[PrReadiness]]() val underlying = new StubGitHubTool: override def createPr( @@ -29,14 +24,13 @@ class RunGitHubToolTest extends munit.FunSuite: seen.add(readiness): Unit Right(pr) val _ = RunGitHubTool(underlying, PrReadiness.Draft) - .createPr("t", "b", readiness) - seen.asScala.toList + .createPr("t", "b", Some(PrReadiness.Ready)) + assertEquals(seen.asScala.toList, List(Some(PrReadiness.Ready))) - test("createPr without a readiness gets the run's"): - assertEquals(passedOn(None), List(Some(PrReadiness.Draft))) - - test("createPr with a readiness keeps it"): + test("prHandle is the wrapped tool's"): + val underlying = new StubGitHubTool: + override def prHandle(ref: String): Either[String, PrHandle] = Right(pr) assertEquals( - passedOn(Some(PrReadiness.Ready)), - List(Some(PrReadiness.Ready)) + RunGitHubTool(underlying, PrReadiness.Draft).prHandle("x"), + Right(pr) ) diff --git a/tools/src/main/scala/orca/tools/GitHubTool.scala b/tools/src/main/scala/orca/tools/GitHubTool.scala index b660f2033..e3e7db693 100644 --- a/tools/src/main/scala/orca/tools/GitHubTool.scala +++ b/tools/src/main/scala/orca/tools/GitHubTool.scala @@ -3,7 +3,6 @@ package orca.tools import com.github.plokhotnyuk.jsoniter_scala.core.readFromString import orca.{OrcaFlowException, WorkspaceWrite} import orca.events.{OrcaEvent, OrcaListener} -import orca.json.JsonData import orca.subprocess.{CliResult, CliRunner} import orca.util.TextUtil import ox.sleep @@ -125,12 +124,6 @@ enum CheckState: case Failure case Unknown(raw: String) -/** Whether [[GitHubTool.createPr]] opens a PR ready for review or as a draft. - */ -enum PrReadiness derives JsonData: - case Ready - case Draft - /** Recoverable [[GitHubTool.createPr]] failure modes. Common when re-running a * flow against an already-pushed branch. Other gh failures (auth, network) * remain thrown. @@ -226,9 +219,9 @@ trait GitHubTool: * (auth, network). * * `readiness` applies only to a PR this call opens (`gh pr create --draft`); - * when omitted, the run's `--draft` decides. When an open PR already exists - * for the branch, it is returned as it is: neither draft nor ready state is - * changed. + * when omitted, the run's `--draft` decides (applied by the run's `gh`). + * When an open PR already exists for the branch, it is returned as it is: + * neither draft nor ready state is changed. */ def createPr( title: String, @@ -312,6 +305,8 @@ trait GitHubTool: * * `events` lets the tool publish a [[OrcaEvent.Step]] when a PR is opened so * the URL surfaces in the event log. Defaults to `OrcaListener.noop`. + * + * A `createPr` without a readiness opens a ready PR. */ private[orca] class OsGitHubTool( cli: CliRunner, diff --git a/tools/src/main/scala/orca/tools/PrReadiness.scala b/tools/src/main/scala/orca/tools/PrReadiness.scala new file mode 100644 index 000000000..00d542d87 --- /dev/null +++ b/tools/src/main/scala/orca/tools/PrReadiness.scala @@ -0,0 +1,9 @@ +package orca.tools + +import orca.json.JsonData + +/** Whether [[GitHubTool.createPr]] opens a PR ready for review or as a draft. + */ +enum PrReadiness derives JsonData: + case Ready + case Draft