Skip to content

feat: migrate control plane to vercel webhook and consolidate agents on cloud runs#400

Draft
captainsafia wants to merge 44 commits intomainfrom
oz-agent/control-plane-migration
Draft

feat: migrate control plane to vercel webhook and consolidate agents on cloud runs#400
captainsafia wants to merge 44 commits intomainfrom
oz-agent/control-plane-migration

Conversation

@captainsafia
Copy link
Copy Markdown
Collaborator

@captainsafia captainsafia commented Apr 28, 2026

Summary

This PR migrates the triage / respond-to-triaged / PR-review workflows off Docker and onto Warp-hosted cloud agent runs, and lays down a Vercel-based control plane that will eventually replace the GitHub Actions delivery surface entirely.

The cloud-mode rewrite is the immediately-active change: each of the three Python entrypoints now calls run_agent (cloud) + build_agent_config(role="review-triage") and reads results through the new oz_workflows.artifacts.load_*_artifact helpers. Their prompts now describe a oz artifact upload <name>.json handoff instead of a /mnt/output mount; security rules, output schemas, and skill references survive the rewrite verbatim. The Docker assets (docker/triage/, docker/review/, build-{triage,review}-image composite actions, docker_agent.py, test_docker_agent.py) are deleted, and the three workflow YAMLs that used them are updated to drop the Build … agent container step and forward WARP_ENVIRONMENT_ID + WARP_REVIEW_TRIAGE_ENVIRONMENT_ID to the script step. The workflow YAMLs themselves are intentionally retained so the existing GitHub Actions delivery path keeps working through the cutover.

The new control-plane/ Python project is the long-term target: a Vercel webhook handler that verifies HMAC-SHA256 signatures and routes events to a workflow handler, plus a 1-minute cron poller that reads in-flight run state from Vercel KV and applies completed cloud-agent results back to GitHub. Lib helpers cover signatures, routing, trust evaluation, dispatch, in-flight state, GitHub App token exchange, and the cron drain loop. The full architecture and deployment runbook live in control-plane/README.md.

Workflows served by the Vercel webhook in this PR

The webhook + cron control plane now owns the following delivery surface, and the corresponding .github/workflows/*.yml + .github/scripts/*.py shims have been removed in favor of the cloud-mode helpers under lib/scripts/:

  • review-pull-request (PR opened / ready_for_review / review_requested / labeled / /oz-review).
  • enforce-pr-issue-state (PR synchronize / edited).
  • respond-to-pr-comment (@oz-agent mention on PRs and review threads).
  • verify-pr-comment (/oz-verify on PRs).
  • triage-new-issues — newly added: issues.opened on non-triaged issues, @oz-agent mention on a non-triaged issue, and needs-info reporter replies all route through the webhook.

@oz-agent mentions on already-triaged issues continue to flow through the legacy respond-to-triaged-issue-comment GitHub Actions workflow until that workflow is migrated in a follow-up.

Cutover steps after merge

  1. Provision the Vercel project — point a new Python project at control-plane/. vercel.json declares the runtime, both functions, and the 1-minute cron schedule.
  2. Set Vercel project secretsOZ_GITHUB_WEBHOOK_SECRET, OZ_GITHUB_APP_ID, OZ_GITHUB_APP_PRIVATE_KEY, WARP_API_KEY, WARP_API_BASE_URL, WARP_ENVIRONMENT_ID, WARP_REVIEW_TRIAGE_ENVIRONMENT_ID, CRON_SECRET. Detail per-secret in control-plane/README.md.
  3. Provision Vercel KV — add a KV resource to the Vercel project. The cron handler imports vercel_kv lazily.
  4. Update the GitHub App webhook URL — flip the App's webhook URL from the GitHub Actions delivery target to https://<project>.vercel.app/api/webhook. The webhook handler returns 202 with the routed workflow id so the Recent Deliveries UI stays green.
  5. Open the follow-up PR to delete .github/workflows/* per plan §5a — once the Vercel control plane is verified end-to-end, delete the remaining legacy GitHub Actions YAMLs in a follow-up PR. This PR keeps the issue-triggered helpers (respond-to-triaged-issue-comment, create-spec-from-issue, create-implementation-from-issue, comment-on-*) and the plan-approval workflows in place so both delivery paths can be exercised in parallel during cutover.

Validation

  • python -m pytest tests191 tests passed, 47 subtests passed (signature verification, routing table, trust evaluation, dispatch, cron drain loop, builder lifecycle, handler wiring, triage prompt + apply helpers).
  • PYTHONPATH=lib:.github/scripts python -m unittest discover -s .github/scripts/tests294 OK (helpers, role parameter, named artifact loaders, cloud-mode triage/review/respond-to-triaged dispatch, and skill-section assertions; the triage-specific tests have moved to tests/test_triage.py).
  • Triage / review / respond-to-triaged Security Rules: blocks were diff-checked byte-for-byte against main to confirm the cloud rewrite did not weaken or relax the prompt-injection / output-schema rules.

References

Plan id: 7e8e8b6a-9e8a-4cbf-ab95-dd37cf4cc44c.

Conversation: https://staging.warp.dev/conversation/da08a6c7-4f86-4dac-99f6-3358cfe3258e
Run: https://oz.staging.warp.dev/runs/019dd6d9-cb65-73a2-8cfb-d03d37afd03a
Plans:

This PR was generated with Oz.

@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 28, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
oz-for-oss Ready Ready Preview, Comment Apr 29, 2026 11:00pm

Request Review

Copy link
Copy Markdown
Collaborator Author

Round 2: Vercel control plane wiring

This round adds the control-plane wiring that turns the scaffolding from round 1 into an end-to-end PR-flow runtime. Every PR-triggered webhook now flows through the Vercel control plane (webhook → builder → cloud-agent dispatch → KV → cron → apply-result-to-GitHub).

Commits

  • 787e22f feat(oz_workflows): add fire-and-forget dispatch_run helper — adds an optional client: OzAPI | None = None parameter to dispatch_run so the cron poller / webhook handler can reuse a single SDK client. Rebased on top of e41e256.
  • 14275df chore(control-plane): mirror oz_workflows + entrypoints into the function — Vercel installCommand (scripts/vercel_install.sh) mirrors .github/scripts/oz_workflows/ and the four PR entrypoints into control-plane/lib/ before the build step. Mirrored copies are git-ignored so .github/scripts/ stays the single source of truth.
  • 5d8d0e3 refactor(workflows): expose gather/build/apply helpers on PR entrypoints — refactors review_pr.py, respond_to_pr_comment.py, verify_pr_comment.py, and enforce_pr_issue_state.py to expose gather_*_context / build_*_prompt / apply_*_result (plus enforce_pr_state_synchronously for the deterministic enforce-PR path). The legacy main() entrypoints continue to call the same helpers so the GitHub Actions path stays byte-for-byte equivalent.
  • 381a11d feat(control-plane): wire builders, handlers, webhook, and cron registry
    • lib/builders.py: one PromptBuilder per cloud workflow + build_builder_registry.
    • lib/handlers.py: one WorkflowHandlers per cloud workflow + build_handler_registry. Each handler mints a fresh GitHub App-installation token per call.
    • api/webhook.py:process_webhook_request now signature-verifies, routes, runs the synchronous enforce-pr-issue-state path inline, evaluates the route through the builder registry, dispatches via dispatch_run, persists RunState to KV, and returns 202 with {run_id, dispatched, ...}. Errors at any stage surface as a structured 500.
    • api/cron.py:build_workflow_handlers returns the concrete handler registry instead of an empty {}.
  • 8dc2ea5 test(control-plane): add builders + handlers + webhook dispatch unit tests — adds 23 new tests across tests/test_builders.py, tests/test_handlers.py, and tests/test_webhook_dispatch.py. Updated control-plane/README.md to drop the "currently no-op" caveat and document which workflows are live (PR-flow) vs. still pending (issue-flow).

What's live

  • review-pull-request (PR opens, ready_for_review, oz-review label, /oz-review command).
  • respond-to-pr-comment (@oz-agent mentions on PR conversation comments, review comments, and review bodies).
  • verify-pr-comment (/oz-verify command).
  • enforce-pr-issue-state (PR synchronize / edited). Synchronous allow/close decisions run inline in the webhook; the need-cloud-match branch dispatches a cloud agent run.

The issue-triggered workflows and the plan-approval workflows are still routed by lib/routing.py but ignored at dispatch time. They keep flowing through the legacy GitHub Actions paths until a follow-up PR adds their builders / handlers.

Validation

  • PYTHONPATH=.github/scripts python -m unittest discover -s .github/scripts/tests → 494 OK
  • python -m pytest control-plane/tests → 110 passed (up from 87)
  • python -m compileall control-plane/api control-plane/lib .github/scripts/oz_workflows → clean

Operator notes

scripts/vercel_install.sh is now invoked as the project's installCommand. New env vars on the Vercel project:

  • OZ_GITHUB_APP_ID — App's numeric ID.
  • OZ_GITHUB_APP_PRIVATE_KEY — App's PEM-encoded private key.
  • GITHUB_API_BASE_URL (optional) — defaults to https://api.github.com. Override for GitHub Enterprise.

The new control-plane/README.md documents the full env-var matrix and the cutover steps.

Out of scope

  • Issue-triggered workflows (triage-new-issues, respond-to-triaged-issue-comment, create-spec-from-issue, create-implementation-from-issue).
  • trigger_implementation_on_plan_approved.py and remove_stale_issue_labels_on_plan_approved.py.
  • Deleting any GitHub Actions workflow YAMLs.

The legacy GitHub Actions paths still pass all tests and continue to handle every workflow during the cutover.

@oz-for-oss
Copy link
Copy Markdown

oz-for-oss Bot commented Apr 28, 2026

@captainsafia

I'm running /oz-verify for this pull request using the repository's verification-enabled skills.

I couldn't run /oz-verify because this repository does not currently expose any skills with metadata.verification: true under .agents/skills/.

Powered by Oz

@oz-for-oss
Copy link
Copy Markdown

oz-for-oss Bot commented Apr 28, 2026

@captainsafia

I'm working on changes requested in this PR (responding to a PR conversation comment).

You can view the conversation on Warp.

I pushed changes to this PR based on the comment.

Next steps:

  • Review the changes pushed to this PR.
  • Follow up with another comment if further adjustments are needed.

Powered by Oz

Copy link
Copy Markdown
Collaborator Author

Fix WorkflowProgressComment lifecycle regression

Operators were noticing that real PR webhook deliveries dispatched a cloud agent run successfully but never produced any user-visible "Oz is starting to review this pull request..." progress comment, never updated with the session-share link, and never replaced with the final review body. This change wires the WorkflowProgressComment lifecycle across the webhook → cron seam so the comment now appears at dispatch and is driven all the way through to completion.

What changed

be520ed — foundation in canonical oz_workflows + entrypoints

  • WorkflowProgressComment.__init__ now accepts optional comment_id, run_id, oz_run_id, session_link kwargs so callers can rebuild an instance bound to the comment posted at dispatch time.
  • apply_review_result / apply_pr_comment_result / apply_verification_result / apply_issue_association_result each accept an optional progress kwarg. When provided, the helper reuses it instead of constructing a fresh one. The legacy GitHub Actions runtime contract is preserved.

e9b23b7 — builders post the "starting..." comment before dispatch

  • Each builder now constructs a WorkflowProgressComment, posts the workflow-specific opening line via progress.start(...), and stashes progress_comment_id + progress_run_id into DispatchRequest.payload_subset so the cron poller can reconstruct the same comment.
  • The review/respond/verify builders post directly via a new _start_progress_comment helper. The enforce builder hands its progress instance to enforce_pr_state_synchronously, which already drives the start line for the need-cloud-match branch.

8592b5f — cron drives the rest of the lifecycle

  • New non_terminal_handler protocol on WorkflowHandlers: fires on every pending poll. The poller invokes it with the current run, and each PR-flow handler reconstructs the WorkflowProgressComment from the persisted payload_subset and calls record_run_session_link(progress, run) so the session-share link surfaces in the comment as soon as Oz reports it. Failures are absorbed.
  • Each result_applier reconstructs the same progress instance and passes it into the workflow-specific apply_*_result so the final progress.complete / progress.replace_body edits the original comment.
  • Each failure_handler similarly reconstructs the progress and calls progress.report_error(), replacing the in-flight progress comment with the workflow-error message instead of orphaning it.

7d078d1 — vendored mirror refresh

  • bash control-plane/scripts/vercel_install.sh to mirror the canonical changes into control-plane/lib/oz_workflows/ and control-plane/lib/scripts/. Vercel ships only the committed mirror, so the install script's output is committed as part of this stack.

Tests

  • control-plane/tests/test_builders.py: each builder asserts WorkflowProgressComment(...).start(...) was called and that progress_comment_id / progress_run_id land in payload_subset.
  • control-plane/tests/test_handlers.py: each result_applier asserts the reconstructed progress is forwarded to apply_*_result(progress=...). failure_handler asserts progress.report_error() is invoked. New non_terminal_handler test asserts record_run_session_link(progress, run) is called on the rebuilt instance.
  • control-plane/tests/test_poll_runs.py: new tests for the non-terminal hook, including the failure-absorption contract.

Validation gate

Suite Result
python -m pytest control-plane/tests 120 passed, 14 subtests passed
PYTHONPATH=.github/scripts python -m unittest discover -s .github/scripts/tests 494 passed
python -m compileall control-plane/api control-plane/lib .github/scripts/oz_workflows clean

Copy link
Copy Markdown
Collaborator Author

Pushed 03a7e63 to fix the failing CI tests, plus opened #401 to fix the legacy run_tests_on_push / Run tests check that runs from main's pr-hooks.yml.

What was failing

  1. Run tests (this PR's new workflow)python -m pytest tests failed with No module named pytest. The new requirements.txt is intentionally scoped to runtime deps for the Vercel function bundle and does not include pytest / pytest-subtests.
  2. run_tests_on_push / Run tests (reusable from main)python -m unittest discover -s .github/scripts/tests fails with ModuleNotFoundError: No module named 'oz_workflows' because this PR moved oz_workflows from .github/scripts/oz_workflows to lib/oz_workflows, but the reusable workflow on main still exports PYTHONPATH=.github/scripts.
  3. Vercel — the deployment fails ~4s after starting, before any build output. See remaining issue below.

Fixes

  • In this PR (03a7e63): install pytest>=8,<9 and pytest-subtests>=0.13,<1 as a separate workflow step, and document the same install in CONTRIBUTING.md. The new Run tests check now passes (110 passed, 14 subtests passed).
  • In fix(ci): include lib in PYTHONPATH for legacy run-tests #401 (against main): extend the legacy reusable run-tests.yml's PYTHONPATH to lib:.github/scripts. Non-existent PYTHONPATH entries are ignored, so PRs that haven't migrated yet are unaffected. After fix(ci): include lib in PYTHONPATH for legacy run-tests #401 lands, the run_tests_on_push / Run tests check on this PR should go green on the next push.

Vercel deployment — likely needs project-side change

The Vercel deployment fails almost immediately, which usually means Vercel rejected the build before running any commands. The previous control-plane/vercel.json and the new root vercel.json are byte-for-byte identical aside from their location, so the relocation itself looks fine.

The most likely cause is the Vercel project's Root Directory setting (Project Settings → General → Root Directory) — if it's still pinned to control-plane, that path no longer exists on this branch and the deployment will fail before doing anything. Updating that setting to the repo root (or removing it) should unblock the deploy. I don't have access to the Vercel dashboard to verify or change this; happy to follow up if you confirm the setting.

captainsafia and others added 28 commits April 29, 2026 17:57
… them

The previous setup relied on a vercel.json installCommand that copied
.github/scripts/oz_workflows and the four PR-flow entrypoints into
control-plane/lib/ at build time. That approach assumed Vercel would
clone the entire repo onto the build runner, but Vercel's project
root is set to control-plane/ and Vercel does not pull files outside
the project root by default. The install script silently couldn't
locate the source on the build runner, so the function bundle was
deployed without oz_workflows, producing:

  {"error": "webhook runtime not ready: No module named 'oz_workflows'"}

This commit checks the mirrored copies into the branch directly so
they ship with the function bundle regardless of Vercel's source
inclusion settings. The vendored copies live at
control-plane/lib/oz_workflows/ and control-plane/lib/scripts/ and
are no longer in .gitignore.

The vercel.json installCommand is dropped so Vercel uses its default
'pip install -r requirements.txt' for runtime deps.

scripts/vercel_install.sh is retained as a local refresh helper that
contributors run manually after editing .github/scripts/oz_workflows
or any of the four PR-flow entrypoints. CI does not run it; Vercel
does not run it.

A future improvement worth tracking: a pre-commit hook (or CI job)
that fails if control-plane/lib/oz_workflows is out of sync with
.github/scripts/oz_workflows, so contributors cannot merge without
running the refresh.

Co-Authored-By: Oz <oz-agent@warp.dev>
…before dispatch

The Vercel webhook handler dispatched cloud agent runs with bare skill
names like 'review-pr', 'implement-issue', and 'verify-pr', which the
Oz API rejects with:

  invalid skill_spec format: missing ':' separator.
  Expected format: 'repo:skill' or 'org/repo:skill'

The legacy oz_workflows.oz_client.skill_spec resolves these by
checking the local filesystem, but the Vercel function has no
filesystem access to the skill files so it cannot use that path.

This change adds cloud_skill_spec(skill_name) in
control-plane/lib/dispatch.py that formats a bare skill name into

  <workflow_code_repo>:.agents/skills/<name>/SKILL.md

defaulting <workflow_code_repo> to 'warpdotdev/oz-for-oss' (the repo
that hosts the bundled core skills) and allowing forks to override
via the WORKFLOW_CODE_REPOSITORY env var. dispatch_run now calls
cloud_skill_spec on request.skill_name before passing it to the Oz
SDK runner, so the skill arrives at the API in the required form.

Repo-local override skills (e.g. 'review-pr-local' in the consuming
repo) remain referenced inside the prompt body via the existing
oz_workflows.repo_local.format_repo_local_prompt_section helper; this
change only fixes the dispatch-side core skill spec.

Adds 7 tests for cloud_skill_spec covering the default repo, the
env-var override, the kwarg override, the already-qualified
pass-through, the SKILL.md suffix path, the empty-string case, and
the None-skill dispatch path.

Co-Authored-By: Oz <oz-agent@warp.dev>
…reconstructed by callers

The Vercel control plane needs to drive the WorkflowProgressComment
lifecycle across the webhook → cron seam: the webhook posts the
'starting...' comment at dispatch time and the cron poller updates the
same comment as the run progresses. To support that without forking the
helper, two changes are needed:

- WorkflowProgressComment now accepts optional comment_id, run_id,
  oz_run_id, and session_link kwargs. Callers (the cron's result_applier
  and failure_handler) construct an instance bound to the comment posted
  at dispatch time so progress.complete / progress.replace_body /
  progress.report_error edit that exact comment instead of falling back
  to the workflow-prefix lookup. Defaults preserve the legacy GitHub
  Actions runtime contract.
- apply_review_result / apply_pr_comment_result / apply_verification_result /
  apply_issue_association_result now accept an optional progress kwarg.
  When supplied, the helper reuses the caller's WorkflowProgressComment
  instead of constructing a fresh one. Production GHA callers that omit
  progress see no behavior change.

Co-Authored-By: Oz <oz-agent@warp.dev>
Builders now drive the user-facing progress comment lifecycle that the
legacy GitHub Actions `main()` entrypoints used to: each builder
constructs a WorkflowProgressComment for the originating issue/PR,
posts the workflow-specific 'starting...' status line, and stashes
progress_comment_id + progress_run_id onto DispatchRequest.payload_subset
so the cron poller can reconstruct the same instance when the run
terminates.

The review-pr / respond-to-pr-comment / verify-pr-comment builders post
the start comment directly via _start_progress_comment. The
enforce-pr-issue-state builder hands its progress instance to the
synchronous helper, which already drives the start line for the
need-cloud-match branch.

Without this, real PR webhook deliveries dispatched a cloud agent run
but never produced any user-visible 'Oz is starting to review...' /
'/oz-verify is running...' comments, and operators had no way to
follow run progress.

Co-Authored-By: Oz <oz-agent@warp.dev>
The cron poller now keeps the originating progress comment in sync
with the run's lifecycle:

- A new non_terminal_handler protocol on WorkflowHandlers fires on every
  pending poll. Each PR-flow handler reconstructs the
  WorkflowProgressComment from the persisted payload_subset
  (progress_comment_id / progress_run_id) and calls
  record_run_session_link so the session-share link surfaces in the
  user-visible comment as soon as Oz reports it. Failures are absorbed.
- Each result_applier reconstructs the same progress instance and passes
  it through to the workflow-specific apply_*_result helper so the final
  progress.complete / progress.replace_body call edits the comment
  posted at dispatch time instead of creating a sibling comment.
- Each failure_handler also reconstructs the progress instance and
  invokes progress.report_error, producing an in-place error message
  rather than orphaning the in-flight progress comment.

Together with the builder-side start, this restores the user-facing
'Oz is starting to review...' → '...session link...' → 'Oz completed
the review' progression on the originating PR / issue when runs are
dispatched through the Vercel control plane.

Co-Authored-By: Oz <oz-agent@warp.dev>
Mirrors the helpers + apply-result signature changes from .github/scripts/
into control-plane/lib/. The vendored copies are the canonical
Vercel-side source — Vercel does not run scripts/vercel_install.sh,
so any edit to .github/scripts/ only reaches the function bundle
through this committed mirror.

Co-Authored-By: Oz <oz-agent@warp.dev>
…GH Actions

Move the webhook + cron control plane out of `control-plane/` into the
repository root so the Vercel function bundle reads `api/`, `lib/`,
`tests/`, `vercel.json`, and `requirements.txt` directly without a
mirror step. The legacy `control-plane/scripts/vercel_install.sh`
mirror is no longer needed: `lib/oz_workflows/` and `lib/scripts/` are
now the only canonical copies.

PR-driven bot behavior (`review-pull-request`, `enforce-pr-issue-state`,
`respond-to-pr-comment`, `verify-pr-comment`) now flows exclusively
through the webhook. The matching GitHub Actions wrappers and Python
entrypoints have been deleted, along with the `pr-hooks.yml` umbrella
workflow that used to fan them out:

- .github/workflows/{enforce-pr-issue-state, pr-hooks,
  respond-to-pr-comment*, review-pull-request, verify-pr-comment*}.yml
- .github/scripts/{enforce_pr_issue_state, resolve_review_context,
  respond_to_pr_comment, review_pr, verify_pr_comment}.py
- The accompanying tests under .github/scripts/tests/

Issue-triggered (`triage`, `create-spec-from-issue`,
`create-implementation-from-issue`, `respond-to-triaged-issue-comment`,
`comment-on-unready-assigned-issue`, `comment-on-ready-to-{spec,
implement}`) and plan-approval (`comment-on-plan-approved`,
`trigger-implementation-on-plan-approved`,
`remove-stale-issue-labels-on-plan-approved`) workflows stay on the
GitHub Actions delivery path because they clone the repo, push
branches, and open PRs from inside the runner. The router in
`lib/routing.py` deliberately drops `issues` events and `issue_comment`
events on plain issues so the webhook does not double-fire alongside
the workflow.

Other supporting changes:

- Convert `run-tests.yml` from a `workflow_call` shim into a standalone
  PR-triggered workflow that runs both the webhook tests
  (`python -m pytest tests`) and the helper unit tests
  (`PYTHONPATH=lib:.github/scripts python -m unittest discover -s
  .github/scripts/tests`).
- Update `.github/actions/run-oz-python-script` to set
  `PYTHONPATH=lib:.github/scripts` and install dependencies from the
  top-level `requirements.txt` so the issue-triggered + self-improvement
  workflows can still resolve `oz_workflows` after the move.
- Update `.github/actions/setup-oz-python` to default to the top-level
  `requirements.txt`.
- Refresh `README.md`, `CONTRIBUTING.md`, and `docs/platform.md` to
  describe the two delivery surfaces, the routing split, and the new
  paths.

Validation:
- python -m pytest tests \u2192 110 passed, 14 subtests passed
- PYTHONPATH=lib:.github/scripts python -m unittest discover \\
    -s .github/scripts/tests \u2192 362 passed

Co-Authored-By: Oz <oz-agent@warp.dev>
The new `run-tests.yml` workflow runs `python -m pytest tests` against
the webhook control-plane test suite, but pytest is intentionally not
listed in `requirements.txt` (those are runtime dependencies for the
Vercel function bundle). The CI job therefore failed with
`No module named pytest`.

Install the test-only dependencies (`pytest` + `pytest-subtests`) in
the workflow before invoking pytest, and document the same install in
`CONTRIBUTING.md` so local setups stay aligned with CI.

Validation: 110 passed, 14 subtests passed (python -m pytest tests)

Co-Authored-By: Oz <oz-agent@warp.dev>
Migrates the triage-new-issues workflow off the GitHub Actions delivery
surface and onto the Vercel webhook + cron control plane that already
serves the PR-driven workflows in this branch.

- Routing: issues.opened on a non-triaged issue routes to
  triage-new-issues; plain issue_comment events route to triage when
  they carry @oz-agent on a non-triaged issue or are a needs-info
  reply from the original reporter. @oz-agent mentions on triaged
  issues stay on the GitHub Actions respond-to-triaged path.
- Builder: build_triage_request gathers issue + dedupe + triage-config
  context via PyGithub, posts the WorkflowProgressComment start line,
  and stuffs progress_comment_id / progress_run_id onto payload_subset
  so the cron poller can edit the same comment.
- Handler: build_triage_handlers wires up load_triage_artifact, the
  cloud-mode applier, the failure handler, and the non-terminal
  session-link refresh.
- Cloud-mode helpers: lib/scripts/triage_new_issues.py now exposes
  TriageContext, gather_triage_context, build_triage_prompt_for_dispatch,
  and apply_triage_result_for_dispatch alongside the legacy main()
  entrypoint and the pure helpers consumed by the unit tests.
- Cleanup: deletes .github/workflows/triage-new-issues{,-local}.yml,
  the .github/scripts/triage_new_issues.py shim, and moves the triage
  unit tests under tests/.
- Docs: README, api/cron.py, and lib/routing.py docstrings now describe
  the triage delivery path.
- Tests: tests/test_routing.py covers the new issues + plain-issue
  routing; tests/test_builders.py and tests/test_handlers.py cover the
  new builder and handler; the relocated tests/test_triage.py preserves
  the existing pure-helper coverage.

Co-Authored-By: Oz <oz-agent@warp.dev>
The pinned uv version (0.11.7 / 0.11.8) in uv.toml was too strict for
Vercel's bundled uv (0.10.11) and caused production deploys to fail
with 'Required uv version does not match the running version'.
Removing uv.toml lets each environment use whatever uv it ships with;
GitHub Actions can pin via the action's own input if needed.

Also extend .gitignore with the local-only artifacts that have been
showing up in working trees: .vercel CLI output (no trailing slash so
it matches both file and dir), .env*.local for the per-environment
secret files, and .DS_Store from macOS.

Co-Authored-By: Oz <oz-agent@warp.dev>
Previously the webhook routing skipped issues that already carried the
'triaged' label: 'issues.opened' deliveries returned None, and
'@oz-agent' mentions on triaged issues were left to the legacy
'respond-to-triaged-issue-comment' GitHub Actions workflow. That
workflow in turn excludes issues with 'ready-to-spec' or
'ready-to-implement' (see .github/workflows/respond-to-triaged-issue-comment.yml:30),
so issues that progressed past triage fell into a routing gap and
never re-triaged on new comments.

Drop both gates: every 'issues.opened' delivery now triggers triage
(useful when an issue is reopened or imported with prior labels), and
every '@oz-agent' mention on a non-PR issue routes to triage so the
bot picks up new context from the conversation regardless of the
issue's lifecycle stage.

Operators can remove the 'respond-to-triaged-issue-comment' GitHub
Actions workflow once they're comfortable letting triage handle every
mention; for now leaving it in place will simply cause a duplicate
inline-response comment alongside the triage progress comment for
'triaged'-but-not-'ready' issues.

Updates two existing tests to assert the new behavior and adds two
new tests covering the 'ready-to-implement' label path.

Co-Authored-By: Oz <oz-agent@warp.dev>
Resolves #399. The PR review workflow used to request review from
every matching stakeholder (capped at 3) by iterating the agent's
recommended_reviewers list in order. That over-notified maintainers
and gave the assignment no randomness, so the same person tended to
get pinged on every PR that touched their files.

This change reworks _normalize_reviewer_logins to:

- Build the full eligible candidate pool (preserving the existing
  filtering: dedup, strip @-prefix, drop the PR author, require
  membership in .github/STAKEHOLDERS when allowed_logins is set).
- Uniformly sample sample_size logins from that pool using
  random.sample (without replacement).

The default sample_size is the new _REVIEWER_SAMPLE_SIZE = 1
constant. Callers can pass a different size and an injected
random.Random instance for deterministic tests.

Also updates the agent-facing prompt (in both gather_review_context
and the legacy main() path) to clarify that the workflow uniformly
samples exactly one reviewer from the candidates the agent returns,
so the agent should provide a meaningful pool rather than picking
the single 'best' candidate itself.

Adds 16 tests (NormalizeReviewerLoginsTest +
ResolveNonMemberReviewActionTest) covering: default sample size of
1, single-pick semantics, uniform distribution across the pool,
PR-author exclusion (case-insensitive), dedup, @-prefix stripping,
allowed_logins gating, empty-pool / non-list / sample-size=0 short
circuits, sample-size > pool fallback, larger explicit sample sizes,
and the APPROVE / REQUEST_CHANGES branches in
_resolve_non_member_review_action.

Co-Authored-By: Oz <oz-agent@warp.dev>
The agent now only ever takes a REQUEST_CHANGES action against a PR.
Positive verdicts are posted as plain COMMENT reviews so a human still
has to actually approve; the reviewer-request still pings a randomly
sampled stakeholder so the PR is handed off to a maintainer.

- _resolve_non_member_review_action maps verdict=APPROVE -> event=COMMENT
  while preserving recommended_reviewers; verdict=REQUEST_CHANGES is left
  as event=REQUEST_CHANGES with no reviewer request.
- apply_review_result and the legacy main() now post the GitHub review
  only when there is real content (or the event is REQUEST_CHANGES) and
  always issue the reviewer request when recommended_reviewers is set.
- The empty-feedback short-circuit now also requires no reviewers so an
  APPROVE verdict with no inline comments still pings a human.
- _format_review_completion_message no longer claims the bot approved
  the PR; the COMMENT-with-reviewers branch says I left feedback as a
  comment so a maintainer can approve.
- Updated the non-member-review prompt in both gather_review_context and
  legacy main() to explain that APPROVE will be downgraded to COMMENT
  and that a human reviewer is the one who actually approves.
- Tests in tests/test_review_pr_reviewer_sampling.py updated to assert
  the new mapping and the rewritten completion message.

Co-Authored-By: Oz <oz-agent@warp.dev>
The triage workflow now accepts a `comment_type` discriminator on
`triage_result.json` so the agent can return either a structured
triage comment or a lighter issue-thread response when answering a
follow-up question on an already-triaged issue.

- `comment_type="triage"` (default; backwards compatible) drives the
  existing structured comment with statements, follow-up questions,
  duplicate detection, and a maintainer-details expando, plus the
  label mutations applied by `apply_triage_result`.
- `comment_type="response"` drives a brief user-facing reply
  (`response_body`) with a maintainer-only Reasoning expando
  (`details`). The workflow does NOT change any labels in this mode
  so the issue's lifecycle state stays as the maintainer left it.

Routing was already correct: `@oz-agent` mentions on plain issues
route to `WORKFLOW_TRIAGE_NEW_ISSUES` regardless of triaged /
needs-info / ready-to-implement state. The routing docstrings were
updated to call out that the workflow itself picks the comment shape
from the discriminator on its result payload.

- Added `COMMENT_TYPE_TRIAGE`, `COMMENT_TYPE_RESPONSE`,
  `ALLOWED_COMMENT_TYPES`, `RESPONSE_DETAILS_SUMMARY`, and
  `RESPONSE_FALLBACK_BODY` constants.
- Added `extract_comment_type`, `extract_response_body`,
  `extract_response_details`, and `build_response_comment_body`
  helpers. Unknown / non-string `comment_type` values fall back to
  `triage` so a malformed payload never produces a half-rendered
  response.
- `apply_triage_result_for_dispatch` (cloud path) and the legacy
  `process_issue` GHA path branch on `comment_type` before calling
  `apply_triage_result`. Response mode replaces the progress comment
  with the response shape and skips all label mutations.
- The triage prompt now documents both shapes and tells the agent
  when to pick each one.
- Tests cover the discriminator extraction, response field
  normalization, response comment markdown layout, and the
  apply-result dispatch (response mode skips labels; unknown
  `comment_type` falls back to triage and applies labels).

Co-Authored-By: Oz <oz-agent@warp.dev>
The README has grown to ~260 lines mixing repository layout, webhook
flow, GitHub Actions plumbing, onboarding instructions, and local
development notes. Cut it back to a short intro plus a documentation
index, and move the moved content into:

- docs/architecture.md — repo layout, webhook flow, GHA workflows table
- docs/onboarding.md   — GitHub App + Vercel + GHA secrets walkthrough

Local development notes already live in CONTRIBUTING.md, so the README
now links there directly instead of duplicating them.

Co-authored-by: Oz <oz-agent@warp.dev>
… members

The control plane previously refused to dispatch a respond-to-pr-comment
or verify-pr-comment run unless the triggering comment's author was an
organization member, owner, or collaborator (or passed an
`/orgs/{org}/members/{login}` probe). The bot now responds to every
human-authored comment; the only authors filtered out are automation
accounts, which the existing `is_automation_user` check already drops.

- `lib/scripts/respond_to_pr_comment.py:main()` no longer calls
  `is_trusted_commenter`. The bot-author short-circuit at the top of
  `main()` is the only filter that remains.
- `lib/scripts/verify_pr_comment.py:main()` does the same for the
  `/oz-verify` command.
- The respond-to-pr-comment prompt no longer claims the workflow has
  pre-screened the commenter as a trusted org member. The new copy
  reminds the agent to treat all fetched PR / comment content as
  untrusted (per the security rules) and to weigh maintainer comments
  more heavily than drive-by replies based on the
  `author_association` labels the fetch script returns.
- Removed the now-dead trust surface: `is_trusted_commenter` from
  `lib/oz_workflows/helpers.py`, the `urllib.parse` import that only
  existed for the org-membership probe, the standalone `lib/trust.py`
  module, and the `tests/test_trust.py` suite that tested it.

Out of scope by design (different policies, not the comment-response
gate): `enforce_pr_issue_state._is_pr_author_org_member` (auto-close
PRs without a ready issue), `review_pr._is_non_member_pr` (non-member
PRs still get the APPROVE/REQUEST_CHANGES review-action gate), and the
prompt-context filtering helpers `org_member_comments_text` /
`review_thread_comments_text` / `all_review_comments_text` (consumed
by skill scripts rather than the webhook).

Co-Authored-By: Oz <oz-agent@warp.dev>
…webhook

The Vercel control plane now owns every dispatch path that lands a
`create-spec-from-issue` or `create-implementation-from-issue` run.
`lib.routing._route_issues` was extended to handle:

- `issues.assigned` when oz-agent is the new assignee and the issue
  carries `ready-to-spec` or `ready-to-implement`.
- `issues.labeled` when the added label is one of those lifecycle
  labels and oz-agent is already in the assignees.
- `issue_comment` `@oz-agent` mentions on `ready-to-spec` /
  `ready-to-implement` issues (already migrated; cloud-mode helpers
  in `lib/scripts/create_*_from_issue.py` plus builders/handlers).

`ready-to-implement` wins over `ready-to-spec` so an issue carrying
both labels lands on the implementation workflow rather than
regenerating a spec.

The legacy GitHub Actions adapters that used to fan these triggers
out into the runtime are gone:

- .github/workflows/create-spec-from-issue-local.yml
- .github/workflows/create-spec-from-issue.yml
- .github/workflows/create-implementation-from-issue-local.yml
- .github/workflows/create-implementation-from-issue.yml
- .github/scripts/create_spec_from_issue.py (no remaining consumers)
- .github/scripts/tests/test_issue_workflow_dispatch_inputs.py

`.github/scripts/create_implementation_from_issue.py` is kept on
purpose because `trigger_implementation_on_plan_approved.py` still
imports it for the plan-approved trigger; that flow is a separate
migration.

Webhook test suite: 224 passing (9 new routing cases for the
`issues.assigned` / `issues.labeled` paths).

Co-Authored-By: Oz <oz-agent@warp.dev>
Move the three GitHub Actions workflows that fired on `pull_request_target.labeled`
for the `plan-approved` label onto the Vercel webhook:

- `comment-on-plan-approved.yml` (posts the spec-approved comment on
  the linked issue).
- `remove-stale-issue-labels-on-plan-approved.yml` (strips
  `ready-to-spec` from the linked issue).
- `trigger-implementation-on-plan-approved.yml` (dispatches a
  create-implementation cloud agent run when the linked issue carries
  `ready-to-implement` and `oz-agent` is assigned).

The router exposes a single `WORKFLOW_PLAN_APPROVED` workflow keyed
on `pull_request.labeled` with the `plan-approved` label. The
webhook handler runs `apply_plan_approved_sync` inline (analogous
to `enforce-pr-issue-state`'s sync path) which:

1. Verifies the PR is open and is a spec PR (head branch matches
   `oz-agent/spec-issue-{N}` or every changed file lives under
   `specs/`).
2. Resolves the linked issue via `resolve_issue_number_for_pr`.
3. Posts the spec-approved comment on the linked issue with a
   workflow-prefix metadata marker so retried webhook deliveries
   do not double-post.
4. Removes `ready-to-spec` from the linked issue if present.
5. Returns a structured outcome unless the issue is ready for
   implementation, in which case it returns `None` and the webhook
   falls through to `build_plan_approved_request`. That builder
   reuses `gather_create_implementation_context` and dispatches a
   cloud agent run keyed on `WORKFLOW_PLAN_APPROVED`. The cron-side
   handler is a thin alias around the existing
   `build_create_implementation_handlers` so the apply path
   (`apply_create_implementation_result`) is unchanged.

Behavior tightening: the synchronous helper gates all three side
effects on the spec-only check, so a non-spec PR labeled
`plan-approved` no longer strips `ready-to-spec` off an unrelated
issue. The legacy GHA `remove-stale-issue-labels-on-plan-approved`
workflow stripped the label regardless of spec-only status.

Webhook test suite: 241 passing (+17 new cases for plan-approved
across routing, builder, handler, sync helper, and dispatch
fallthrough).

Deletions:
- .github/workflows/comment-on-plan-approved.yml
- .github/workflows/trigger-implementation-on-plan-approved-local.yml
- .github/workflows/trigger-implementation-on-plan-approved.yml
- .github/workflows/remove-stale-issue-labels-on-plan-approved-local.yml
- .github/workflows/remove-stale-issue-labels-on-plan-approved.yml
- .github/scripts/trigger_implementation_on_plan_approved.py
- .github/scripts/remove_stale_issue_labels_on_plan_approved.py
- .github/scripts/tests/test_trigger_implementation_on_plan_approved.py
- .github/scripts/create_implementation_from_issue.py (last consumer
  was `trigger_implementation_on_plan_approved.py`).

Co-Authored-By: Oz <oz-agent@warp.dev>
…uming repo

The cloud-mode `gather_review_context` was loading
`.github/STAKEHOLDERS` from `workspace_path / '.github' / 'STAKEHOLDERS'`.
The Vercel webhook hands in `Path('/tmp')` because the consuming
repository is not checked out on the function's filesystem, so the
file never resolved and non-member-PR review requests silently lost
stakeholder enforcement: no `allowed_logins` filter on the agent's
`recommended_reviewers` list, and no `Stakeholders (from
`.github/STAKEHOLDERS`):` block in the prompt.

The triage cloud path already loaded STAKEHOLDERS via the GitHub API
(`_load_stakeholders_from_repo` in `triage_new_issues.py`); review's
cloud path missed that migration.

Promote the API-backed helpers into `oz_workflows.triage`:
- `STAKEHOLDERS_REPO_PATH = '.github/STAKEHOLDERS'` constant.
- `decode_repo_text_file(repo_handle, path)` — generic text loader
  that wraps `Repository.get_contents` and tolerates missing files,
  directory paths, and GithubException.
- `load_stakeholders_from_repo(repo_handle)` — drop-in API-backed
  counterpart to the workspace-backed `load_stakeholders`.
- Both share `_parse_stakeholders_lines` so the workspace and API
  surfaces produce byte-for-byte identical entries.

Update `gather_review_context` to call the API-backed loader so the
file is read from the repository that triggered the webhook. The
existing private `_load_stakeholders_from_repo` and
`_decode_repo_text_file` in `triage_new_issues.py` become thin
aliases over the moved-out helpers so test fixtures patching those
private names keep working.

Webhook test suite: 249 passing (+8 cases for the API-backed loader).

Co-Authored-By: Oz <oz-agent@warp.dev>
… API

The Vercel webhook does not check out the consuming repository, so
the cloud-mode `gather_*_context` helpers that resolved the
repo-local companion skill or the directory-spec context through
`workspace_path = Path('/tmp')` always degraded to empty results:
the prompt lost the repo-local override section, and any issue
without an approved spec PR lost its `specs/GH<N>/` content.

Add API-backed counterparts and wire them into the cloud paths:

- `oz_workflows/repo_local.py`:
  - `repo_local_skill_path_for_dispatch(repo_handle, name)` reads
    `.agents/skills/<name>-local/SKILL.md` via
    `decode_repo_text_file` and returns the repo-relative path
    string when the body is non-empty.
  - `format_repo_local_prompt_section` accepts `Path | str` so
    cloud and legacy callers share one formatter.
- `oz_workflows/helpers.py`:
  - `read_repo_spec_files(repo_handle, issue_number)` mirrors
    `read_local_spec_files` via the API.
  - `resolve_spec_context_for_issue_via_api` /
    `resolve_spec_context_for_pr_via_api` walk both branches
    (approved spec PR + `specs/GH<N>/{product,tech}.md`) without
    touching the local filesystem.

Wire the helpers into the cloud paths:

- `gather_review_context` uses
  `repo_local_skill_path_for_dispatch` for the companion section
  and `resolve_spec_context_for_pr_via_api` for the spec text.
  The legacy subprocess-based `_resolve_spec_context_text_for_pr`
  is replaced with a `_format_spec_context_text` helper that
  renders the resolved dict.
- `gather_pr_comment_context` and
  `gather_create_implementation_context` switch to the API spec
  resolver.
- `build_triage_prompt_for_dispatch` accepts `repo_handle` and
  resolves the `triage-issue-local` and `dedupe-issue-local`
  companions through the API.
- `lib.builders.build_triage_request` passes `repo_handle`
  through.

Webhook test suite: 265 passing (+16 cases for the API-backed
repo-local skill and spec-context helpers).

Co-Authored-By: Oz <oz-agent@warp.dev>
The triage agent's SME (subject-matter expert) suggestions were never consumed downstream: there's no `extract_sme_candidates` helper, no surfacing in comments/labels/reviewers, and the only production reference was the schema example inside the prompt.

Remove the goal line, JSON schema field (`sme_candidates`), `Repository Stakeholders` prompt section, and the `stakeholders_text` parameter that fed it from `build_triage_prompt`. Drop `stakeholders_text` from the cloud-mode `TriageContext`, the `gather_triage_context` / `build_triage_prompt_for_dispatch` flow, and the legacy `process_issue` / `main` chain. Update `.agents/skills/triage-issue/SKILL.md`: drop the SME workflow step (and renumber subsequent steps), the STAKEHOLDERS input line, and the `SMEs` reference in the output expectations.

The `oz_workflows.triage` helpers (`load_stakeholders_from_repo`, `format_stakeholders_for_prompt`, `STAKEHOLDERS_REPO_PATH`) and their tests stay in place because `scripts/review_pr.py` still uses them for non-member PR reviewer enforcement.

Co-Authored-By: Oz <oz-agent@warp.dev>
…to-X labels

Move the `comment-on-ready-to-implement` / `comment-on-ready-to-spec` GitHub Actions workflows into the Vercel webhook control plane. When a maintainer adds `ready-to-spec` or `ready-to-implement` to an issue without enlisting `oz-agent` as an assignee, the webhook now posts a one-shot announcement comment letting contributors know the issue is open for the matching kind of contribution and that maintainers can tag `@oz-agent` to have the bot pick up the work automatically.

- Add `WORKFLOW_ANNOUNCE_READY_ISSUE` to `lib/routing.py` and split the `issues.labeled` route: oz-agent assigned still routes to create-spec / create-implementation, oz-agent NOT assigned now routes to the new workflow instead of being dropped.

- Add `lib/scripts/announce_ready_issue.py` with `apply_announce_ready_issue_sync`. The handler is fully synchronous (no cloud-agent dispatch fallback), idempotent via the `oz-agent-metadata` marker, and emits label-specific announcement bodies that link back to `@oz-agent` and the `specs/` tree as relevant.

- Wire `sync_announce_ready_issue` into `api/webhook.py` alongside the existing `sync_enforcer` / `sync_plan_approved` paths, special-cased to short-circuit so the workflow never reaches the dispatch code path.

- Add 13 tests covering routing, sync-handler outcomes (announced / noop / skipped), and webhook short-circuit behavior.

- Delete the legacy `.github/workflows/comment-on-ready-to-{implement,spec}.yml` adapters now that the webhook owns this behavior.

Co-Authored-By: Oz <oz-agent@warp.dev>
Co-Authored-By: Oz <oz-agent@warp.dev>
Co-Authored-By: Oz <oz-agent@warp.dev>
Co-Authored-By: Oz <oz-agent@warp.dev>
Co-Authored-By: Oz <oz-agent@warp.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants