mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
1e2e05cfb4
Automated nightly regeneration of tend's workflow files, picking up tend 0.1.18. **tend version:** 0.1.17 → 0.1.18 Notable changes: - `tend-mention`: both halves of the 👀 reaction now live in the `handle` job. Previously `verify` added the eyes and `handle` removed them, so a burst of mentions on one thread could cancel a queued `handle` — which allocates no runner and runs no steps, `always()` included — leaving the reaction stranded (max-sixty/tend#990). - `tend-review` / `tend-triage`: the eyes-removal lookup now paginates (`--paginate`, `per_page=100`). A thread with more than 30 reactions could push the bot's own eyes off the first page, so the removal silently found nothing (max-sixty/tend#990). - `tend check`: the secret audit now reports only org secrets this repo can actually read, instead of every org secret (max-sixty/tend#994). - `running-in-ci` skill: notes that fork PR reviews reach no successor session, and scopes PR-description claims to the merge base (max-sixty/tend#985, max-sixty/tend#992). Full comparison: https://github.com/max-sixty/tend/compare/0.1.17...0.1.18 Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
480 lines
26 KiB
YAML
480 lines
26 KiB
YAML
# Generated by tend 0.1.18. Regenerate with: uvx tend@latest init
|
|
#
|
|
# Do not edit this file directly — it will be overwritten on regeneration.
|
|
# To customize behavior, edit the relevant skill (for example,
|
|
# `running-tend`) in this repo's .claude/skills/ directory, or open an issue at
|
|
# https://github.com/max-sixty/tend/issues for changes that need to
|
|
# happen upstream in the tend-ci-runner plugin.
|
|
|
|
name: tend-mention
|
|
on:
|
|
issues:
|
|
types: [edited]
|
|
issue_comment:
|
|
types: [created, edited]
|
|
# The review events reach only the `relay` job below: their runs carry
|
|
# `refs/pull/N/merge`, which the operational secrets' environment does not
|
|
# admit, so the secret-bearing jobs see them re-entered as a
|
|
# `repository_dispatch` instead. Same-repo PRs only — the notifications poll
|
|
# covers fork PRs.
|
|
pull_request_review:
|
|
types: [submitted]
|
|
# `created` is intentionally absent. Modern GitHub fires *both*
|
|
# pull_request_review and pull_request_review_comment for every newly-created
|
|
# inline comment (the standalone POST /pulls/PR/comments endpoint, the
|
|
# /replies endpoint, the "Add single comment" UI button, and reviews
|
|
# submitted with inline comments — all empirically verified). Subscribing to
|
|
# `created` would produce a duplicate dispatch whose handle runs collide on
|
|
# the tend-mention-handle-PR concurrency group, with the loser cancelled and
|
|
# posted as a CANCELLED check_run on the PR head SHA — which renders the
|
|
# PR's statusCheckRollup as FAILURE even though the bot did its job from the
|
|
# sibling run. Edits have no sibling event (review submissions don't fire on
|
|
# edits), so we still need to listen for `edited` to catch edit-to-summon
|
|
# ("@bot" added to an existing comment after the fact).
|
|
pull_request_review_comment:
|
|
types: [edited]
|
|
# The relay's re-entry point. GitHub creates this run even though a
|
|
# `GITHUB_TOKEN` triggered it — `workflow_dispatch` and `repository_dispatch`
|
|
# are the two events exempt from the rule that token-triggered events start
|
|
# no workflow — and it carries the default branch, which the environment
|
|
# admits.
|
|
repository_dispatch:
|
|
types: [tend-mention-review]
|
|
|
|
jobs:
|
|
# The secretless half of the review path: move the event onto a ref the
|
|
# environment admits, and nothing else. The merge ref itself can never be
|
|
# admitted — a same-repo `pull_request` run executes the PR head's own
|
|
# workflow files on that same ref, so admitting it would hand a pushed
|
|
# workflow the secrets the environment exists to deny. All judgement lives
|
|
# in `verify`, which re-reads the review or comment from the API, so a
|
|
# skipped event costs one extra short run rather than a second copy of the
|
|
# heuristics here.
|
|
relay:
|
|
# Fork PRs are excluded to hold long-standing behaviour: the notifications
|
|
# poll covers them, and it applies author-association tiers that this path
|
|
# does not. What the filter buys is that no fork head is checked out into a
|
|
# secret-bearing run; it is not an authorship gate, since a mention from
|
|
# any user with read access already wakes `handle` on a same-repo PR.
|
|
# Widening this is a separate decision, and moot while a fork run's token
|
|
# is refused the dispatch POST (403, probed). A dispatched run fails the
|
|
# event check, so the relay cannot re-enter itself.
|
|
if: |
|
|
(github.event_name == 'pull_request_review' ||
|
|
github.event_name == 'pull_request_review_comment') &&
|
|
github.event.pull_request.head.repo.full_name == github.repository
|
|
runs-on: ubuntu-24.04
|
|
# No secrets here. `contents: write` is what the dispatch POST needs, and
|
|
# is the whole of what this token can do — probed both ways on a live
|
|
# repo: an otherwise identical same-repo run declaring `contents: read`
|
|
# is refused with 403, so a narrower token would leave every review
|
|
# mention unanswered rather than merely unprivileged. It grants nobody a
|
|
# new capability: only someone who can already push a branch can open the
|
|
# same-repo PR whose workflow file this is, and they could declare any
|
|
# permissions they liked in it. The merge restriction is what bounds that,
|
|
# here as everywhere.
|
|
permissions:
|
|
contents: write
|
|
steps:
|
|
# Identifiers only: `verify` re-reads the review or comment from the
|
|
# API, so the words the bot weighs and acts on are the ones GitHub
|
|
# holds, and a forged dispatch faces the same scrutiny as a relayed one.
|
|
- name: Re-enter on an admitted ref
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
run: |
|
|
gh api "repos/$GITHUB_REPOSITORY/dispatches" \
|
|
-f event_type=tend-mention-review \
|
|
-f "client_payload[kind]=${{ github.event_name }}" \
|
|
-f "client_payload[pr]=${{ github.event.pull_request.number }}" \
|
|
-f "client_payload[id]=${{ github.event.review.id || github.event.comment.id }}"
|
|
|
|
verify:
|
|
# Skip comments on the issues tend files about its own health: the action
|
|
# auto-comments on those when a run fails or is refused, and without this
|
|
# guard those comments re-trigger tend-mention, producing a
|
|
# self-sustaining ~1 run/minute loop until the underlying condition
|
|
# clears. The prompt's self-loop guard can't help here because the model
|
|
# never executes — the action fails before Claude starts. A relayed review
|
|
# enters as `repository_dispatch` and is judged in the check step below,
|
|
# against the record the API holds.
|
|
if: |
|
|
github.event_name == 'repository_dispatch' ||
|
|
(github.event_name == 'issues' &&
|
|
contains(github.event.issue.body, '@worktrunk-bot')) ||
|
|
(github.event_name == 'issue_comment' &&
|
|
contains(github.event.issue.labels.*.name, 'tend-outage') == false && contains(github.event.issue.labels.*.name, 'tend-rate-limit') == false)
|
|
runs-on: ubuntu-24.04
|
|
environment:
|
|
name: tend
|
|
deployment: false
|
|
outputs:
|
|
should_run: ${{ steps.check.outputs.should_run }}
|
|
reason: ${{ steps.check.outputs.reason }}
|
|
url: ${{ steps.check.outputs.url }}
|
|
ts: ${{ steps.check.outputs.ts }}
|
|
steps:
|
|
- name: Verify bot engagement
|
|
id: check
|
|
run: |
|
|
# shellcheck shell=bash
|
|
# Pre-check for tend-mention: decide whether the mention is addressed to the
|
|
# bot — by name or by engagement — and so whether the agent boots at all.
|
|
#
|
|
# Inlined into the generated workflow (adopter repos have no copy of this
|
|
# file), so it stays self-contained: env in, GITHUB_OUTPUT out.
|
|
#
|
|
# env: BOT_NAME, EVENT_NAME, COMMENT_BODY, COMMENT_AUTHOR, COMMENT_AUTHOR_TYPE,
|
|
# ISSUE_BODY, ISSUE_OR_PR_NUMBER, ISSUE_AUTHOR, PR_URL, PAYLOAD_KIND,
|
|
# PAYLOAD_PR, PAYLOAD_ID, GITHUB_REPOSITORY, GITHUB_OUTPUT, GITHUB_TOKEN
|
|
# out: should_run, reason, url, ts
|
|
|
|
# A relayed review event arrives as identifiers only ({kind, pr, id}): resolve
|
|
# them against the API before judging anything, so the words weighed below are
|
|
# the ones GitHub holds rather than whatever the payload carried. Any
|
|
# write-scoped actor can POST a dispatch, so a forged payload faces the same
|
|
# checks a real event does — and fetching by PR and id binds the two, so a
|
|
# payload pairing a real review with some other PR dies here instead of
|
|
# steering the handle job. The ids are spliced into API paths here and into the
|
|
# prompt later, so reject anything but digits at this edge.
|
|
KIND="$EVENT_NAME"
|
|
if [ "$EVENT_NAME" = "repository_dispatch" ]; then
|
|
KIND="$PAYLOAD_KIND"
|
|
if ! [[ "$PAYLOAD_PR" =~ ^[0-9]+$ && "$PAYLOAD_ID" =~ ^[0-9]+$ ]]; then
|
|
echo "malformed dispatch payload — skipping"
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
case "$KIND" in
|
|
pull_request_review)
|
|
if ! REVIEW=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR/reviews/$PAYLOAD_ID"); then
|
|
echo "review $PAYLOAD_ID not found on PR $PAYLOAD_PR — skipping"
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
REVIEW_AUTHOR=$(echo "$REVIEW" | jq -r '.user.login')
|
|
# REST reports the state uppercase (a webhook payload's is lowercase);
|
|
# normalize so the terminal-approval gate below reads one shape.
|
|
REVIEW_STATE=$(echo "$REVIEW" | jq -r '.state | ascii_downcase')
|
|
COMMENT_BODY=$(echo "$REVIEW" | jq -r '.body // ""')
|
|
echo "url=$(echo "$REVIEW" | jq -r '.html_url')" >> "$GITHUB_OUTPUT"
|
|
# A review without `submitted_at` (a PENDING one, which only a forged
|
|
# dispatch can name) would otherwise write the string `null`, which
|
|
# handle's `date -d` rejects — failing the job red where the empty-value
|
|
# guard would have skipped it.
|
|
echo "ts=$(echo "$REVIEW" | jq -r '.submitted_at // empty')" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
pull_request_review_comment)
|
|
if ! COMMENT=$(gh api "repos/$GITHUB_REPOSITORY/pulls/comments/$PAYLOAD_ID"); then
|
|
echo "comment $PAYLOAD_ID not found — skipping"
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
# Comments fetch by id alone, so bind the PR explicitly.
|
|
if [ "$(echo "$COMMENT" | jq -r '.pull_request_url')" != "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR" ]; then
|
|
echo "comment $PAYLOAD_ID does not belong to PR $PAYLOAD_PR — skipping"
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
COMMENT_AUTHOR=$(echo "$COMMENT" | jq -r '.user.login')
|
|
COMMENT_BODY=$(echo "$COMMENT" | jq -r '.body // ""')
|
|
echo "url=$(echo "$COMMENT" | jq -r '.html_url')" >> "$GITHUB_OUTPUT"
|
|
echo "ts=$(echo "$COMMENT" | jq -r '.updated_at')" >> "$GITHUB_OUTPUT"
|
|
;;
|
|
*)
|
|
echo "unknown dispatch kind '$KIND' — skipping"
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
;;
|
|
esac
|
|
fi
|
|
|
|
# Mentions always run
|
|
if [ "$KIND" = "issues" ]; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# The bot's own comments never summon it (its PAT-based User account is
|
|
# invisible to the Bot-type skip below). Placed *before* the mention check,
|
|
# since a bot comment can quote a prior @-mention. Comments only: the bot's
|
|
# review *submissions* are judged with the review kind below — a review carries
|
|
# reviewer-role signal a comment can't.
|
|
if { [ "$KIND" = "issue_comment" ] || [ "$KIND" = "pull_request_review_comment" ]; } \
|
|
&& [ "$COMMENT_AUTHOR" = "$BOT_NAME" ]; then
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
if [ -n "$COMMENT_BODY" ] && printf '%s\n' "$COMMENT_BODY" | grep -qF "@$BOT_NAME"; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
echo "reason=mention" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# Other bots' undirected comments (deploy notifications, CI status) summon by
|
|
# mention only, never by the engagement heuristics below — which would boot a
|
|
# no-op session per notification, twice when the source bot edits its comment.
|
|
if [ "$KIND" = "issue_comment" ] && [ "$COMMENT_AUTHOR_TYPE" = "Bot" ]; then
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
# A review's record includes review.body (checked above) but NOT the bodies of
|
|
# the inline comments attached to the review. Fetch them so a first-contact
|
|
# @-mention inside an inline comment is detected on PRs where the bot has no
|
|
# prior engagement. One object per line, so `--paginate` concatenates pages
|
|
# instead of reducing within one. Keep the `{body, in_reply_to_id}`
|
|
# construction: `in_reply_to_id` is an *optional* property, absent rather than
|
|
# null on a fresh comment, and building the object normalizes absent to null so
|
|
# the `== null` select below counts both shapes. A bare `.in_reply_to_id`
|
|
# stream would emit nothing for a fresh comment and count every review as
|
|
# reply-only.
|
|
if [ "$KIND" = "pull_request_review" ]; then
|
|
INLINE=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PAYLOAD_PR/reviews/$PAYLOAD_ID/comments" \
|
|
--jq '.[] | {body, in_reply_to_id}')
|
|
|
|
if printf '%s\n' "$INLINE" | jq -r '.body' | grep -qF "@$BOT_NAME"; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
echo "reason=mention" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
|
|
FRESH_INLINE=$(printf '%s\n' "$INLINE" | jq -s '[.[] | select(.in_reply_to_id == null)] | length')
|
|
|
|
# A contentless approval — no body, no inline comments — asks for nothing,
|
|
# whoever submitted it, and the bot cannot merge on its own. Without this it
|
|
# reads as engagement below and boots a session whose only outcome is a
|
|
# silent exit. `approved` and `$INLINE` empty are both load-bearing: a bare
|
|
# COMMENTED review is how GitHub wraps a human's inline reply (not terminal),
|
|
# and an approval whose nits live inline is a request to the PR's author — on
|
|
# a bot-authored PR, a role the bot has to act in.
|
|
if [ "$REVIEW_STATE" = "approved" ] \
|
|
&& [ -z "$COMMENT_BODY" ] \
|
|
&& [ -z "$INLINE" ]; then
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
# Non-mention: check bot engagement
|
|
if [ "$KIND" = "issue_comment" ]; then
|
|
ISSUE_NUMBER="$ISSUE_OR_PR_NUMBER"
|
|
|
|
if [ -z "$PR_URL" ]; then
|
|
if [ "$ISSUE_AUTHOR" = "$BOT_NAME" ]; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
if printf '%s\n' "$ISSUE_BODY" | grep -qF "@$BOT_NAME"; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
# Don't reduce inside jq: `gh api --paginate` applies `--jq` once per page,
|
|
# so `| length` emits one count per page rather than one overall. Past 100
|
|
# comments the variable holds e.g. `100\n7`, a numeric test on it errors
|
|
# with `integer expression expected`, and the failed test falls through to
|
|
# should_run=false — the bot goes quiet on its most-engaged threads.
|
|
# Capture the per-element stream and test it for emptiness — the bare
|
|
# substitution also keeps a failing `gh api` fatal under GHA's default
|
|
# `bash -e`.
|
|
BOT_COMMENTS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
|
|
--jq ".[] | select(.user.login == \"$BOT_NAME\") | .id")
|
|
if [ -n "$BOT_COMMENTS" ]; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
|
|
PR_NUMBER="$ISSUE_NUMBER"
|
|
else
|
|
PR_NUMBER="$PAYLOAD_PR"
|
|
fi
|
|
|
|
PR_AUTHOR=$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login')
|
|
|
|
# The bot's own review summons a session in exactly one shape: the reviewer
|
|
# role handing work to the author role — fresh content (a body, or an inline
|
|
# comment that isn't a reply) on a PR the bot authored. On another author's PR
|
|
# the review session already did whatever the review warranted; an empty-body
|
|
# reply-only review is the synthetic container GitHub wraps around an inline
|
|
# reply — the same comment the self-comment skip above drops on its other event
|
|
# path. Either would otherwise read as engagement below (the BOT_REVIEWS
|
|
# heuristic counts this very review) and boot a session that exits silently.
|
|
# Sits *after* the inline @-mention scan, so an explicit summons the bot quotes
|
|
# still wins.
|
|
if [ "$KIND" = "pull_request_review" ] && [ "$REVIEW_AUTHOR" = "$BOT_NAME" ]; then
|
|
if [ "$PR_AUTHOR" = "$BOT_NAME" ] \
|
|
&& { [ -n "$COMMENT_BODY" ] || [ "$FRESH_INLINE" -gt 0 ]; }; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
|
|
if [ "$PR_AUTHOR" = "$BOT_NAME" ]; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
|
|
# Captured, not counted — see the note on the issue-comment lookup above.
|
|
BOT_REVIEWS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews" \
|
|
--jq ".[] | select(.user.login == \"$BOT_NAME\") | .id")
|
|
if [ -n "$BOT_REVIEWS" ]; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
|
|
BOT_COMMENTS=$(gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments" \
|
|
--jq ".[] | select(.user.login == \"$BOT_NAME\") | .id")
|
|
if [ -n "$BOT_COMMENTS" ]; then
|
|
echo "should_run=true" >> "$GITHUB_OUTPUT"
|
|
echo "reason=participation" >> "$GITHUB_OUTPUT"; exit 0
|
|
fi
|
|
|
|
echo "should_run=false" >> "$GITHUB_OUTPUT"
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
|
|
BOT_NAME: worktrunk-bot
|
|
EVENT_NAME: ${{ github.event_name }}
|
|
COMMENT_BODY: ${{ github.event.comment.body }}
|
|
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
|
|
COMMENT_AUTHOR_TYPE: ${{ github.event.comment.user.type }}
|
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
ISSUE_OR_PR_NUMBER: ${{ github.event.issue.number }}
|
|
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
|
PR_URL: ${{ github.event.issue.pull_request.url }}
|
|
PAYLOAD_KIND: ${{ github.event.client_payload.kind }}
|
|
PAYLOAD_PR: ${{ github.event.client_payload.pr }}
|
|
PAYLOAD_ID: ${{ github.event.client_payload.id }}
|
|
|
|
handle:
|
|
needs: verify
|
|
if: needs.verify.outputs.should_run == 'true'
|
|
concurrency:
|
|
group: ${{ github.workflow }}-handle-${{ github.event.issue.number || github.event.client_payload.pr }}
|
|
cancel-in-progress: false
|
|
runs-on: ubuntu-24.04
|
|
environment:
|
|
name: tend
|
|
deployment: false
|
|
permissions:
|
|
contents: write
|
|
pull-requests: write
|
|
actions: read
|
|
issues: write
|
|
steps:
|
|
# Both halves of the reaction belong to this job, so the eyes can only
|
|
# go on once the job that takes them off has started. Put them in
|
|
# `verify` and `handle` respectively and the routine burst case strands
|
|
# them: a third mention on one thread evicts the second's pending
|
|
# `handle` — a job cancelled while queued allocates no runner and runs
|
|
# no steps, `always()` included — while its `verify` already reacted.
|
|
#
|
|
# The dispatch arm covers a relayed inline comment, whose id the check
|
|
# step verified belongs to this PR; a review *submission* has no single
|
|
# comment to react to, so it gets no eyes — same as when the events
|
|
# arrived directly. The job's own `if` already carries `should_run`.
|
|
- name: React with eyes
|
|
if: |
|
|
((github.event.comment && contains(github.event.comment.body, '@worktrunk-bot'))
|
|
|| (github.event.client_payload.kind == 'pull_request_review_comment'
|
|
&& needs.verify.outputs.reason == 'mention'))
|
|
run: |
|
|
gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \
|
|
|| echo "::warning::could not add the eyes reaction"
|
|
env:
|
|
REPO: ${{ github.repository }}
|
|
TARGET: ${{ github.event_name == 'issue_comment'
|
|
&& format('issues/comments/{0}', github.event.comment.id)
|
|
|| format('pulls/comments/{0}', github.event.comment.id || github.event.client_payload.id) }}
|
|
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
|
|
|
|
- uses: actions/checkout@v7
|
|
with:
|
|
fetch-depth: 0
|
|
fetch-tags: true
|
|
token: ${{ secrets.TEND_BOT_TOKEN }}
|
|
|
|
- uses: ./.github/actions/claude-setup
|
|
|
|
- name: Check out PR branch
|
|
if: |
|
|
(github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') ||
|
|
github.event_name == 'repository_dispatch'
|
|
run: |
|
|
PR_STATE=$(gh pr view "$PR_NUMBER" --json state --jq '.state')
|
|
if [ "$PR_STATE" = "OPEN" ]; then
|
|
gh pr checkout "$PR_NUMBER"
|
|
else
|
|
echo "::warning::PR is $PR_STATE — staying on default branch"
|
|
fi
|
|
env:
|
|
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
|
|
PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event.client_payload.pr }}
|
|
|
|
- name: Compute queue delay
|
|
id: delay
|
|
run: |
|
|
if [ -z "$EVENT_TS" ]; then
|
|
echo "seconds=" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
event_epoch=$(date -d "$EVENT_TS" +%s)
|
|
echo "seconds=$(( $(date +%s) - event_epoch ))" >> "$GITHUB_OUTPUT"
|
|
env:
|
|
# A relayed event's timestamp comes from verify, which read it off
|
|
# the API record — the dispatch payload never carries one to spoof.
|
|
EVENT_TS: ${{ github.event.comment.updated_at || needs.verify.outputs.ts || github.event.issue.updated_at }}
|
|
|
|
- uses: max-sixty/tend/claude@0.1.18
|
|
with:
|
|
github_token: ${{ secrets.TEND_BOT_TOKEN }}
|
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
bot_name: worktrunk-bot
|
|
model: opus
|
|
prompt: >-
|
|
${{ steps.delay.outputs.seconds
|
|
&& format('This job started {0}s after the triggering event (over ~40s means it was queued). ',
|
|
steps.delay.outputs.seconds) || '' }}Before acting,
|
|
check recent comments: exit silently if the bot already responded
|
|
to the trigger; handle any other unaddressed comments too.
|
|
|
|
${{ github.event_name == 'issues'
|
|
&& format('An issue was updated with a mention of you ({0}). Read it and respond.', github.event.issue.html_url)
|
|
|| (github.event.client_payload.kind == 'pull_request_review_comment' && needs.verify.outputs.reason == 'mention'
|
|
&& format('You were mentioned in an inline review comment on PR #{0} ({1}, comment ID {2}). Read the full context, then respond. If changes are requested, make them, commit, and push.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id))
|
|
|| (github.event.client_payload.kind == 'pull_request_review_comment'
|
|
&& format('An inline review comment was posted on a PR where you previously participated (PR #{0}, {1}, comment ID {2}). Read the full context. Only respond if the comment is directed at you or requests changes.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id))
|
|
|| (github.event.client_payload.kind == 'pull_request_review' && needs.verify.outputs.reason == 'mention'
|
|
&& format('A review was submitted on PR #{0} that mentions you ({1}, review ID {2}). Read the review and full context, then respond. If changes were requested, make them, commit, and push.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id))
|
|
|| (github.event.client_payload.kind == 'pull_request_review'
|
|
&& format('A review was submitted on a PR where you previously participated (PR #{0}, {1}, review ID {2}). Read the review and full context. If it requests changes or asks questions, respond appropriately — including when you authored the review: a review your review workflow left on your own PR is your reviewer role speaking, not a self-loop, so action it. Exit silently for a plain approval, a review with no actionable content, or one between other participants.', github.event.client_payload.pr, needs.verify.outputs.url, github.event.client_payload.id))
|
|
|| (contains(github.event.comment.body, '@worktrunk-bot')
|
|
&& format('You were mentioned in a comment ({0}). Read the full context and respond. If changes are requested, make them, commit, and push.', github.event.comment.html_url))
|
|
|| format('A user commented on an issue/PR where you previously participated ({0}). Read the full context. Only respond if the comment is directed at you, asks a question you can help with, or requests changes you can make. If the conversation is between other participants, exit silently.', github.event.comment.html_url)
|
|
}}
|
|
|
|
- name: Restore local setup actions for POST cleanup
|
|
if: always()
|
|
run: |
|
|
dir=.github/actions/claude-setup
|
|
git checkout "$GITHUB_SHA" -- "$dir" ||
|
|
echo "::warning::could not restore $dir from $GITHUB_SHA; POST cleanup of the local action may fail"
|
|
|
|
- name: Remove the eyes reaction
|
|
if: |
|
|
always()
|
|
&& ((github.event.comment && contains(github.event.comment.body, '@worktrunk-bot'))
|
|
|| (github.event.client_payload.kind == 'pull_request_review_comment'
|
|
&& needs.verify.outputs.reason == 'mention'))
|
|
run: |
|
|
REACTION_ID=$(gh api --paginate \
|
|
"repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \
|
|
--jq ".[] | select(.user.login == \"$BOT_NAME\") | .id" | head -n1)
|
|
if [ -n "$REACTION_ID" ]; then
|
|
gh api -X DELETE "repos/$REPO/$TARGET/reactions/$REACTION_ID" --silent \
|
|
|| echo "::warning::could not remove the eyes reaction"
|
|
fi
|
|
env:
|
|
REPO: ${{ github.repository }}
|
|
TARGET: ${{ github.event_name == 'issue_comment'
|
|
&& format('issues/comments/{0}', github.event.comment.id)
|
|
|| format('pulls/comments/{0}', github.event.comment.id || github.event.client_payload.id) }}
|
|
BOT_NAME: worktrunk-bot
|
|
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
|