Skip to content

tend-notifications #6975

tend-notifications

tend-notifications #6975

# Generated by tend 0.2.7. Regenerate with: uvx tend@latest init
#
# Do not edit this file directly — it will be overwritten on regeneration.
# To customize behavior, edit the relevant skill (for example,
# `running-tend`) in this repo's .claude/skills/ directory, or open an issue at
# https://github.com/max-sixty/tend/issues for changes that need to
# happen upstream in the tend-ci-runner plugin.
name: tend-notifications
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
jobs:
notifications:
concurrency:
group: tend-notifications
cancel-in-progress: false
if: github.repository_owner == 'max-sixty'
runs-on: ubuntu-24.04
environment:
name: tend
deployment: false
permissions:
contents: write
pull-requests: write
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.1.0
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: |
uv run --script - <<'TEND_PY'
# /// script
# requires-python = ">=3.12"
# dependencies = []
# ///
"""Decide whether the notifications workflow has work for an agent."""
from __future__ import annotations
import json
import os
import subprocess
import sys
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
GRAPHQL_QUERY = """
query($q: String!) {
search(query: $q, type: ISSUE, first: 100) {
nodes { ... on PullRequest {
mergeable headRefOid
comments(last: 100) { nodes { author { login } body } }
} }
}
}
"""
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
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.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
fetch-tags: true
token: ${{ secrets.TEND_BOT_TOKEN }}
- uses: ./.github/actions/tend-setup
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.7
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 }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
bot_name: worktrunk-bot
model: opus
checkout_mode: base
base_branch: ${{ github.event.repository.default_branch }}
sandbox_setup: |2
mkdir -p ~/.local/bin
uv tool install --quiet pre-commit
for t in cargo-insta cargo-nextest pre-commit nu nix; do command -v "$t" >/dev/null || { echo "::error::tend sandbox is missing $t; see Sandbox toolchain in .github/CLAUDE.md"; exit 1; }; done
prompt: |2
/tend-ci-runner:notifications
Notification snapshot cutoff: ${{ steps.check.outputs.cutoff }}
Possible conflicted bot PR count: ${{ steps.check.outputs.conflict_count }}