chore: update tend workflows (0.1.24 → 0.2.0) (#4035)

Nightly regeneration of the tend workflow files, picking up the 0.1.x →
0.2.0 release.

**tend version:** 0.1.24 → 0.2.0

## Notable changes

- **Runtime switch and a per-workflow enable gate.** Every generated
workflow now reads `.config/tend.yaml` at job start and skips when tend
is disabled, so turning tend off no longer needs a workflow edit
(max-sixty/tend#1132).
- **`ci-fix` gets a concurrency group per branch and watched workflow.**
A red branch that fails every push collapses into one session instead of
one per commit, and a red `publish-site` can't starve behind a stream of
red `ci` (max-sixty/tend#1148).
- **Runner logic moved out of Bash and skills into Python**, with new
Claude effort/harness arguments and experimental Codex subscription auth
threaded through the generated workflows (max-sixty/tend#1158,
max-sixty/tend#1161, max-sixty/tend#1159).
- **Review and approval fixes**: a standing bot approval is now
dismissed when a review withholds its verdict, and when another PR
merging invalidates it (max-sixty/tend#1136, max-sixty/tend#1139).
- **Notifications acknowledge each resolved thread** rather than marking
the whole repository read, and secret listings are paginated so a second
page can't pass as clean (max-sixty/tend#1121, max-sixty/tend#1137).

Full comparison:
https://github.com/max-sixty/tend/compare/0.1.24...0.2.0

Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>
This commit is contained in:
Worktrunk Bot
2026-09-07 09:04:13 -07:00
committed by GitHub
parent c40e1220ce
commit 15e7658bd9
8 changed files with 1097 additions and 292 deletions
+81 -2
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -16,6 +16,15 @@ on:
jobs:
fix-ci:
if: github.repository_owner == 'max-sixty' && github.event.workflow_run.conclusion == 'failure'
concurrency:
# A red branch fails every push that follows, each on its own commit, so
# one session per branch — not per commit — is what collapses the burst.
# The watched workflow is in the key too: a red `publish-site` must not
# starve behind a stream of red `ci`. Never cancel — a running session
# may already have pushed a branch or opened a PR. Default queue depth
# is right: the newest failure replaces the pending one.
group: ${{ github.workflow }}-${{ github.event.workflow_run.name }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
runs-on: ubuntu-24.04
environment:
name: tend
@@ -25,7 +34,75 @@ jobs:
pull-requests: write
actions: read
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
ref: main
fetch-depth: 0
@@ -33,8 +110,10 @@ jobs:
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.tend_enabled.outputs.enabled == 'true'
- uses: max-sixty/tend/claude@0.1.24
- uses: max-sixty/tend/claude@0.2.0
if: steps.tend_enabled.outputs.enabled == 'true'
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+391 -200
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -76,10 +76,78 @@ jobs:
permissions:
contents: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
# 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
if: steps.tend_enabled.outputs.enabled == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
@@ -108,224 +176,272 @@ jobs:
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:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- uses: astral-sh/setup-uv@v10.0.1
if: steps.tend_enabled.outputs.enabled == 'true'
with:
version: "0.12.10"
ignore-empty-workdir: true
- name: Verify bot engagement
id: check
if: steps.tend_enabled.outputs.enabled == 'true'
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
uv run --script - <<'TEND_PY'
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"""Decide whether a mention event should start an agent session."""
# 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
from __future__ import annotations
# Mentions always run
if [ "$KIND" = "issues" ]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
# 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
def gh(*args: str, quiet: bool = False) -> str:
result = subprocess.run(["gh", *args], capture_output=True, text=True, 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
# 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}')
def gh_json(*args: str, quiet: bool = False) -> Any:
return json.loads(gh(*args, quiet=quiet))
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')
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
# 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"
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 "")
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
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")
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
def verdict(should_run: bool, reason: str = "") -> int:
output("should_run", should_run)
if reason:
output("reason", reason)
return 0
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
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]] = []
fresh_inline = 0
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
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)
echo "should_run=false" >> "$GITHUB_OUTPUT"
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")
fresh_inline = sum(comment.get("in_reply_to_id") is None for comment in inline)
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 kind == "pull_request_review" and review_author == bot:
if pr_author == bot and (comment_body or fresh_inline > 0):
return verdict(True, "participation")
return verdict(False)
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 }}
BOT_NAME: worktrunk-bot
@@ -357,6 +473,73 @@ jobs:
actions: read
issues: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
# 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
@@ -370,9 +553,9 @@ jobs:
# 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'))
(steps.tend_enabled.outputs.enabled == 'true') && (((github.event.comment && contains(github.event.comment.body, '@worktrunk-bot'))
|| (github.event.client_payload.kind == 'pull_request_review_comment'
&& needs.verify.outputs.reason == 'mention'))
&& needs.verify.outputs.reason == 'mention')))
run: |
gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \
|| echo "::warning::could not add the eyes reaction"
@@ -384,17 +567,20 @@ jobs:
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.tend_enabled.outputs.enabled == 'true'
- name: Check out PR branch
if: |
(github.event_name == 'issue_comment' && github.event.issue.pull_request.url != '') ||
github.event_name == 'repository_dispatch'
steps.tend_enabled.outputs.enabled == 'true' &&
((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
@@ -408,6 +594,7 @@ jobs:
- name: Compute queue delay
id: delay
if: steps.tend_enabled.outputs.enabled == 'true'
run: |
if [ -z "$EVENT_TS" ]; then
echo "seconds=" >> "$GITHUB_OUTPUT"
@@ -420,7 +607,8 @@ jobs:
# 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.24
- uses: max-sixty/tend/claude@0.2.0
if: steps.tend_enabled.outputs.enabled == 'true'
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -456,7 +644,9 @@ jobs:
}}
- name: Restore local setup actions for POST cleanup
if: always()
if: |
always()
&& (steps.tend_enabled.outputs.enabled == 'true')
run: |
dir=.github/actions/tend-setup
git checkout "$GITHUB_SHA" -- "$dir" ||
@@ -465,9 +655,10 @@ jobs:
- name: Remove the eyes reaction
if: |
always()
&& ((github.event.comment && contains(github.event.comment.body, '@worktrunk-bot'))
&& (steps.tend_enabled.outputs.enabled == 'true')
&& (((github.event.comment && contains(github.event.comment.body, '@worktrunk-bot'))
|| (github.event.client_payload.kind == 'pull_request_review_comment'
&& needs.verify.outputs.reason == 'mention'))
&& needs.verify.outputs.reason == 'mention')))
run: |
REACTION_ID=$(gh api --paginate \
"repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \
+72 -2
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -25,7 +25,75 @@ jobs:
actions: read
issues: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
ref: main
fetch-depth: 0
@@ -33,8 +101,10 @@ jobs:
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.tend_enabled.outputs.enabled == 'true'
- uses: max-sixty/tend/claude@0.1.24
- uses: max-sixty/tend/claude@0.2.0
if: steps.tend_enabled.outputs.enabled == 'true'
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+252 -77
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -28,93 +28,268 @@ jobs:
actions: read
issues: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- uses: astral-sh/setup-uv@v10.0.1
if: steps.tend_enabled.outputs.enabled == 'true'
with:
version: "0.12.10"
ignore-empty-workdir: true
- name: Check for unread notifications and conflicted PRs
id: check
if: steps.tend_enabled.outputs.enabled == 'true'
env:
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
run: |
# shellcheck shell=bash
# Establish the repository's frequent maintenance queue and decide whether the
# agent needs to boot. Inlined into the generated workflow: env in,
# GITHUB_OUTPUT out.
#
# env: GITHUB_REPOSITORY, GITHUB_OUTPUT, GITHUB_TOKEN
uv run --script - <<'TEND_PY'
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"""Decide whether the notifications workflow has work for an agent."""
# Activity newer than this belongs to an event workflow that may still be
# running. The same cutoff is passed to the agent and, once every older item has
# a semantic outcome, to GitHub's repository-level mark-read endpoint. Newer
# activity therefore cannot be acknowledged by this run.
CUTOFF=$(date -u -d '10 minutes ago' +%Y-%m-%dT%H:%M:%SZ)
echo "cutoff=$CUTOFF" >> "$GITHUB_OUTPUT"
from __future__ import annotations
# Watching makes a new issue or PR visible before the bot has participated in
# its thread. The installer sets this too; every poll repeats the idempotent PUT
# so a later settings change is repaired without additional state.
gh api "repos/$GITHUB_REPOSITORY/subscription" -X PUT \
-F subscribed=true -F ignored=false --silent \
|| echo "::warning::could not enable repository watching; retrying next cycle"
import json
import os
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
# Capture every unread page at the cutoff. GitHub occasionally returns an HTML
# error page even with a successful status, so validate the slurped page shape.
# A failed fetch leaves the queue untouched for the next scheduled cycle.
ENDPOINT="notifications?before=$CUTOFF&per_page=100"
if PAGES=$(gh api "$ENDPOINT" --paginate --slurp 2>/dev/null) \
&& NOTIFS=$(echo "$PAGES" | jq -ce \
'if type == "array" and all(.[]; type == "array") then add // [] else error("invalid pages") end'); then
COUNT=$(echo "$NOTIFS" | jq 'length')
else
COUNT=0
echo "::warning::notifications fetch failed; queue left for the next cycle"
fi
GRAPHQL_QUERY = """
query($q: String!) {
search(query: $q, type: ISSUE, first: 100) {
nodes { ... on PullRequest {
mergeable headRefOid
comments(last: 100) { nodes { author { login } body } }
} }
}
}
"""
echo "count=$COUNT" >> "$GITHUB_OUTPUT"
# GitHub computes mergeability lazily after the base moves. UNKNOWN therefore
# means "worth a synchronous local test", not "clean". This is only a cheap
# boot gate; the agent test-merges every candidate before changing a branch.
# Read the newest comments: a deferral is normally the PR's latest activity.
# An older marker can waste boots, but the resolver paginates before acting.
# shellcheck disable=SC2016 # $q is a GraphQL variable, not a shell variable.
if BOT_LOGIN=$(gh api user --jq .login 2>/dev/null) \
&& PRS=$(gh api graphql -f query='
query($q: String!) {
search(query: $q, type: ISSUE, first: 100) {
nodes { ... on PullRequest {
mergeable headRefOid
comments(last: 100) { nodes { author { login } body } }
} }
}
}' -f q="repo:$GITHUB_REPOSITORY author:$BOT_LOGIN is:pr is:open" \
--jq '.data.search.nodes' 2>/dev/null) \
&& CONFLICT_COUNT=$(jq -er --arg bot "$BOT_LOGIN" '
[.[]
| select(.mergeable != "MERGEABLE")
| . as $pr
| "<!-- tend-conflict-deferred head=\($pr.headRefOid) -->" as $marker
| select(any($pr.comments.nodes[]?;
.author.login == $bot
and (((.body // "") | sub("\\s+$"; "") | split("\n") | last) == $marker))
| not)]
| length' <<<"$PRS"); then
:
else
CONFLICT_COUNT=0
echo "::warning::bot PR conflict scan failed; retrying next cycle"
fi
def _gh(*args: str, quiet: bool = False) -> str:
result = subprocess.run(
["gh", *args],
capture_output=True,
text=True,
env=os.environ.copy(),
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
echo "conflict_count=$CONFLICT_COUNT" >> "$GITHUB_OUTPUT"
if [ "$COUNT" = "0" ] && [ "$CONFLICT_COUNT" = "0" ]; then
echo "No notification or conflict work — skipping"
else
[ "$COUNT" = "0" ] || \
echo "$COUNT notification task(s) — proceeding"
[ "$CONFLICT_COUNT" = "0" ] || \
echo "$CONFLICT_COUNT possible conflicted bot PR(s) — proceeding"
fi
def _json(*args: str, quiet: bool = False) -> Any:
return json.loads(_gh(*args, quiet=quiet))
def _paginated(path: str) -> list[Any]:
text = _gh("api", path, "--paginate", quiet=True)
decoder = json.JSONDecoder()
pages: list[Any] = []
position = 0
saw_page = False
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)
saw_page = True
if not isinstance(page, list):
raise TypeError("paginated GitHub response was not an array")
pages.extend(page)
if not saw_page:
raise ValueError("paginated GitHub response was empty")
return pages
def _output(name: str, value: str | int) -> None:
with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as stream:
stream.write(f"{name}={value}\n")
def _notifications(cutoff: str) -> int:
try:
return len(_paginated(f"notifications?before={cutoff}&per_page=100"))
except (
json.JSONDecodeError,
subprocess.CalledProcessError,
TypeError,
ValueError,
):
print("::warning::notifications fetch failed; queue left for the next cycle")
return 0
def _actor_login(actor: object) -> str:
if not isinstance(actor, dict):
return ""
return str(actor.get("login") or "")
def _is_deferred(pr: dict[str, Any], bot: str) -> bool:
marker = f"<!-- tend-conflict-deferred head={pr.get('headRefOid', '')} -->"
comments = pr.get("comments")
nodes = comments.get("nodes", []) if isinstance(comments, dict) else []
return any(
_actor_login(comment.get("author")) == bot
and str(comment.get("body") or "").rstrip().split("\n")[-1] == marker
for comment in nodes
if isinstance(comment, dict)
)
def _conflicts(repo: str) -> int:
try:
bot = _gh("api", "user", "--jq", ".login", quiet=True).strip()
if not bot:
raise ValueError("authenticated GitHub login was empty")
response = _json(
"api",
"graphql",
"-f",
f"query={GRAPHQL_QUERY}",
"-f",
f"q=repo:{repo} author:{bot} is:pr is:open",
quiet=True,
)
nodes = response["data"]["search"]["nodes"]
if not isinstance(nodes, list):
raise TypeError("GraphQL search nodes were not an array")
return sum(
pr.get("mergeable") != "MERGEABLE" and not _is_deferred(pr, bot)
for pr in nodes
if isinstance(pr, dict)
)
except (
json.JSONDecodeError,
KeyError,
subprocess.CalledProcessError,
TypeError,
ValueError,
):
print("::warning::bot PR conflict scan failed; retrying next cycle")
return 0
def main(*, now: datetime | None = None) -> int:
repo = os.environ["GITHUB_REPOSITORY"]
cutoff = ((now or datetime.now(UTC)) - timedelta(minutes=10)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
_output("cutoff", cutoff)
try:
_gh(
"api",
f"repos/{repo}/subscription",
"-X",
"PUT",
"-F",
"subscribed=true",
"-F",
"ignored=false",
"--silent",
quiet=True,
)
except subprocess.CalledProcessError:
print("::warning::could not enable repository watching; retrying next cycle")
count = _notifications(cutoff)
_output("count", count)
conflict_count = _conflicts(repo)
_output("conflict_count", conflict_count)
if count == 0 and conflict_count == 0:
print("No notification or conflict work — skipping")
else:
if count:
print(f"{count} notification task(s) — proceeding")
if conflict_count:
print(f"{conflict_count} possible conflicted bot PR(s) — proceeding")
return 0
if __name__ == "__main__":
raise SystemExit(main())
TEND_PY
- uses: actions/checkout@v7
if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch'
if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch')
with:
ref: main
fetch-depth: 0
@@ -122,9 +297,9 @@ jobs:
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch'
- uses: max-sixty/tend/claude@0.1.24
if: steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch'
if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch')
- uses: max-sixty/tend/claude@0.2.0
if: (steps.tend_enabled.outputs.enabled == 'true') && (steps.check.outputs.count != '0' || steps.check.outputs.conflict_count != '0' || github.event_name == 'workflow_dispatch')
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+72 -2
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -25,7 +25,75 @@ jobs:
actions: read
issues: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
ref: main
fetch-depth: 0
@@ -33,8 +101,10 @@ jobs:
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.tend_enabled.outputs.enabled == 'true'
- uses: max-sixty/tend/claude@0.1.24
- uses: max-sixty/tend/claude@0.2.0
if: steps.tend_enabled.outputs.enabled == 'true'
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+81 -4
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -31,7 +31,75 @@ jobs:
actions: read
issues: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- name: React with eyes
if: steps.tend_enabled.outputs.enabled == 'true'
run: |
gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \
|| echo "::warning::could not add the eyes reaction"
@@ -44,12 +112,14 @@ jobs:
# tree lands after it. Setup executes as the runner user, outside the
# containment the harness builds for the contributor's code.
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
fetch-depth: 0
fetch-tags: true
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.tend_enabled.outputs.enabled == 'true'
# GitHub only materializes refs/pull/N/merge for mergeable PRs — on
# conflicting PRs it 404s and every downstream step cascades as skipped.
@@ -58,6 +128,7 @@ jobs:
# tree.
- name: Resolve PR checkout ref
id: pr_ref
if: steps.tend_enabled.outputs.enabled == 'true'
env:
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
PR: ${{ github.event.pull_request.number }}
@@ -69,6 +140,7 @@ jobs:
echo "::notice::refs/pull/$PR/merge unavailable (likely merge conflict); falling back to /head"
fi
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
ref: ${{ steps.pr_ref.outputs.ref }}
allow-unsafe-pr-checkout: true
@@ -77,7 +149,8 @@ jobs:
fetch-tags: true
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: max-sixty/tend/claude@0.1.24
- uses: max-sixty/tend/claude@0.2.0
if: steps.tend_enabled.outputs.enabled == 'true'
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -94,14 +167,18 @@ jobs:
/tend-ci-runner:review ${{ github.event.pull_request.number }}
- name: Restore local setup actions for POST cleanup
if: always()
if: |
always()
&& (steps.tend_enabled.outputs.enabled == 'true')
run: |
dir=.github/actions/tend-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()
if: |
always()
&& (steps.tend_enabled.outputs.enabled == 'true')
run: |
REACTION_ID=$(gh api --paginate \
"repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \
+76 -3
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -28,7 +28,75 @@ jobs:
actions: read
issues: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- name: React with eyes
if: steps.tend_enabled.outputs.enabled == 'true'
run: |
gh api "repos/$REPO/$TARGET/reactions" -f content=eyes --silent \
|| echo "::warning::could not add the eyes reaction"
@@ -38,6 +106,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.TEND_BOT_TOKEN }}
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
ref: main
fetch-depth: 0
@@ -45,8 +114,10 @@ jobs:
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.tend_enabled.outputs.enabled == 'true'
- uses: max-sixty/tend/claude@0.1.24
- uses: max-sixty/tend/claude@0.2.0
if: steps.tend_enabled.outputs.enabled == 'true'
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
@@ -63,7 +134,9 @@ jobs:
/tend-ci-runner:triage ${{ github.event.issue.number }}
- name: Remove the eyes reaction
if: always()
if: |
always()
&& (steps.tend_enabled.outputs.enabled == 'true')
run: |
REACTION_ID=$(gh api --paginate \
"repos/$REPO/$TARGET/reactions?content=eyes&per_page=100" \
+72 -2
View File
@@ -1,4 +1,4 @@
# Generated by tend 0.1.24. Regenerate with: uvx tend@latest init
# Generated by tend 0.2.0. 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,
@@ -25,7 +25,75 @@ jobs:
actions: read
issues: write
steps:
- name: Check whether tend is enabled
id: tend_enabled
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
-H "Accept: application/vnd.github.raw+json" \
"repos/$GITHUB_REPOSITORY/contents/.config/tend.yaml" \
> "$RUNNER_TEMP/tend.yaml"
ruby - "$RUNNER_TEMP/tend.yaml" <<'RUBY' >> "$GITHUB_OUTPUT"
# Inspect the parsed YAML node so the extra YAML 1.1 boolean words (yes/no/on/off)
# do not diverge from the YAML 1.2 parser used by `tend init`.
require "psych"
path = ARGV.fetch(0)
documents = Psych.parse_stream(File.read(path, mode: "r:bom|utf-8")).children
unless documents.length == 1
abort "tend config must contain exactly one YAML document"
end
mapping = documents.first.root
unless mapping.is_a?(Psych::Nodes::Mapping)
abort "tend config must contain a YAML mapping"
end
def has_yaml_merge_key?(node)
case node
when Psych::Nodes::Mapping
node.children.each_slice(2).any? do |key, value|
(key.is_a?(Psych::Nodes::Scalar) && key.plain && key.value == "<<") ||
has_yaml_merge_key?(key) || has_yaml_merge_key?(value)
end
when Psych::Nodes::Sequence
node.children.any? { |value| has_yaml_merge_key?(value) }
else
false
end
end
if has_yaml_merge_key?(mapping)
abort "tend config: YAML merge keys (<<) are not supported"
end
matches = mapping.children.each_slice(2).select do |key, _value|
key.is_a?(Psych::Nodes::Scalar) && key.value == "enabled"
end
abort "tend config: enabled must appear at most once" if matches.length > 1
value = matches.dig(0, 1)
enabled = true
if value
bool_tag = value.respond_to?(:tag) && value.tag == "tag:yaml.org,2002:bool"
literal = value.value.downcase if value.is_a?(Psych::Nodes::Scalar)
unless value.is_a?(Psych::Nodes::Scalar) &&
(value.plain || bool_tag) &&
["true", "false"].include?(literal)
abort "tend config: enabled must be true or false"
end
enabled = literal == "true"
end
puts "enabled=#{enabled}"
unless enabled
warn "::notice title=Tend disabled::The tend config sets enabled: false; skipping this job"
end
RUBY
- uses: actions/checkout@v7
if: steps.tend_enabled.outputs.enabled == 'true'
with:
ref: main
fetch-depth: 0
@@ -33,8 +101,10 @@ jobs:
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
if: steps.tend_enabled.outputs.enabled == 'true'
- uses: max-sixty/tend/claude@0.1.24
- uses: max-sixty/tend/claude@0.2.0
if: steps.tend_enabled.outputs.enabled == 'true'
with:
github_token: ${{ secrets.TEND_BOT_TOKEN }}
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}