mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-21 19:02:45 -05:00
345 lines
14 KiB
YAML
345 lines
14 KiB
YAML
### Reusable "agent performs a repo task and opens a reviewed PR" workflow.
|
|
###
|
|
### A caller workflow supplies a task script (backend/scripts/*.py) that
|
|
### regenerates repo files using the OpenAI Responses API, plus an optional
|
|
### deterministic fallback script. This workflow owns the shared machinery:
|
|
### App credentials, checkout, the agent-vs-fallback dance, commit + PR
|
|
### mechanics, and self-repair — when the caller's validation workflow fails
|
|
### on the task branch, the caller's workflow_run trigger re-invokes this
|
|
### workflow with the failing logs so the agent can fix its own output.
|
|
###
|
|
### Task script contract (stdlib-only is preferred, so bare python3 runs it):
|
|
### python3 <script> --write --rationale-file <path>
|
|
### [--failure-context-file <path>] [extra args]
|
|
### --rationale-file receives markdown appended to the PR body;
|
|
### --failure-context-file carries the failed run's logs on repair runs.
|
|
###
|
|
### Adopting a new task = one ~30-line caller workflow + one script.
|
|
|
|
name: Agent Task PR (reusable)
|
|
|
|
on:
|
|
workflow_call:
|
|
inputs:
|
|
task-name:
|
|
description: "Short slug for the task (concurrency group, comments)"
|
|
required: true
|
|
type: string
|
|
script:
|
|
description: "Task script to run (repo-relative path)"
|
|
required: true
|
|
type: string
|
|
script-args:
|
|
description: "Extra args for the task script"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
fallback-script:
|
|
description: "Deterministic fallback script when the agent can't run"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
fallback-args:
|
|
description: "Args for the fallback script"
|
|
required: false
|
|
type: string
|
|
default: "--write"
|
|
files:
|
|
description: "Repo paths the task may modify (git add, space-separated)"
|
|
required: true
|
|
type: string
|
|
branch:
|
|
description: "Bot-owned branch the PR is pushed to"
|
|
required: true
|
|
type: string
|
|
pr-title:
|
|
description: "Title for the generated PR"
|
|
required: true
|
|
type: string
|
|
reviewer:
|
|
description: "GitHub username to request review from (empty = none)"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
slack-channel:
|
|
description: "Slack channel ID for PR review notifications (empty = no post)"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
slack-mention:
|
|
description: "GitHub username to resolve to a Slack mention"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
agent-model:
|
|
description: "OPENAI_MODEL override for the agent call (empty = script default)"
|
|
required: false
|
|
type: string
|
|
default: ""
|
|
timeout-minutes:
|
|
description: "Job timeout; must fit the script's own retry budget"
|
|
required: false
|
|
type: number
|
|
default: 75
|
|
max-repair-commits:
|
|
description: "Max commits on the branch before repairs stop"
|
|
required: false
|
|
type: number
|
|
default: 5
|
|
secrets:
|
|
# Minted so PRs/commits come from the app bot, not github-actions[bot].
|
|
CHERRY_PICK_APP_PRIVATE_KEY:
|
|
description: "GitHub App private key for the PR token"
|
|
required: true
|
|
# Any task not needing an agent call can omit it; empty → straight to
|
|
# the fallback script.
|
|
OPENAI_API_KEY:
|
|
description: "OpenAI API key for the agent call"
|
|
required: false
|
|
SLACK_BOT_TOKEN:
|
|
description: "Slack bot token for PR review notifications"
|
|
required: false
|
|
|
|
concurrency:
|
|
group: agent-task-${{ inputs.task-name }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
run:
|
|
# Scheduled/dispatch runs always run; workflow_run only on a validation
|
|
# failure of the task branch IN THIS REPO (a fork's matching branch name
|
|
# must never reach the secrets below).
|
|
if: >-
|
|
github.event_name != 'workflow_run' ||
|
|
(github.event.workflow_run.conclusion == 'failure' &&
|
|
github.event.workflow_run.head_repository.full_name == github.repository)
|
|
runs-on: ubuntu-latest
|
|
# GITHUB_TOKEN only needs actions:read (to fetch the failed run's logs);
|
|
# PR writes go through the App token minted below.
|
|
permissions:
|
|
contents: read
|
|
actions: read
|
|
timeout-minutes: ${{ inputs.timeout-minutes }}
|
|
env:
|
|
BRANCH: ${{ inputs.branch }}
|
|
FILES: ${{ inputs.files }}
|
|
steps:
|
|
- name: Mint GitHub App installation token
|
|
id: app-token
|
|
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
|
with:
|
|
client-id: ${{ vars.CHERRY_PICK_APP_ID }}
|
|
private-key: ${{ secrets.CHERRY_PICK_APP_PRIVATE_KEY }}
|
|
permission-contents: write
|
|
permission-pull-requests: write
|
|
|
|
- name: Checkout repository
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # ratchet:actions/checkout@v6
|
|
with:
|
|
persist-credentials: true
|
|
# On a validation-failure repair, check out the PR head so the agent
|
|
# iterates on its own last attempt instead of starting from main.
|
|
ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || 'main' }}
|
|
token: ${{ steps.app-token.outputs.token }}
|
|
|
|
- name: Configure git identity as App
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
|
run: |
|
|
bot_user_id="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
|
|
git config user.name "${APP_SLUG}[bot]"
|
|
git config user.email "${bot_user_id}+${APP_SLUG}[bot]@users.noreply.github.com"
|
|
|
|
# A workflow_run event can fire for a stale validation run (queued behind
|
|
# a newer regeneration, or after the PR merged). Only repair when the
|
|
# failed sha is still the head of the still-open task PR, and stop after
|
|
# max-repair-commits so a stubborn failure can't loop paid agent calls
|
|
# and CI runs forever.
|
|
- name: Verify repair target is current
|
|
if: github.event_name == 'workflow_run'
|
|
id: repair-guard
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
FAILED_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
MAX_REPAIR_COMMITS: ${{ inputs.max-repair-commits }}
|
|
run: |
|
|
proceed=true
|
|
# Capture first: a gh failure must abort the step, not read as
|
|
# "no open PR" and silently skip the repair.
|
|
pr_list="$(gh pr list --state open --head "${BRANCH}" \
|
|
--json number,headRefOid \
|
|
--jq '.[0] | if . == null then "" else "\(.number) \(.headRefOid)" end')"
|
|
read -r pr_number pr_head <<< "${pr_list}"
|
|
if [ -z "${pr_number}" ]; then
|
|
echo "::notice::No open PR for ${BRANCH} (merged or closed). Skipping repair."
|
|
proceed=false
|
|
elif [ "${pr_head}" != "${FAILED_SHA}" ]; then
|
|
echo "::notice::Failed run's sha ${FAILED_SHA} is not the PR head (${pr_head}); the run is stale. Skipping."
|
|
proceed=false
|
|
else
|
|
commit_count="$(gh pr view "${pr_number}" --json commits --jq '.commits | length')"
|
|
if [ "${commit_count}" -ge "${MAX_REPAIR_COMMITS}" ]; then
|
|
gh pr comment "${pr_number}" --body \
|
|
"Validation failed again, but this PR reached the automated repair limit (${MAX_REPAIR_COMMITS} commits). Stopping the repair loop — the task output needs a manual fix." \
|
|
|| echo "::warning::Could not post repair-limit comment."
|
|
echo "::notice::Repair limit reached for PR #${pr_number}. Skipping."
|
|
proceed=false
|
|
fi
|
|
fi
|
|
echo "proceed=${proceed}" >> "${GITHUB_OUTPUT}"
|
|
|
|
- name: Collect failing validation context
|
|
if: >-
|
|
github.event_name == 'workflow_run' &&
|
|
steps.repair-guard.outputs.proceed == 'true'
|
|
env:
|
|
# GITHUB_TOKEN has actions:read; the App token was minted without it.
|
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
FAILED_RUN_ID: ${{ github.event.workflow_run.id }}
|
|
run: |
|
|
# The agent gets the tail of the failing logs plus which jobs died.
|
|
gh api "repos/${{ github.repository }}/actions/runs/${FAILED_RUN_ID}/jobs?per_page=100" \
|
|
--jq '.jobs[] | select(.conclusion == "failure") | .name' \
|
|
> /tmp/failed_jobs.txt || true
|
|
{
|
|
echo "Failed jobs:"
|
|
cat /tmp/failed_jobs.txt
|
|
echo ""
|
|
gh run view "${FAILED_RUN_ID}" --repo "${{ github.repository }}" --log-failed || true
|
|
} > /tmp/failure_context.txt
|
|
wc -c /tmp/failure_context.txt
|
|
|
|
- name: Run task
|
|
if: >-
|
|
github.event_name != 'workflow_run' ||
|
|
steps.repair-guard.outputs.proceed == 'true'
|
|
env:
|
|
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
OPENAI_MODEL: ${{ inputs.agent-model }}
|
|
SCRIPT: ${{ inputs.script }}
|
|
SCRIPT_ARGS: ${{ inputs.script-args }}
|
|
FALLBACK_SCRIPT: ${{ inputs.fallback-script }}
|
|
FALLBACK_ARGS: ${{ inputs.fallback-args }}
|
|
# Task scripts take --write/--rationale-file/--failure-context-file
|
|
# (see header). The agent path is preferred; the deterministic
|
|
# fallback keeps the task landing when there's no key or the agent
|
|
# call fails.
|
|
run: |
|
|
set -o pipefail
|
|
|
|
args="--write --rationale-file /tmp/agent_rationale.md ${SCRIPT_ARGS}"
|
|
if [ -s /tmp/failure_context.txt ]; then
|
|
args="${args} --failure-context-file /tmp/failure_context.txt"
|
|
fi
|
|
|
|
if [ -n "${OPENAI_API_KEY:-}" ] && python3 "${SCRIPT}" ${args}; then
|
|
echo "Agent task succeeded."
|
|
elif [ -n "${FALLBACK_SCRIPT}" ]; then
|
|
echo "::warning::Agent path unavailable or failed; running deterministic fallback."
|
|
python3 "${FALLBACK_SCRIPT}" ${FALLBACK_ARGS}
|
|
else
|
|
echo "::error::Agent task failed and no fallback is configured."
|
|
exit 1
|
|
fi
|
|
|
|
- name: Open or update PR
|
|
id: pr
|
|
if: >-
|
|
github.event_name != 'workflow_run' ||
|
|
steps.repair-guard.outputs.proceed == 'true'
|
|
env:
|
|
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
|
FAILED_SHA: ${{ github.event.workflow_run.head_sha }}
|
|
PR_TITLE: ${{ inputs.pr-title }}
|
|
REVIEWER: ${{ inputs.reviewer }}
|
|
TASK_NAME: ${{ inputs.task-name }}
|
|
# Fixed branch + force-push: the content changes run over run, so an
|
|
# already-open PR should be refreshed, not left stale.
|
|
run: |
|
|
if git diff --quiet -- ${FILES}; then
|
|
echo "No changes to ${FILES}. Nothing to do."
|
|
exit 0
|
|
fi
|
|
|
|
git switch -C "${BRANCH}"
|
|
git add ${FILES}
|
|
git commit -m "${PR_TITLE}"
|
|
# Repair runs push on top of the sha the guard verified; refuse to
|
|
# clobber the branch if someone pushed in between.
|
|
if [ "${{ github.event_name }}" = "workflow_run" ]; then
|
|
git push --force-with-lease="${BRANCH}:${FAILED_SHA}" origin "${BRANCH}"
|
|
else
|
|
git push --force origin "${BRANCH}"
|
|
fi
|
|
|
|
body="Generated by the [${TASK_NAME} workflow run](${RUN_URL})."
|
|
if [ -s /tmp/agent_rationale.md ]; then
|
|
body="$(printf '%s\n\n%s\n' "${body}" "$(cat /tmp/agent_rationale.md)")"
|
|
fi
|
|
|
|
pr_number="$(gh pr list --state open --head "${BRANCH}" --json number --jq '.[0].number')"
|
|
if [ -n "${pr_number}" ]; then
|
|
echo "Refreshed existing PR #${pr_number} via force-push."
|
|
else
|
|
gh pr create \
|
|
--base main \
|
|
--head "${BRANCH}" \
|
|
--title "${PR_TITLE}" \
|
|
--body "${body}"
|
|
pr_number="$(gh pr list --state open --head "${BRANCH}" --json number --jq '.[0].number')"
|
|
fi
|
|
|
|
# Body refresh keeps the rationale/current run link current after a
|
|
# repair push; reviewer re-request makes sure the owner is notified.
|
|
edit_args=(--body "${body}")
|
|
if [ -n "${REVIEWER}" ]; then
|
|
edit_args+=(--add-reviewer "${REVIEWER}")
|
|
fi
|
|
gh pr edit "${pr_number}" "${edit_args[@]}" \
|
|
|| echo "::warning::Could not update PR #${pr_number}"
|
|
|
|
echo "pr_number=${pr_number}" >> "${GITHUB_OUTPUT}"
|
|
echo "pr_url=${REPO_URL}/pull/${pr_number}" >> "${GITHUB_OUTPUT}"
|
|
|
|
# Ping the reviewer in Slack when the task produced or refreshed a PR.
|
|
# Non-fatal: the PR is the artifact; a Slack hiccup must not fail the run.
|
|
#
|
|
# The action loads from a checkout of this workflow's own sha, not the
|
|
# working tree: a workflow_run repair checks out the task PR's head,
|
|
# which can predate the action.
|
|
- name: Checkout Slack actions
|
|
id: slack-actions
|
|
if: >-
|
|
inputs.slack-channel != '' &&
|
|
(github.event_name != 'workflow_run' ||
|
|
steps.repair-guard.outputs.proceed == 'true') &&
|
|
steps.pr.outputs.pr_number != ''
|
|
continue-on-error: true # notification-only; a checkout outage must not fail the run
|
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # ratchet:actions/checkout@v6
|
|
with:
|
|
persist-credentials: false
|
|
ref: ${{ github.sha }}
|
|
path: .ci-actions
|
|
sparse-checkout: .github/actions
|
|
|
|
- name: Post PR to Slack for review
|
|
if: >-
|
|
inputs.slack-channel != '' &&
|
|
(github.event_name != 'workflow_run' ||
|
|
steps.repair-guard.outputs.proceed == 'true') &&
|
|
steps.pr.outputs.pr_number != '' &&
|
|
steps.slack-actions.outcome == 'success'
|
|
continue-on-error: true
|
|
uses: ./.ci-actions/.github/actions/slack-post-message
|
|
with:
|
|
bot-token: ${{ secrets.SLACK_BOT_TOKEN }}
|
|
channel: ${{ inputs.slack-channel }}
|
|
mention: ${{ inputs.slack-mention }}
|
|
text: |
|
|
${{ inputs.task-name }} PR ready for review {mention}
|
|
*PR:* <${{ steps.pr.outputs.pr_url }}|#${{ steps.pr.outputs.pr_number }}>
|
|
*Run:* <${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}|workflow run>
|