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
20 changes: 20 additions & 0 deletions .ai/wheels/wheels-bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,23 @@ Flip the repo variable `WHEELS_BOT_ENABLED` to `false` to halt every bot workflo
## Auto-fire safety net

The bot is permitted to chain stages (triage → research → propose-fix), and handoff fires on `*-confidence:high` OR `*-confidence:medium`. Low stays manual. Sensitive areas (security, middleware, migrations, deploy, DI, cross-engine) are caught by the propose-fix prompt's own step-4 safety net, which posts a `fix-held` marker instead of opening a PR. Reviewer A and B then critique whatever propose-fix produces, escalating to the Senior Advisor on deadlock. All bot PRs land as `--draft` and require a human approving review on `develop`.

## PR-prep automation (release unblocking)

- **Commit-message gate.** `pr.yml`'s `Validate Commit Messages` lints the
**PR title** (the squash subject), not every commit — because PRs are
squash-merged, intermediate commit headers don't land in `develop`; only the
PR title does. Edit the title to fix a failure; the `edited` trigger re-runs
the check (and `fast-test` is skipped on title-only edits). Local guard:
`tools/test-commit-title.sh`.
- **Freshen (`bot-freshen.yml`).** On push to develop + a 30-min backstop:
behind-but-clean bot PRs are updated via non-destructive `update-branch`;
DIRTY ones are dispatched to the resolver. Decision logic:
`.github/scripts/freshen-decide.sh`.
- **Conflict resolution (`bot-resolve-conflicts.yml` + `/resolve-conflicts`).**
A deterministic classifier (`.github/scripts/classify-conflicts.sh`)
auto-resolves content/docs conflicts (markdown/MDX anywhere, CHANGELOG,
`.ai/`, `docs/`) and pushes; any code conflict is escalated with
the `conflict:needs-human` label and a comment — never auto-resolved.
- **Not automated:** merging. PRs are brought to a green, conflict-free,
ready state; the maintainer performs the final squash-merge.
8 changes: 7 additions & 1 deletion .claude/commands/_shared-rails.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ they are honored. Violating them is a bug — fix the prompt, not the rails.
`ci`, `chore`, `revert`. **Scope is optional and unrestricted** — pick a
short noun that helps a reader skim history (e.g. `model`, `web/blog`),
or omit it entirely. Don't agonize over which scope is "right."
- **Subject ≤ 100 chars, not ALL-CAPS.** Sentence-case is fine.
- **Header ≤ 100 chars, not ALL-CAPS.** commitlint measures the WHOLE header —
`type(scope): subject` including the `type(scope): ` prefix — not just the
subject. A 90-char subject under a `docs(web/guides): ` prefix is a 108-char
header and FAILS. Count the prefix. Sentence-case is fine.
- **The PR title is the linted gate.** Because the repo squash-merges, the PR
title becomes the landing commit subject and is what CI validates — make the
PR title itself a valid conventional-commit header ≤ 100 chars.
- **DCO sign-off required.** Every commit you author MUST end with the
trailer `Signed-off-by: wheels-bot[bot] <wheels-bot[bot]@users.noreply.github.com>`
matching the configured git author identity. Use `git commit -s` (the
Expand Down
61 changes: 61 additions & 0 deletions .claude/commands/resolve-conflicts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# /resolve-conflicts

Reconcile content/docs merge-conflict markers on a bot PR branch (low-risk paths only). Invoked by bot-resolve-conflicts.yml after a deterministic risk gate.

## Rails

Read `.claude/commands/_shared-rails.md` first — they apply to every step
below. Highlights for this command:

- Use `gh` for GitHub state, `git` for the PR branch only.
- **Filesystem writes are limited to the conflicted content/docs files only.**
Never touch code.
- Output is **a completed merge commit** — the workflow pushes after this
prompt completes.

## Args

- `<pr-number>` — the PR branch with content/docs conflict markers to resolve

# Resolve content conflicts — PR #<pr-number>

You are running inside `bot-resolve-conflicts.yml`. The workflow has already
merged `origin/develop` into the PR branch and a **deterministic classifier
has confirmed every conflicted file is pure documentation/content**
(markdown/MDX at any path, CHANGELOG, or under `.ai/` or `docs/`).

## Hard safety rule

Run this first:

```bash
git diff --name-only --diff-filter=U
```

Confirm EVERY listed file is in the low-risk set the upstream classifier
admits — i.e. each file is a `*.md` or `*.mdx` (any path), a `CHANGELOG`
file, or under `.ai/` or `docs/`. If ANY listed file falls OUTSIDE that set
(any code file — `.cfc`, `.cfm`, `.js`, `.ts`, `.py`, `.sh`, `.json`, `.yml`,
`.yaml` — or any other non-doc file), DO NOT resolve it. Run
`git merge --abort`, post a comment saying the gate and the command disagreed
(a bug), and stop. This should never happen, but never resolve a code conflict.

## Resolve

For each conflicted content file:
1. Open it and read the full conflict region(s).
2. Reconcile the `<<<<<<<` / `=======` / `>>>>>>>` markers by **integrating
both sides' intent** — these are docs, so prose from both branches almost
always belongs in the result; merge them coherently rather than picking one
side and discarding the other. Remove all conflict markers.
3. `git add <file>`.

After all files are resolved:

```bash
git diff --name-only --diff-filter=U # must print nothing
git commit --no-edit # completes the merge commit
```

Do NOT `git push` — the workflow pushes after verifying no markers remain.
Do NOT edit any file that was not in the conflicted set. Do NOT touch code.
29 changes: 29 additions & 0 deletions .github/scripts/classify-conflicts.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Reads conflicted file paths (stdin or args, one per line) and prints
# "resolve" iff EVERY path is pure documentation/content, else "escalate".
# Conservative by design: unknown or empty input -> escalate.
set -euo pipefail

is_low_risk() {
case "$1" in
*.md|*.mdx) return 0 ;; # markdown/MDX anywhere is non-executable
CHANGELOG|CHANGELOG.*) return 0 ;;
.ai/*|*/.ai/*) return 0 ;;
docs/*|*/docs/*) return 0 ;;
esac
return 1
}

files=()
if [ "$#" -gt 0 ]; then
files=("$@")
else
while IFS= read -r line || [ -n "$line" ]; do [ -n "$line" ] && files+=("$line"); done
fi

if [ "${#files[@]}" -eq 0 ]; then echo "escalate"; exit 0; fi

for f in "${files[@]}"; do
if ! is_low_risk "$f"; then echo "escalate"; exit 0; fi
done
echo "resolve"
11 changes: 11 additions & 0 deletions .github/scripts/freshen-decide.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Maps a PR's GitHub mergeStateStatus to a freshen action.
# BEHIND -> update (merge develop in, non-destructive)
# DIRTY -> dispatch-resolver (real conflict)
# * -> skip (CLEAN/UNSTABLE/BLOCKED/UNKNOWN are not our job)
set -euo pipefail
case "${1:-}" in
BEHIND) echo "update" ;;
DIRTY) echo "dispatch-resolver" ;;
*) echo "skip" ;;
esac
76 changes: 76 additions & 0 deletions .github/workflows/bot-freshen.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
name: Wheels Bot — Freshen PRs

# Keeps open bot PRs current with develop. On each push to develop (plus a
# 30-min backstop), behind-but-clean branches are updated non-destructively
# (merge develop in); DIRTY branches are handed to bot-resolve-conflicts.yml.
on:
push:
branches: [develop]
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:

permissions:
contents: read
pull-requests: write
actions: write

concurrency:
group: wheels-bot-freshen
cancel-in-progress: false

jobs:
freshen:
name: Freshen open bot PRs
if: vars.WHEELS_BOT_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 20
env:
REPO: wheels-dev/wheels
steps:
- name: Generate App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.WHEELS_BOT_APP_ID }}
private-key: ${{ secrets.WHEELS_BOT_PRIVATE_KEY }}

- uses: actions/checkout@v6
with:
fetch-depth: 1

- name: Sweep
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
decide=.github/scripts/freshen-decide.sh
# gh's author.login reports App authors as `app/<slug>` (verified: bot
# PRs show `app/wheels-bot` on the `gh pr list --json author` surface),
# while REST/webhook surfaces use `<slug>[bot]`. Match both forms so the
# filter is robust across gh versions / API surfaces.
prs=$(gh pr list --repo "$REPO" --state open --base develop \
--json number,isDraft,author \
--jq '.[] | select(.isDraft==false) | select(.author.login=="app/wheels-bot" or .author.login=="wheels-bot[bot]") | .number')
if [ -z "$prs" ]; then echo "No open bot PRs."; exit 0; fi
for n in $prs; do
status=UNKNOWN
for _ in $(seq 1 9); do # mergeStateStatus is async; poll ~45s
status=$(gh pr view "$n" --repo "$REPO" --json mergeStateStatus --jq '.mergeStateStatus' || echo "UNKNOWN")
[ "$status" != "UNKNOWN" ] && break
sleep 5
done
action=$(bash "$decide" "$status")
echo "PR #$n: status=$status -> $action"
case "$action" in
update)
gh api -X PUT "repos/$REPO/pulls/$n/update-branch" \
&& echo " updated #$n" \
|| echo " update-branch no-op/failed for #$n (already current or raced to DIRTY)";;
dispatch-resolver)
gh workflow run bot-resolve-conflicts.yml --repo "$REPO" -f pr-number="$n" \
&& echo " dispatched resolver for #$n" \
|| echo " failed to dispatch resolver for #$n";;
skip) echo " nothing to do for #$n";;
esac
done
170 changes: 170 additions & 0 deletions .github/workflows/bot-resolve-conflicts.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
name: Wheels Bot — Resolve Conflicts

# Dispatched by bot-freshen.yml for an open bot PR whose mergeStateStatus is
# DIRTY. A deterministic classifier decides: auto-resolve content/docs
# conflicts (via /resolve-conflicts), or escalate anything touching code.
on:
workflow_dispatch:
inputs:
pr-number:
description: 'PR number to attempt conflict resolution on'
required: true
type: string

permissions:
contents: read
pull-requests: write
issues: write

concurrency:
group: wheels-bot-resolve-${{ inputs.pr-number }}
cancel-in-progress: false

jobs:
resolve:
name: Resolve conflicts (tiered)
if: vars.WHEELS_BOT_ENABLED == 'true'
runs-on: ubuntu-latest
timeout-minutes: 60
env:
PR_NUMBER: ${{ inputs.pr-number }}
REPO: wheels-dev/wheels
steps:
- name: Generate App token
id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.WHEELS_BOT_APP_ID }}
private-key: ${{ secrets.WHEELS_BOT_PRIVATE_KEY }}

- name: Resolve PR head ref
id: pr
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
if ! [[ "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
echo "::error::pr-number must be numeric, got: $PR_NUMBER"; exit 1
fi
ref=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefName -q '.headRefName')
if [ -z "$ref" ]; then echo "::error::no head ref for #$PR_NUMBER"; exit 1; fi
echo "head=$ref" >> "$GITHUB_OUTPUT"

- name: Checkout PR branch
uses: actions/checkout@v6
with:
ref: ${{ steps.pr.outputs.head }}
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}

- name: Skip check
id: gate
uses: ./.github/actions/wheels-bot-skip-check
with:
target-type: pr
target-number: ${{ env.PR_NUMBER }}
marker-pattern: 'wheels-bot:conflict-attempted:${{ env.PR_NUMBER }}'
github-token: ${{ steps.app-token.outputs.token }}

- name: Configure git
if: steps.gate.outputs.skip == 'false'
run: |
set -euo pipefail
git config user.name "wheels-bot[bot]"
git config user.email "wheels-bot[bot]@users.noreply.github.com"

- name: Merge develop to surface conflicts
id: merge
if: steps.gate.outputs.skip == 'false'
run: |
set -euo pipefail
echo "base=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" # PR head before merge
git fetch origin develop
if git merge --no-edit origin/develop; then
echo "result=clean" >> "$GITHUB_OUTPUT"
else
echo "result=conflict" >> "$GITHUB_OUTPUT"
fi

- name: No conflicts (clean merge)
if: steps.gate.outputs.skip == 'false' && steps.merge.outputs.result == 'clean'
run: |
echo "::notice::PR #${PR_NUMBER} merged cleanly with develop; nothing to resolve. The freshen sweep will fast-forward it via update-branch."

- name: Classify conflicts
id: classify
if: steps.gate.outputs.skip == 'false' && steps.merge.outputs.result == 'conflict'
run: |
set -euo pipefail
files=$(git diff --name-only --diff-filter=U)
echo "Conflicted files:"; printf '%s\n' "$files"
decision=$(printf '%s\n' "$files" | bash .github/scripts/classify-conflicts.sh)
echo "decision=$decision" >> "$GITHUB_OUTPUT"
{ echo 'CONFLICT_FILES<<EOF'; printf '%s\n' "$files"; echo 'EOF'; } >> "$GITHUB_ENV"

- name: Escalate (code conflict)
if: steps.gate.outputs.skip == 'false' && steps.classify.outputs.decision == 'escalate'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
git merge --abort || true
gh label create conflict:needs-human --repo "$REPO" --color B60205 \
--description "Merge conflict touches code; needs manual resolution" 2>/dev/null || true
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label conflict:needs-human
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$(printf '%s\n' \
"🛑 **Merge conflict needs a human.** The conflict touches code paths, which the bot will not auto-resolve." \
"" \
"Conflicted files:" '```' "$CONFLICT_FILES" '```' \
"" \
"Please merge \`develop\` and resolve manually. (Labelled \`conflict:needs-human\`.)" \
"" \
"<!-- wheels-bot:conflict-attempted:$PR_NUMBER -->")"

- name: Resolve (content/docs only) via Claude
if: steps.gate.outputs.skip == 'false' && steps.classify.outputs.decision == 'resolve'
uses: anthropics/claude-code-action@v1
with:
allowed_bots: 'wheels-bot[bot],github-actions[bot]'
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ steps.app-token.outputs.token }}
prompt: |
/resolve-conflicts ${{ env.PR_NUMBER }}
claude_args: |
--model claude-opus-4-7
--max-turns 400
--allowedTools "Bash(gh:*),Bash(git:*),Read,Edit,Write,Grep,Glob"

- name: Verify resolution, push, or escalate (loop-safe)
if: steps.gate.outputs.skip == 'false' && steps.classify.outputs.decision == 'resolve'
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
BASE_SHA: ${{ steps.merge.outputs.base }}
run: |
set -euo pipefail
# Success = a clean, committed merge to push. Failure = conflict markers
# still present, OR the command's safety gate aborted the merge with no
# new commit. On failure the PR stays DIRTY, so we MUST post the
# conflict-attempted marker and escalate — otherwise the freshen sweep
# re-dispatches this resolver every cycle (runaway loop), since the
# skip-check only matches that marker.
if git diff --name-only --diff-filter=U | grep -q .; then
git merge --abort 2>/dev/null || true # unresolved markers -> not resolved
elif [ -f .git/MERGE_HEAD ]; then
git commit --no-edit # resolved but uncommitted -> finish merge
fi
if [ "$(git rev-parse HEAD)" != "$BASE_SHA" ]; then
git push origin HEAD # resolved cleanly; PR checks re-validate
exit 0
fi
# No new commit -> resolution did not complete. Escalate to a human and
# post the marker so freshen does not re-dispatch this resolver.
gh label create conflict:needs-human --repo "$REPO" --color B60205 \
--description "Merge conflict needs manual resolution" 2>/dev/null || true
gh pr edit "$PR_NUMBER" --repo "$REPO" --add-label conflict:needs-human
gh pr comment "$PR_NUMBER" --repo "$REPO" --body "$(printf '%s\n' \
"⚠️ **Automated content-conflict resolution did not complete** — leaving this for a human." \
"" \
"<!-- wheels-bot:conflict-attempted:$PR_NUMBER -->")"
echo "::error::resolve path produced no committed merge; escalated to human"
exit 1
Loading
Loading