Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
52 changes: 48 additions & 4 deletions .github/workflows/dotbot-act.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,55 @@ jobs:
# .git/config. Pin the action to a trusted ref below instead.
ref: ${{ github.event.pull_request.head.sha || format('refs/pull/{0}/head', github.event.issue.number) }}
token: ${{ secrets.REPO_ACCESS_TOKEN }}
# DOTBOT_ACT_MODEL (org or repo VARIABLE), when set, OVERRIDES the act
# model that would otherwise come from `act.model` in the in-repo
# .openrouter-review.yml. Like DOTBOT_REVIEW_MODELS on the review side,
# this is a REPLACEMENT of the act block, not an addition: the variable is
# authoritative whenever it is set, so one place (org/repo variables)
# drives the act model for every consuming repo. When it is unset, nothing
# is generated and the in-repo file (or the action default) stands.
#
# Unlike the review workflow, which re-renders .openrouter-review.yml in
# place (review never commits), the generated act config is written
# OUTSIDE the checkout: act mode pushes commits to the PR branch, so a
# generated file sitting in the worktree could be staged by the agent's
# commit and overwrite the repo's own config. Writing it to RUNNER_TEMP
# and passing it via `config_path` leaves the committed config untouched.
- name: Override act model from DOTBOT_ACT_MODEL (when set)
id: act_model
if: ${{ vars.DOTBOT_ACT_MODEL != '' }}
env:
ACT_MODEL: ${{ vars.DOTBOT_ACT_MODEL }}
run: |
set -euo pipefail
model="${ACT_MODEL:-}"
model="${model//[[:space:]]/}"
if [ -z "$model" ]; then
echo "::warning::DOTBOT_ACT_MODEL set but empty — using in-repo act model"
exit 0
fi
# OpenRouter slugs: letters/digits plus . _ : / - @ + and the `~`
# "latest" alias prefix (e.g. ~deepseek/deepseek-flash-latest, the
# value this repo's org variable actually carries). Anything else is
# not a slug, so fail loudly instead of writing a config the action
# would choke on.
if [[ ! "$model" =~ ^[A-Za-z0-9._:/~@+-]+$ ]]; then
echo "::error::DOTBOT_ACT_MODEL '$model' is not a valid OpenRouter model slug"
exit 2
fi
# Single-quoted YAML scalar keeps unusual-but-legal slugs intact.
printf "act:\n model: '%s'\n" "${model//\'/\'\'}" > "$RUNNER_TEMP/dotbot-act-model.yml"
echo "config_path=$RUNNER_TEMP/dotbot-act-model.yml" >> "$GITHUB_OUTPUT"
echo "Rendered act model config from DOTBOT_ACT_MODEL:"
cat "$RUNNER_TEMP/dotbot-act-model.yml"
- name: dotbot autonomous edits
# Trusted main ref, NOT the PR-head checkout. Update this SHA on each
# release (matches the v1/latest tag).
uses: wezell/openrouter-code-review-action@d68edbb67a3365b53b41d9f05406c084bf338295
# Trusted main ref, NOT the PR-head checkout. This repo is the canonical
# home, so the pin points at itself (kept in lockstep with the review
# workflow). bbe2345 = v1.2.0 / v1 / latest.
uses: dotCMS/openrouter-code-review-action@bbe2345626d658ba630921faa6be8166cd421bda
with:
mode: act
# DOTBOT_ACT_MODEL wins when set; otherwise the in-repo config file.
config_path: ${{ steps.act_model.outputs.config_path || '.openrouter-review.yml' }}
openrouter_api_key: ${{ secrets.OPENROUTER_API_KEY }}
debug_level: 1
debug_level: 1
18 changes: 17 additions & 1 deletion .github/workflows/dotbot-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,27 @@ jobs:
# Trusted ref, NOT the PR-head checkout. Update this SHA on each
# release (matches the v1/latest tag) — enforced by
# tests/test_module_coverage.py.
uses: wezell/openrouter-code-review-action@d68edbb67a3365b53b41d9f05406c084bf338295
#
# This repo is the canonical home (releases are cut here by
# auto-release.yml), so the pin points at itself: the upstream
# wezell/openrouter-code-review-action has no release carrying the
# github_approval_token input, and the old wezell@d68edbb pin predated it,
# so GitHub silently dropped the input and auto-approval never fired.
# bbe2345 = v1.2.0 / v1 / latest.
uses: dotCMS/openrouter-code-review-action@bbe2345626d658ba630921faa6be8166cd421bda
with:
mode: review
openrouter_api_key: ${{ secrets.OPENROUTER_API_KEY }}
model: deepseek/deepseek-v4-pro-0813
# Auto-approval when every reviewer reports "patch is correct". This
# repo has no DOTBOT_GITHUB_USER_PAT machine-user PAT (see the note
# above), so the workflow token is used: the approval lands as
# github-actions[bot], which the repo permits
# (Settings → Actions → "Allow GitHub Actions to create and approve
# pull requests"). A PAT is still the better choice where one exists
# — it attributes the approval to a named user, and a bot cannot
# approve a PR the bot itself authored.
github_approval_token: ${{ github.token }}
Comment thread
wezell marked this conversation as resolved.
Comment thread
wezell marked this conversation as resolved.
reasoning_effort: medium
web_search_mode: cached
debug_level: 1
Expand Down
48 changes: 39 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,19 +39,27 @@ jobs:

### Automatic PR Approval

If the `DOTBOT_GITHUB_USER_PAT` secret is set (passed via the
`github_approval_token` input), the action approves the PR **as the PAT's
user** — e.g. `dotCMS-Machine-User` — whenever *every* reviewer model in the
roster concludes `Overall: patch is correct`. The approval is idempotent per
head commit: re-running the workflow on the same SHA will not spam duplicate
approvals.

If the secret is not set, or any reviewer model reports a finding, the action
If a token is passed via the `github_approval_token` input — normally the
`DOTBOT_GITHUB_USER_PAT` secret owned by a machine user such as
`dotCMS-Machine-User` — the action approves the PR **as that user** whenever
*every* reviewer model in the roster concludes `Overall: patch is correct`. The
approval is idempotent per head commit: re-running the workflow on the same SHA
will not spam duplicate approvals.

If the input is unset, or any reviewer model reports a finding, the action
simply posts its review comments and skips the approval step. Approval
submission failures are logged as warnings and never fail the review run.

> The PAT needs `pull-requests: write` scope, and its user must differ from
> the PR author (GitHub rejects approvals from the PR author).
>
> Where no machine-user PAT is available, `github_approval_token: ${{ github.token }}`
> works too, provided the repo allows it (Settings → Actions → "Allow GitHub
> Actions to create and approve pull requests"). Installation tokens cannot read
> `GET /user`, so the action does not try to resolve an identity for them: the
> approval is submitted as `github-actions[bot]` and idempotency falls back to a
> marker in the review body. A bot cannot approve a PR the bot itself authored,
> so a PAT remains the better choice where one exists.

## Act on `/dotbot` Comments

Expand Down Expand Up @@ -162,6 +170,28 @@ Each reviewer runs the full pipeline in sequence:
Review state (resume threads, SHA-delta scope, cache keys) is isolated per
model, so changing the roster won't reuse the wrong review.

### Org/Repo Variables (`vars.DOTBOT_*`)

The self-hosted workflows read two GitHub **Variables** (org- or repo-level),
so one place can pin the models for every consuming repo without editing its
config file:

| Variable | Mode | Effect |
|----------|------|--------|
| `DOTBOT_REVIEW_MODELS` | review | Comma-separated roster. First entry becomes the primary `review.model`, the rest become `review.models` ("the fight"). Replaces the in-repo `review:` block for that run. |
| `DOTBOT_ACT_MODEL` | act | Model slug for `/dotbot` edits, including OpenRouter's `~` "latest" aliases (e.g. `~deepseek/deepseek-flash-latest`). Replaces the in-repo `act:` block for that run. |

Both are optional. With a variable unset, nothing is generated and the in-repo
`.openrouter-review.yml` (or the action default) applies. When set, the variable
is authoritative — a variable set to a malformed slug fails the run instead of
silently falling back.

The act override is rendered to `$RUNNER_TEMP/dotbot-act-model.yml` and passed
through the `config_path` input rather than written into the checkout: act mode
pushes commits to the PR branch, so a generated file inside the worktree could
otherwise be swept into the agent's commit. Review renders in place, since
review never commits.

Override the file path with the `config_path` action input or
`OPENROUTER_REVIEW_CONFIG` env var (e.g. `ci/openrouter-models.yml`). Per-call
action inputs (`model:`, `reasoning_effort:`) still win over the file when
Expand All @@ -184,7 +214,7 @@ call time; `cached` and `disabled` skip the live web fetch.
| `model_timeout_seconds` | Wall-clock budget per reviewer model pass; 0 disables | `900` |
| `web_search_mode` | `disabled` / `cached` / `live` | `live` |
| **Review-only** | | |
| `github_approval_token` | GitHub user PAT (e.g. `secrets.DOTBOT_GITHUB_USER_PAT`) owned by a machine user such as `dotCMS-Machine-User`. When every reviewer model reports `Overall: patch is correct`, dotbot approves the PR as that user; when unset (or any model dissents) reviews post as normal comments with no approval | *(unset)* |
| `github_approval_token` | Token used to approve the PR when every reviewer model reports `Overall: patch is correct`. Normally a machine-user PAT (e.g. `secrets.DOTBOT_GITHUB_USER_PAT` for `dotCMS-Machine-User`); `${{ github.token }}` also works and approves as `github-actions[bot]`. When unset (or any model dissents) reviews post as normal comments with no approval | *(unset)* |
| `additional_prompt` | Extra reviewer instructions (verbatim) | |
| `resolve_stale_threads` | `0` or `1` — resolve prior unresolved dotbot threads the reviewer model saw but declined to carry forward (judged stale/fixed); threads never shown to the model stay open | `0` |
| **Act-only** | | |
Expand Down
22 changes: 18 additions & 4 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ inputs:
required: false
github_approval_token:
description: >-
GitHub user PAT owned by the machine user (e.g. dotCMS-Machine-User).
When every reviewer model reports "Overall: patch is correct", dotbot
approves the PR as that user. Leave empty (secret DOTBOT_GITHUB_USER_PAT
unset) to disable auto-approval and just post review comments normally.
Token used to submit the approval review when every reviewer model reports
"Overall: patch is correct". Usually a GitHub user PAT owned by a machine
user (e.g. dotCMS-Machine-User, secret DOTBOT_GITHUB_USER_PAT) so the
approval is attributed to a named user; `${{ github.token }}` also works,
in which case the approval shows as github-actions[bot] (the repo must
allow Actions to approve pull requests). Leave empty to disable
auto-approval and just post review comments normally.
required: false
default: ""
config_path:
Expand Down Expand Up @@ -124,6 +127,11 @@ runs:
DOTBOT_MODEL_INPUT: ${{ inputs.model }}
OPENROUTER_REVIEW_CONFIG: ${{ inputs.config_path }}
GITHUB_ACTION_PATH: ${{ github.action_path }}
# Composite steps run in the *caller's* workspace, and `python -m`
# puts the current directory ahead of PYTHONPATH on sys.path — so a
# checkout containing a `cli/` package would execute that code
# instead of this pinned action. PYTHONSAFEPATH stops the injection.
PYTHONSAFEPATH: "1"
run: |
set -euo pipefail
PYTHONPATH="${GITHUB_ACTION_PATH}:${PYTHONPATH:-}" \
Expand Down Expand Up @@ -180,6 +188,12 @@ runs:
# deltas reach the GitHub Actions live log as soon as the
# OpenRouter SSE stream emits them.
PYTHONUNBUFFERED: "1"
# See the note on the resume-state step: without this, a checkout
# containing its own `cli/` package shadows the pinned action's code
# (`python -m` prefers the cwd). For act mode that means running
# PR-head code with a write token — the exact escalation the trusted
# ref pin exists to prevent.
PYTHONSAFEPATH: "1"
# Conversation threads for the OpenRouter agent loop live here so
# the cache steps can persist them across runs for resume.
OPENROUTER_THREAD_DIR: ${{ runner.temp }}/openrouter-review-threads
Expand Down
96 changes: 68 additions & 28 deletions cli/review/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,27 +2,35 @@

The reviewer workflow runs a roster of LLM reviewers over a PR. When every
reviewer in the roster concludes ``Overall: patch is correct``, the workflow
approves the PR on their behalf using a dedicated GitHub user token — so the
approval shows up as coming from the machine user, not from the action's
default ``GITHUB_TOKEN`` identity.

The approval is idempotent per head SHA: if the machine user has already
approved the current head commit, we skip re-submitting so re-triggered runs
do not spam duplicate approvals.
approves the PR on their behalf. The token that submits the approval decides the
identity it appears under: a machine-user PAT shows a named user (e.g.
dotCMS-Machine-User), while ``${{ github.token }}`` shows ``github-actions[bot]``.

Installation tokens cannot call ``GET /user`` (GitHub answers 403 "Resource not
accessible by integration"), so the approver identity is resolved best-effort:
when it is unknown, idempotency is keyed off the marker in the review body
instead of the login.

The approval is idempotent per head SHA: if this action has already approved the
current head commit, we skip re-submitting so re-triggered runs do not spam
duplicate approvals.
"""

from __future__ import annotations

from collections.abc import Callable
from collections.abc import Callable, Iterable
from dataclasses import dataclass

from github import Github
from github.PullRequestReview import PullRequestReview

from ..core.exceptions import GitHubAPIError

APPROVAL_MARKER = "approved automatically by dotbot"

APPROVAL_BODY_TEMPLATE = (
"✅ dotbot review: all reviewer models ({models}) agree — **patch is correct**.\n\n"
"<sub>approved automatically by dotbot</sub>"
f"<sub>{APPROVAL_MARKER}</sub>"
)


Expand All @@ -40,6 +48,42 @@ def build_approval_body(models: list[str]) -> str:
return APPROVAL_BODY_TEMPLATE.format(models=", ".join(models))


def _resolve_login(gh: Github, debug: Callable[[int, str], None]) -> str:
"""Return the approval token's user login, or ``""`` when it cannot be read.

Installation tokens (``${{ github.token }}``) get a 403 from ``GET /user``.
The login is only used for logging and for the login-based idempotency
shortcut, so this is best-effort: an unresolvable token still submits the
approval, deduplicated by :data:`APPROVAL_MARKER` in the review body.
"""
try:
return gh.get_user().login
except Exception: # noqa: BLE001 — an installation token cannot read /user
debug(
1,
"Approval token cannot resolve its own user (installation token); "
"falling back to the review body marker for idempotency",
)
return ""


def _is_our_approval(review: PullRequestReview, *, login: str, head_sha: str) -> bool:
"""True when ``review`` is an existing approval of ``head_sha`` that we made.

Matches the resolved login, or — when the token cannot report a login (the
installation-token case) — our own :data:`APPROVAL_MARKER` in the body.
"""
if review.state != "APPROVED" or (review.commit_id or "") != head_sha:
return False
author = review.user.login if review.user is not None else None
return bool(login and author == login) or APPROVAL_MARKER in (review.body or "")


def _approval_exists(reviews: Iterable[PullRequestReview], *, login: str, head_sha: str) -> bool:
"""True when an approval for ``head_sha`` came from us already."""
return any(_is_our_approval(review, login=login, head_sha=head_sha) for review in reviews)


def submit_pr_approval(
*,
repository: str,
Expand All @@ -51,20 +95,18 @@ def submit_pr_approval(
) -> ApprovalOutcome:
"""Submit an ``APPROVE`` review on the PR as the token's user.

Uses a dedicated ``Github`` client authenticated with ``token`` so the
review is attributed to the machine user (e.g. dotCMS-Machine-User)
rather than the default action token. Skips submission when that user
has already approved this exact head commit.
Uses a dedicated ``Github`` client authenticated with ``token`` so the review
is attributed to that token rather than the action's default credentials —
a machine-user PAT, or ``github.token`` for a ``github-actions[bot]``
approval. Skips submission when this token has already approved this exact
head commit.

Raises:
GitHubAPIError: if any GitHub API call fails.
"""
gh = Github(login_or_token=token, per_page=100)

try:
login = gh.get_user().login
except Exception as exc:
raise GitHubAPIError(f"failed to resolve approval token user: {exc}") from exc
login = _resolve_login(gh, debug)

try:
pr = gh.get_repo(repository).get_pull(pr_number)
Expand All @@ -74,15 +116,9 @@ def submit_pr_approval(
) from exc

try:
for review in pr.get_reviews():
review_author = review.user.login if review.user is not None else None
if (
review_author == login
and review.state == "APPROVED"
and (review.commit_id or "") == head_sha
):
debug(1, f"Approval already submitted by {login} for {head_sha}; skipping")
return ApprovalOutcome(submitted=False, login=login, reason="already_approved")
if _approval_exists(pr.get_reviews(), login=login, head_sha=head_sha):
debug(1, f"Approval already submitted for {head_sha}; skipping")
return ApprovalOutcome(submitted=False, login=login, reason="already_approved")
except Exception as exc:
raise GitHubAPIError(f"failed to list reviews on {repository}#{pr_number}: {exc}") from exc

Expand All @@ -95,8 +131,12 @@ def submit_pr_approval(
pr._requester.requestJsonAndCheck("POST", f"{pr.url}/reviews", input=payload)
except Exception as exc:
raise GitHubAPIError(
f"failed to submit approval review on {repository}#{pr_number} as {login}: {exc}"
f"failed to submit approval review on {repository}#{pr_number} as "
f"{login or 'installation token'}: {exc}"
) from exc

debug(1, f"Submitted APPROVE review on {repository}#{pr_number} as {login}")
debug(
1,
f"Submitted APPROVE review on {repository}#{pr_number} as {login or 'installation token'}",
)
return ApprovalOutcome(submitted=True, login=login)
Loading
Loading