Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c99714a
feat(seidroid): add the agentic reviewer as a callable workflow
bdchatham Sep 2, 2026
da01842
feat(seidroid-review): admit an automatic pull_request review
bdchatham Sep 4, 2026
2f5fcfc
feat(seidroid-review): fail soft when the verdict cannot post, and st…
bdchatham Sep 4, 2026
2f11d75
feat(seidroid-review): acknowledge the trigger with a reaction (#72)
bdchatham Sep 5, 2026
38a1e0b
feat(seidroid-review): post each verdict as a new comment (#73)
bdchatham Sep 5, 2026
68406ee
fix(seidroid-review): read the finding counts where the driver writes…
bdchatham Sep 5, 2026
a32defa
feat(seidroid-review): answer the request with the verdict, and drop …
bdchatham Sep 6, 2026
bf507f3
feat(seidroid-review)!: remove the cross-repository target grammar (#83)
bdchatham Sep 6, 2026
543f11f
fix(seidroid-review): withdraw the eyes when the review answers (#84)
bdchatham Sep 6, 2026
19cb2fb
fix(seidroid-review): publish a check run and a comment when a review…
bdchatham Sep 6, 2026
bf0fd23
feat(seidroid-review): let the workflow own the driver version (#87)
bdchatham Sep 6, 2026
d477b7d
fix(seidroid-review): fall back to GITHUB_TOKEN for the position and …
bdchatham Sep 6, 2026
86cca74
feat(seidroid-review): review a pull request once, and let a caller a…
bdchatham Sep 6, 2026
5f5fd78
feat(seidroid-review): default to a codex scout, and record two accep…
bdchatham Sep 6, 2026
50ada30
feat(seidroid-review): publish the review check under both names (#92)
bdchatham Sep 6, 2026
5d06528
fix(seidroid-review): refuse an explicit re-review on a fork-originat…
bdchatham Sep 6, 2026
41ee3ff
feat(seidroid-review): resolve the threads a re-review closed (#90)
bdchatham Sep 6, 2026
30f5c09
chore(seidroid-review): take driver v0.15.0 (#95)
bdchatham Sep 6, 2026
d2dd154
fix(seidroid-review): gate the guard's admission on the caller's mode…
bdchatham Sep 6, 2026
bc93b4f
feat(seidroid-review): let a pull request ask for nits (#94)
bdchatham Sep 6, 2026
3544bf5
feat(seidroid-review): read every page of the thread history, and the…
bdchatham Sep 6, 2026
b1b51f8
feat(seidroid-review): post the findings as one review (#97)
bdchatham Sep 7, 2026
2f58b80
feat(seidroid-review)!: remove the allow-policies input (#101)
bdchatham Sep 7, 2026
98c2619
feat(seidroid-review): gate each superseded thread on its own replace…
bdchatham Sep 7, 2026
2f7efad
fix(seidroid-review): withdraw the reactions on a cancelled run, from…
bdchatham Sep 7, 2026
2d60411
feat(seidroid-review): widen the trigger to every comment event, and …
bdchatham Sep 7, 2026
8200805
chore(seidroid-review): take driver v0.17.0 (#104)
bdchatham Sep 8, 2026
1529742
feat(seidroid-review): default the review model to Opus 5
bdchatham Sep 8, 2026
d895e04
fix(seidroid-review): keep the 1M context window on the Opus 5 default
bdchatham Sep 9, 2026
92f74c9
fix(seidroid-review): raise the driver floor to v0.18.0
bdchatham Sep 10, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,596 changes: 3,596 additions & 0 deletions .github/workflows/seidroid-review.yml

Large diffs are not rendered by default.

88 changes: 88 additions & 0 deletions .github/workflows/workflow-test-self.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
name: Workflow tests
# The shell and jq inside seidroid-review.yml, run against a gh stub, and its job
# conditions evaluated against synthetic payloads. Nothing here reaches the GitHub
# API, so this needs no token and no permissions.
#
# Three jobs, so each check in the list names the step it covers. A job that ran two
# harnesses would report one name for two things, and one check would go red for the
# other's failure.
#
# `Place findings and resolve threads` covers a pair of steps rather than either half:
# placement records which thread each posted comment replaced, and the resolve step
# closes on that record. `The reaction steps` covers which of the three runs in which
# job state, and what each leaves on the trigger comment. `Guard the request` covers
# the admission path, from the event the workflow accepts to the collection the
# acknowledgement reaches.
on:
pull_request:
paths:
- '.github/workflows/seidroid-review.yml'
# run-guard.sh asserts what ai-assistant.yml claims of the same comment, so an
# edit there can break an invariant stated here. Without this the break lands
# on the next unrelated pull request that touches the file above, pointing at
# the wrong change.
- '.github/workflows/ai-assistant.yml'
- '.github/workflows/workflow-test-self.yml'
- 'test/seidroid-review/**'
push:
branches: [ main ]
paths:
- '.github/workflows/seidroid-review.yml'
# run-guard.sh asserts what ai-assistant.yml claims of the same comment, so an
# edit there can break an invariant stated here. Without this the break lands
# on the next unrelated pull request that touches the file above, pointing at
# the wrong change.
- '.github/workflows/ai-assistant.yml'
- '.github/workflows/workflow-test-self.yml'
- 'test/seidroid-review/**'
permissions:
contents: read
jobs:
place-findings:
name: Place findings and resolve threads
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Install the YAML reader
run: python3 -m pip install --quiet pyyaml
- name: Run the placement and resolve harness
run: test/seidroid-review/run.sh

reactions:
name: The reaction steps
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Install the YAML reader
run: python3 -m pip install --quiet pyyaml
- name: Run the reaction harness
run: test/seidroid-review/reactions.sh
# Which step runs in which job state, which is where the cancellation behaviour
# lives and which the shell harness above cannot see.
- name: Check the step conditions
run: python3 test/seidroid-review/conditions.py .github/workflows/seidroid-review.yml

guard-admission:
name: Guard the request
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v7
with:
python-version: '3.x'
- name: Install the YAML reader
run: python3 -m pip install --quiet pyyaml
- name: Run the guard harness
run: test/seidroid-review/run-guard.sh
19 changes: 19 additions & 0 deletions test/seidroid-review/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Written by run.sh: the two steps extracted from the workflow, the fixtures it
# generates, and one directory of output per case.
place.sh
resolve.sh
fx/gen/
out/
# Written by reactions.sh: the three reaction steps, and one directory per case.
ack.sh
answer.sh
withdraw.sh
out-reactions/
# Written by run-guard.sh: the five steps it extracts from the workflow. Two of
# them are steps reactions.sh also extracts, under names of their own so neither
# harness can overwrite the other's extraction.
refuse.sh
parse.sh
admit.sh
guard-ack.sh
guard-answer.sh
190 changes: 190 additions & 0 deletions test/seidroid-review/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Workflow tests

Three harnesses over `.github/workflows/seidroid-review.yml`. All three read the
steps out of the YAML on every run, so none can pass against a stale copy.

```sh
test/seidroid-review/run.sh # placement and thread resolution
test/seidroid-review/reactions.sh # the three reaction steps
test/seidroid-review/run-guard.sh # the guard, and the reaction collection
python3 test/seidroid-review/conditions.py .github/workflows/seidroid-review.yml
```

Each needs `bash`, `jq`, and `python3` with PyYAML, and exits non-zero on the
first failed assertion count.

`extract.py` is shared. It reads a step's `run:` block and a workflow-level env
key out of the YAML, by step name or step id.

`reactions.sh` and `run-guard.sh` both extract `Acknowledge the trigger` and
`Answer the request`, and they ask different things of them: `reactions.sh` asks
which step runs in which job state and what it leaves on the comment,
`run-guard.sh` asks which REST collection the URL reaches. Each writes its own
extraction — `ack.sh` and `answer.sh` against `guard-ack.sh` and
`guard-answer.sh` — so running both cannot have one overwrite the other.

## `Place findings on the code` and `Resolve the threads this review closed`

Runs both steps under `bash`, against a `gh` stub, and checks what they posted,
counted and closed. It prints a table of one row per case.

Both steps are in one harness because they are one behaviour. Placement records
which thread each posted comment replaced; the resolve step closes a thread on
finding its id in that record. A harness that ran only one of them could not
tell whether the record it wrote is the record the other reads.

The extractor runs twice, once per step, and the two `FINDING_MARKER` readings
are asserted equal: placement stamps a comment with it and the resolve step
recognises a thread by it.

`bin/gh` goes on `PATH` ahead of the real `gh`. It logs every call, serves
fixture JSON through the step's own `jq`, keeps the request body the step sent,
and decides per case whether a call succeeds. `STUB_*` variables in `run_case`
and `run_resolve` select the fixtures and the answers.

### The fixtures

`fx/files*.json` are `GET /compare` responses. One JSON object each: compare
paginates its commits, and a second page carries no `files` key, so the step
reads one page.

- `files.json` — four files, two with a patch, two without
- `files-short.json` — the same diff with `pkg/b.go` missing, which is what a
truncated response looks like
- `files-stale.json` — a different commit's diff, for the pushed-head case

`fx/line-ok.tsv` and `fx/file-ok.txt` list the `path`/`side`/`line` and the
paths the stub accepts. A finding outside them is refused, which is how the
per-finding ladder is exercised.

`fx/superseded*.json` are findings files carrying the driver's `supersedes`
linkage. `fx/all-placeable.json` carries it on no finding, which is what a
driver older than the linkage writes.

`fx/check-*.json` are the driver's `check.json`, one per thread plan the resolve
step has to act on. The review threads themselves are generated in `run.sh`,
because every body has to open with the marker the workflow defines now: two
pages, and four threads that fail this step's own tests — the other identity, a
foreign account, no marker, and a marker quoted mid-body.

## The reaction steps

`reactions.sh` runs `Acknowledge the trigger`, `Answer the request` and
`Withdraw the reactions on a cancelled run` against `bin-reactions/gh`, which keeps
the reaction list a comment carries and serves it through the step's own `jq`. Each
case reports the exact set left on the trigger comment.

No case names the step it runs. `conditions.py --select` names it, from the job state
and from `Post the verdict`'s outcome, so the two harnesses cannot drift and a case
cannot quietly stop exercising the step it claims to.

Each case declares **two** job states, because a cancellation has a moment: the state
the runner reached `Answer the request` in, and the state it reached the withdrawal step
in. `success>cancelled` is a cancellation that arrived after the answer. The answer step
then posts its own thumb, so a late-cancellation case proves the outcome through the
steps rather than placing a reaction by hand.

A case's `posted` column is `Post the verdict`'s own output: `true` when its comment
landed, `false` when the POST was refused, unreported when that step never ran. Not its
outcome. That step tolerates a refused POST and exits 0 either way, so its outcome reads
`success` on a verdict that never landed, and a thumb kept on that reading would stand
for a review nobody can see.

Four properties every case holds to. A human's reaction is never withdrawn. Neither is
a reaction of this bot's that no step here chooses, so a `rocket` some other workflow
left survives. A thumb that answers a verdict already on the pull request survives a
later cancellation. And a run cancelled before it reached `Answer the request` takes
only the eyes: a thumb on the comment then belongs to an EARLIER run, whose verdict may
still stand.

## The step conditions

`conditions.py` covers what a shell harness cannot see. A step condition decides which
reaction step runs in which job state, and that is where the cancellation behaviour
lives. `Answer the request` reads a conclusion and may thumb the request, so it must
skip a cancelled run. The withdrawal step must take a cancelled run, unless
`Post the verdict` landed its comment.

It models the runner's own rule that a condition naming none of
`always`/`cancelled`/`failure`/`success` is stored as `success() && (...)`, and treats
any term it does not decide as unknown rather than as false.

Three checks are stated over the file rather than over a table, so they cover a step
added later. The two that walk steps walk **every job's raw steps list**, so an unnamed
step is not invisible to them, and both search the **whole step** rather than one key:

- No step that can run on a cancelled job may reach `check_path` or
`verdict_produced`. An inline `${{ steps.drive.outputs.check_path }}` in `run:`,
`with:` or `if:` reaches the same value an `env:` key would.
- Every `steps.<id>` a step reads must be a real id on an earlier step. Delete the id,
or move the reader in front of it, and the read is empty forever with no error
anywhere — and a harness that takes the value as an argument cannot notice.
- The withdrawal is the **last** step of the review job. The runner evaluates a
condition when it reaches the step, so any step after the withdrawal is a step during
which a cancellation leaves the eyes standing. Checking the position covers a step
appended later; mutating one ordering would not.

No check needs telling where to look. A check that has to be pointed at a step is not
stated over the file.

## The guard, and the two steps that react

Runs the request-admission path under `bash` against a `gh` stub of its own, and
evaluates the shipped job conditions against synthetic event payloads. Five
steps are read: `Refuse an event this workflow does not handle`, `parse`, `Admit
the request`, `Acknowledge the trigger` and `Answer the request`.

`gha.py` covers what a script harness cannot see. A job condition and a step's
`env:` mapping are GitHub expressions, and both decide which payload field a
request is read from, so both are evaluated here rather than restated. It models
four GitHub semantics the conditions rest on — case-insensitive string
comparison, `||` and `&&` yielding one operand each, **both short-circuiting**,
and `contains` over an array testing membership — and `--selftest` checks each
one. That model is read from GitHub's published expression semantics: nothing in
this directory calls a runner.

Short-circuiting is the one to be careful with. The runner's Or and And nodes
return on the first truthy or falsy operand and never evaluate the rest, so a
`fromJSON` an operand nothing reaches would refuse never runs. A model that
evaluated eagerly reports a failure the runner does not have, and one assertion
here stated the opposite of what a real event does before this was modelled.

Four modes:

```sh
gha.py <workflow> <job> <context.json> # the job's if:, as true or false
gha.py --env <workflow> <step> <key> <ctx.json> # what a step's env key resolves to
gha.py --input <workflow> <input> <field> # a declared workflow_call input field
gha.py --selftest # the expression model itself
```

One group reaches outside this file. `ai-assistant.yml` answers the same comments
and reserves the exact `@seidroid review` body for the reviewer, so the last group
evaluates that workflow's own reply condition beside the parse and records which
tool answers each body. Every body is checked on all three comment events, because
the assistant has a branch each and this workflow now answers all three: a helper
naming one event would measure the division on the path that already had it and
infer the two this workflow adds. Two bodies both tools answer; the group says
which and why.

`ai-assistant.yml` is in `workflow-test-self.yml`'s `paths:` filter for that
reason. Without it an edit there breaks an invariant stated here, and the break
lands on the next unrelated pull request that touches `seidroid-review.yml`.

`bin-guard/gh` logs every call, serves the answer the case chose through the
step's own `--jq` filter, and tells the fork check from the label check by the
filter each sends. A failed read prints nothing and exits non-zero, which is the
shape `Admit the request` is written against: it captures stdout, so an empty
capture is what tells the fork check and the once-per-PR gate that nobody
answered. `bin/gh` beside it files an error body instead, because the placement
step reads one.

### The fixtures

There are none. A guard case turns on six payload fields and five API answers,
so each is built in the run from `STUB_*` and context arguments, where the case
that chose it can be read beside the assertion it drives.

`STUB_TEAM`, `STUB_ORIGIN`, `STUB_LABELS`, `STUB_REVIEWS`, `STUB_COMMENTS` and
`STUB_REACTIONS` choose what the stub answers; `FAIL` on any of them is a read
that nobody answered.
Loading
Loading