Skip to content

Start the auto-spawned terminal in the departing pane's cwd #5437

Start the auto-spawned terminal in the departing pane's cwd

Start the auto-spawned terminal in the departing pane's cwd #5437

Workflow file for this run

# Generated by tend 0.2.10. 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]
# Review events arrive re-posted by tend-mention-relay. 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:
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: |
vars.TEND_ENABLED != 'false' && (
github.event_name == 'repository_dispatch' ||
(github.event_name == 'issues' &&
contains(github.event.issue.body, '@dormouse-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
permissions:
contents: read
outputs:
should_run: ${{ steps.check.outputs.should_run }}
reason: ${{ steps.check.outputs.reason }}
url: ${{ steps.check.outputs.url }}
ts: ${{ steps.check.outputs.ts }}
steps:
- uses: astral-sh/setup-uv@v10.1.0
id: tend_uv
env:
UV_NO_MODIFY_PATH: "1"
with:
version: "0.12.13"
enable-cache: false
ignore-empty-workdir: true
- name: Verify bot engagement
id: check
run: |
"$TEND_UV" run --script - <<'TEND_PY'
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"""Decide whether a mention event should start an agent session."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
def gh(*args: str, quiet: bool = False) -> str:
# `gh` colorizes a piped `--json`/`--jq` response when the job's
# environment forces color, and the ANSI codes land inside the body
# `gh_json` parses. `CLICOLOR_FORCE=0` is the setting that defeats it:
# `gh` ranks a forced value above `NO_COLOR`, so `NO_COLOR` alone loses.
env = os.environ.copy()
env.update(NO_COLOR="1", CLICOLOR_FORCE="0")
result = subprocess.run(
["gh", *args], capture_output=True, text=True, env=env, check=False
)
if result.returncode:
if result.stderr and not quiet:
sys.stderr.write(result.stderr)
raise subprocess.CalledProcessError(
result.returncode, result.args, result.stdout, result.stderr
)
return result.stdout
def gh_json(*args: str, quiet: bool = False) -> Any:
return json.loads(gh(*args, quiet=quiet))
def gh_paginated(path: str) -> list[dict[str, Any]]:
text = gh("api", "--paginate", path)
decoder = json.JSONDecoder()
position = 0
items: list[dict[str, Any]] = []
while position < len(text):
while position < len(text) and text[position].isspace():
position += 1
if position == len(text):
break
page, position = decoder.raw_decode(text, position)
if not isinstance(page, list):
raise TypeError("paginated GitHub response was not an array")
items.extend(page)
return items
def actor_login(actor: object) -> str:
"""Return a GitHub actor login, including for deleted-account records."""
if not isinstance(actor, dict):
return ""
return str(actor.get("login") or "")
def output(name: str, value: str | bool) -> None:
rendered = str(value).lower() if isinstance(value, bool) else value
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
stream.write(f"{name}={rendered}\n")
def verdict(should_run: bool, reason: str = "") -> int:
output("should_run", should_run)
if reason:
output("reason", reason)
return 0
def main() -> int:
env = os.environ
bot = env.get("BOT_NAME", "")
repo = env.get("GITHUB_REPOSITORY", "")
kind = env.get("EVENT_NAME", "")
comment_body = env.get("COMMENT_BODY", "")
comment_author = env.get("COMMENT_AUTHOR", "")
review_author = ""
review_state = ""
inline: list[dict[str, Any]] = []
if kind == "repository_dispatch":
kind = env.get("PAYLOAD_KIND", "")
pr = env.get("PAYLOAD_PR", "")
item_id = env.get("PAYLOAD_ID", "")
if not pr.isdigit() or not item_id.isdigit():
print("malformed dispatch payload — skipping")
return verdict(False)
if kind == "pull_request_review":
try:
review = gh_json(
"api", f"repos/{repo}/pulls/{pr}/reviews/{item_id}", quiet=True
)
except (subprocess.CalledProcessError, json.JSONDecodeError):
print(f"review {item_id} not found on PR {pr} — skipping")
return verdict(False)
review_author = actor_login(review.get("user"))
review_state = str(review["state"]).lower()
comment_body = review.get("body") or ""
output("url", review["html_url"])
output("ts", review.get("submitted_at") or "")
elif kind == "pull_request_review_comment":
try:
comment = gh_json(
"api", f"repos/{repo}/pulls/comments/{item_id}", quiet=True
)
except (subprocess.CalledProcessError, json.JSONDecodeError):
print(f"comment {item_id} not found — skipping")
return verdict(False)
expected_pr = f"https://api.github.com/repos/{repo}/pulls/{pr}"
if comment.get("pull_request_url") != expected_pr:
print(f"comment {item_id} does not belong to PR {pr} — skipping")
return verdict(False)
comment_author = actor_login(comment.get("user"))
comment_body = comment.get("body") or ""
output("url", comment["html_url"])
output("ts", comment["updated_at"])
else:
print(f"unknown dispatch kind '{kind}' — skipping")
return verdict(False)
if kind == "issues":
return verdict(True)
if (
kind in {"issue_comment", "pull_request_review_comment"}
and comment_author == bot
):
return verdict(False)
if comment_body and f"@{bot}" in comment_body:
return verdict(True, "mention")
if kind == "issue_comment" and env.get("COMMENT_AUTHOR_TYPE") == "Bot":
return verdict(False)
if kind == "pull_request_review":
inline = gh_paginated(
f"repos/{repo}/pulls/{env.get('PAYLOAD_PR', '')}/reviews/"
f"{env.get('PAYLOAD_ID', '')}/comments"
)
if any(f"@{bot}" in (comment.get("body") or "") for comment in inline):
return verdict(True, "mention")
# A review the bot wrote hands work to nobody: the review session
# applies the findings it raised. The mention checks run first, so
# naming the bot in a review still summons a session.
if review_author == bot:
return verdict(False)
if review_state == "approved" and not comment_body and not inline:
return verdict(False)
if kind == "issue_comment":
issue_number = env.get("ISSUE_OR_PR_NUMBER", "")
if not env.get("PR_URL"):
if env.get("ISSUE_AUTHOR") == bot or f"@{bot}" in env.get("ISSUE_BODY", ""):
return verdict(True)
comments = gh_paginated(f"repos/{repo}/issues/{issue_number}/comments")
return verdict(
any(actor_login(comment.get("user")) == bot for comment in comments)
)
pr_number = issue_number
else:
pr_number = env.get("PAYLOAD_PR", "")
pr = gh_json("pr", "view", pr_number, "--repo", repo, "--json", "author")
pr_author = actor_login(pr.get("author"))
if pr_author == bot:
return verdict(True, "participation")
reviews = gh_paginated(f"repos/{repo}/pulls/{pr_number}/reviews")
if any(actor_login(review.get("user")) == bot for review in reviews):
return verdict(True, "participation")
comments = gh_paginated(f"repos/{repo}/issues/{pr_number}/comments")
if any(actor_login(comment.get("user")) == bot for comment in comments):
return verdict(True, "participation")
return verdict(False)
if __name__ == "__main__":
try:
raise SystemExit(main())
except subprocess.CalledProcessError as error:
raise SystemExit(error.returncode or 1) from None
TEND_PY
env:
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
TEND_UV: ${{ steps.tend_uv.outputs.uv-path }}
BOT_NAME: dormouse-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. The job's own `if` already
# carries `should_run`.
- name: React with eyes
if: |
((github.event.comment && contains(github.event.comment.body, '@dormouse-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.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 }}
- 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.2.10
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: dormouse-bot
model: opus
checkout_mode: mention
base_branch: ${{ github.event.repository.default_branch }}
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. 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, '@dormouse-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: Remove the eyes reaction
if: |
always()
&& ((github.event.comment && contains(github.event.comment.body, '@dormouse-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.client_payload.id) }}
BOT_NAME: dormouse-bot
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}