mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
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:
@@ -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
@@ -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" \
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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" \
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
Reference in New Issue
Block a user