From 5fdcba59b2ed419c2a122f4300c9d357df735810 Mon Sep 17 00:00:00 2001 From: Ivan Yang Date: Sat, 10 Oct 2026 12:49:29 -0700 Subject: [PATCH 1/5] ci: track PR age and idle time with weekly follow-ups Signed-off-by: Ivan Yang --- .github/AGENTS.md | 11 + .github/scripts/PR_TRIAGE.md | 71 +++ .github/scripts/project_triage.py | 420 ++++++++++++++++++ .github/workflows/project-triage.yml | 77 ++++ tests/python/automation/pyproject.toml | 10 + .../python/automation/test_project_triage.py | 301 +++++++++++++ 6 files changed, 890 insertions(+) create mode 100644 .github/AGENTS.md create mode 100644 .github/scripts/PR_TRIAGE.md create mode 100644 .github/scripts/project_triage.py create mode 100644 .github/workflows/project-triage.yml create mode 100644 tests/python/automation/pyproject.toml create mode 100644 tests/python/automation/test_project_triage.py diff --git a/.github/AGENTS.md b/.github/AGENTS.md new file mode 100644 index 000000000..c362fb06b --- /dev/null +++ b/.github/AGENTS.md @@ -0,0 +1,11 @@ + + +# Automation boundaries + +- PR reminders apply only to open, non-draft PRs; recheck immediately before posting. +- Derive merge readiness from the PR's checks, reviews and mergeability, not a manually maintained Project status. +- Keep elapsed PR age distinct from inactivity and observed merge-readiness duration. +- Public PR comments and workflow summaries must not expose private Project notes or fields. diff --git a/.github/scripts/PR_TRIAGE.md b/.github/scripts/PR_TRIAGE.md new file mode 100644 index 000000000..c2b0657ff --- /dev/null +++ b/.github/scripts/PR_TRIAGE.md @@ -0,0 +1,71 @@ + + +# PR triage metrics and reminders + +`project-triage.yml` runs daily at 16:43 UTC (09:43 PDT / 08:43 PST), after +merging to the default branch. GitHub schedules may be delayed. Manual runs +default to a read-only preview; enable `apply` to update fields and `notify` +to send comments. The workflow never merges or closes PRs. + +## Fields + +- **Idle**: full 24-hour periods since the latest human comment, submitted + review, inline review comment, or human-linked commit's committer timestamp. + Falls back to PR creation. Bot activity, CI and these reminder comments do + not reset it. Editing an old comment does not reset it. Historical Git + timestamps cannot establish when a rebased commit was pushed. +- **Age**: full 24-hour periods since PR creation, independent of Idle. +- **Ready days / Ready since**: time since first observing the current head + as non-draft, mergeable, `CLEAN`, review `APPROVED` and check rollup `SUCCESS`. + No historical readiness is inferred on first run. Failed, pending, unknown, + draft or new-head states reset this clock. An observation gap exceeding 48 + hours also resets it. Changes between daily observations may not be detected. +- **Triage state**: internal JSON storing the Ready head and observation times. + +The script adds missing fields and places Age immediately after Idle in table +views that already show Idle. Archived items, other repositories, issues and +closed/merged PRs are skipped. Draft PRs still receive Idle and Age updates. + +## Reminders + +Only open, non-draft PRs receive comments: + +1. Ready for at least 7 consecutive days and still unmerged. +2. Open for more than 14 consecutive days, regardless of recent activity. + +These are calendar-day rules, not business-day or holiday calculations. Both +reasons share one comment and one seven-day cooldown. The comment marker makes +retries after an ambiguous send discoverable; writes are never blindly retried. +The PR is read again immediately before posting to avoid nudging a newly closed +or draft PR. No API can make that check and comment one atomic operation. + +Mention PR assignees first, otherwise requested reviewers; for merge-ready PRs, +fall back to approving reviewers, then the author. All targets and comment text +come from public PR data, never private Project notes. The rules apply equally +to internal and external contributions; there is no automatic close policy. + +## Authentication and verification + +`PROJECT_WRITE_TOKEN` needs the `project` scope and appropriate organization +authorization. It is used only for Project GraphQL requests. The workflow's +`GITHUB_TOKEN` reads repository data and posts comments with `pull-requests: write`. +No Project credential is exposed to PR-triggered test jobs. + +Run policy tests with: + +```sh +python3 -m unittest discover -s tests/python/automation -v +``` + +For a local preview, provide `PROJECT_TOKEN` and `REPO_TOKEN` securely in the +environment, then run: + +```sh +python3 .github/scripts/project_triage.py --report /tmp/triage-report.json +``` + +Add `--apply` for Project writes; `--notify` additionally posts due reminders. +API failures produce a failed run and never manufacture zero-valued metrics. diff --git a/.github/scripts/project_triage.py b/.github/scripts/project_triage.py new file mode 100644 index 000000000..3ef46c6d9 --- /dev/null +++ b/.github/scripts/project_triage.py @@ -0,0 +1,420 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Refresh PR age/idle fields and send bounded, public-data-only reminders.""" + +import argparse +import json +import logging +import os +import subprocess +from datetime import datetime, timedelta, timezone +from pathlib import Path + +LOG = logging.getLogger("isaaccapture.automation.project_triage") +MARKER = "" +FIELDS = { + "Idle": "NUMBER", + "Age": "NUMBER", + "Ready days": "NUMBER", + "Ready since": "DATE", + "Triage state": "TEXT", +} +PR_QUERY = """query($owner:String!,$repo:String!,$number:Int!) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + number url state isDraft createdAt closedAt headRefOid + mergeable mergeStateStatus reviewDecision + commits(last:1) { nodes { commit { statusCheckRollup { state } } } } + } } +}""" + + +def timestamp(value): + return datetime.fromisoformat(value.replace("Z", "+00:00")) + + +def iso(value): + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +def days(now, start): + return max(0, (now - start).days) + + +def human(user): + return ( + bool(user) + and user.get("type") != "Bot" + and not user.get("login", "").endswith("[bot]") + ) + + +def latest_activity(pr, comments, reviews, inline, commits): + dates = [timestamp(pr["createdAt"])] + for entry in comments + inline: + if human(entry.get("user")) and MARKER not in (entry.get("body") or ""): + dates.append(timestamp(entry["created_at"])) + for review in reviews: + if human(review.get("user")) and review.get("submitted_at"): + dates.append(timestamp(review["submitted_at"])) + for commit in commits: + if human(commit.get("author")) or human(commit.get("committer")): + dates.append(timestamp(commit["commit"]["committer"]["date"])) + return max(dates) + + +def ready(pr): + commits = pr.get("commits", {}).get("nodes", []) + checks = (commits[-1]["commit"].get("statusCheckRollup") or {}) if commits else {} + return ( + pr["state"] == "OPEN" + and not pr["isDraft"] + and pr["mergeable"] == "MERGEABLE" + and pr["mergeStateStatus"] == "CLEAN" + and pr["reviewDecision"] == "APPROVED" + and checks.get("state") == "SUCCESS" + ) + + +def ready_state(pr, previous, now): + # Daily observation cannot reconstruct earlier Ready periods or unseen transitions. + if not ready(pr): + return {} + if ( + previous.get("head") == pr["headRefOid"] + and previous.get("since") + and previous.get("observed") + and timedelta(0) <= now - timestamp(previous["observed"]) <= timedelta(hours=48) + and timestamp(previous["since"]) <= now + ): + since = previous["since"] + else: + since = iso(now) + return {"head": pr["headRefOid"], "since": since, "observed": iso(now)} + + +def reasons(pr, state, now): + if pr["state"] != "OPEN" or pr["isDraft"]: + return [] + result = [] + if state.get("since") and now - timestamp(state["since"]) >= timedelta(days=7): + result.append( + "This PR has been observed ready to merge for at least 7 consecutive days." + ) + if now - timestamp(pr["createdAt"]) > timedelta(days=14): + result.append("This PR has been open for more than 14 consecutive days.") + return result + + +def due(comments, now): + sent = [ + timestamp(c["created_at"]) for c in comments if MARKER in (c.get("body") or "") + ] + return not sent or now - max(sent) >= timedelta(days=7) + + +def recipients(detail, reviews, is_ready): + people = {u["login"] for u in detail.get("assignees", []) if human(u)} + if not people: + people.update( + u["login"] for u in detail.get("requested_reviewers", []) if human(u) + ) + if not people and is_ready: + people.update( + r["user"]["login"] + for r in reviews + if r["state"] == "APPROVED" and human(r.get("user")) + ) + if not people and human(detail.get("user")): + people.add(detail["user"]["login"]) + return sorted(people) + + +class GitHub: + def __init__(self, token): + self.env = dict(os.environ, GH_TOKEN=token) + + def call(self, endpoint, payload=None, method=None): + args = ["gh", "api", endpoint] + if method: + args += ["--method", method] + if payload is not None: + args += ["--input", "-"] + proc = subprocess.run( + args, + input=json.dumps(payload) if payload is not None else None, + text=True, + capture_output=True, + env=self.env, + check=False, + timeout=90, + ) + if proc.returncode: + raise RuntimeError( + f"GitHub request failed: {endpoint}: {proc.stderr.strip()}" + ) + data = json.loads(proc.stdout) if proc.stdout.strip() else None + if isinstance(data, dict) and data.get("errors"): + raise RuntimeError(f"GraphQL request failed: {data['errors']}") + return data + + def graphql(self, query, **variables): + return self.call("graphql", {"query": query, "variables": variables})["data"] + + def pages(self, endpoint): + result = [] + page = 1 + while True: + rows = self.call(f"{endpoint}?per_page=100&page={page}") + result.extend(rows) + if len(rows) < 100: + return result + page += 1 + + +def project_data(api, org, number): + meta = api.graphql( + """query($org:String!,$number:Int!) { + organization(login:$org) { projectV2(number:$number) { + id viewerCanUpdate fields(first:100) { nodes { + ... on ProjectV2Field { id name dataType } + } } + } } + }""", + org=org, + number=number, + )["organization"]["projectV2"] + if not meta or not meta["viewerCanUpdate"]: + raise RuntimeError("Project is unavailable or not writable") + items = [] + cursor = None + while True: + page = api.graphql( + """query($id:ID!,$cursor:String) { + node(id:$id) { ... on ProjectV2 { items(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } nodes { + id isArchived content { ... on PullRequest { number repository { nameWithOwner } } } + fieldValues(first:100) { nodes { + ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2Field { name } } } + ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2Field { name } } } + ... on ProjectV2ItemFieldTextValue { text field { ... on ProjectV2Field { name } } } + } } + } + } } } + }""", + id=meta["id"], + cursor=cursor, + )["node"]["items"] + items.extend(page["nodes"]) + if not page["pageInfo"]["hasNextPage"]: + return meta, items + cursor = page["pageInfo"]["endCursor"] + + +def ensure_fields(api, project, apply): + fields = {f["name"]: f for f in project["fields"]["nodes"] if f} + for name, kind in FIELDS.items(): + if name in fields: + if fields[name]["dataType"] != kind: + raise RuntimeError(f"Unexpected type for {name}") + elif apply: + field = api.graphql( + """mutation($project:ID!,$name:String!,$type:ProjectV2FieldType!) { + createProjectV2Field(input:{projectId:$project,name:$name,dataType:$type}) { + projectV2Field { ... on ProjectV2Field { id name dataType } } + } + }""", + project=project["id"], + name=name, + type=kind, + )["createProjectV2Field"]["projectV2Field"] + fields[name] = field + return fields + + +def update_field(api, project_id, item_id, field, value): + if value is None: + api.graphql( + """mutation($project:ID!,$item:ID!,$field:ID!) { + clearProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field}) { + projectV2Item { id } + } + }""", + project=project_id, + item=item_id, + field=field["id"], + ) + else: + key = {"NUMBER": "number", "DATE": "date", "TEXT": "text"}[field["dataType"]] + api.graphql( + """mutation($project:ID!,$item:ID!,$field:ID!,$value:ProjectV2FieldValue!) { + updateProjectV2ItemFieldValue(input:{projectId:$project,itemId:$item,fieldId:$field,value:$value}) { + projectV2Item { id } + } + }""", + project=project_id, + item=item_id, + field=field["id"], + value={key: value}, + ) + + +def show_age(api, project, fields): + views = api.graphql( + """query($id:ID!) { node(id:$id) { ... on ProjectV2 { + views(first:100) { nodes { id layout fields(first:100) { nodes { + ... on ProjectV2Field { id } ... on ProjectV2SingleSelectField { id } + ... on ProjectV2IterationField { id } + } } } } + } } }""", + id=project["id"], + )["node"]["views"]["nodes"] + for view in views: + visible = [f["id"] for f in view["fields"]["nodes"]] + idle, age = fields["Idle"]["id"], fields["Age"]["id"] + if view["layout"] != "TABLE_LAYOUT" or idle not in visible or age in visible: + continue + visible.insert(visible.index(idle) + 1, age) + api.graphql( + """mutation($view:ID!,$fields:[ID!]) { + updateProjectV2View(input:{viewId:$view,configuration:{visibleFieldIds:$fields}}) { + projectV2View { id } + } + }""", + view=view["id"], + fields=visible, + ) + + +def snapshot(api, owner, repo, number): + return api.graphql(PR_QUERY, owner=owner, repo=repo, number=number)["repository"][ + "pullRequest" + ] + + +def process(project_api, repo_api, meta, fields, item, repo, now, apply, notify): + owner, name = repo.split("/") + number = item["content"]["number"] + pr = snapshot(repo_api, owner, name, number) + if pr["state"] != "OPEN": + return None + root = f"repos/{repo}" + comments = repo_api.pages(f"{root}/issues/{number}/comments") + reviews = repo_api.pages(f"{root}/pulls/{number}/reviews") + inline = repo_api.pages(f"{root}/pulls/{number}/comments") + commits = repo_api.pages(f"{root}/pulls/{number}/commits") + existing = {} + for value in item["fieldValues"]["nodes"]: + if value.get("field", {}).get("name") in FIELDS: + existing[value["field"]["name"]] = next( + value[k] for k in ("number", "date", "text") if k in value + ) + previous = json.loads(existing.get("Triage state") or "{}") + state = ready_state(pr, previous, now) + idle = days(now, latest_activity(pr, comments, reviews, inline, commits)) + updates = { + "Idle": idle, + "Age": days(now, timestamp(pr["createdAt"])), + "Ready days": days(now, timestamp(state["since"])) if state else None, + "Ready since": state["since"][:10] if state else None, + "Triage state": json.dumps(state, sort_keys=True) if state else None, + } + why = reasons(pr, state, now) + reminder = bool(why and due(comments, now)) + if apply: + for field, value in updates.items(): + if existing.get(field) != value: + update_field(project_api, meta["id"], item["id"], fields[field], value) + if notify and reminder: + # Recheck immediately before sending; Project snapshots can outlive a merge or draft toggle. + fresh = snapshot(repo_api, owner, name, number) + fresh_state = ( + state if fresh["headRefOid"] == pr["headRefOid"] and ready(fresh) else {} + ) + why = reasons(fresh, fresh_state, now) + fresh_comments = repo_api.pages(f"{root}/issues/{number}/comments") + if why and due(fresh_comments, now): + detail = repo_api.call(f"{root}/pulls/{number}") + if detail["state"] == "open" and not detail["draft"]: + mentions = " ".join( + "@" + login for login in recipients(detail, reviews, ready(fresh)) + ) + body = ( + f"{MARKER}\nAutomated PR follow-up {mentions}\n\n" + + "\n".join("- " + reason for reason in why) + + "\n\nPlease merge if ready, or share the blocker and next step. " + "This reminder repeats at most once every 7 days while eligible." + ) + repo_api.call(f"{root}/issues/{number}/comments", {"body": body}) + return { + "number": number, + "age": updates["Age"], + "idle": idle, + "draft": pr["isDraft"], + "ready_days": updates["Ready days"], + "reminder_due": reminder, + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--org", default="NVIDIA") + parser.add_argument("--project", type=int, default=322) + parser.add_argument("--repo", default="NVIDIA/IsaacCapture") + parser.add_argument("--apply", action="store_true") + parser.add_argument("--notify", action="store_true") + parser.add_argument("--report", type=Path, required=True) + args = parser.parse_args() + if args.notify and not args.apply: + parser.error("--notify requires --apply") + project_api = GitHub(os.environ["PROJECT_TOKEN"]) + repo_api = GitHub(os.environ["REPO_TOKEN"]) + now = datetime.now(timezone.utc) + meta, items = project_data(project_api, args.org, args.project) + fields = ensure_fields(project_api, meta, args.apply) + report, failures = [], [] + for item in items: + content = item.get("content") or {} + if ( + item["isArchived"] + or content.get("repository", {}).get("nameWithOwner") != args.repo + ): + continue + try: + row = process( + project_api, + repo_api, + meta, + fields, + item, + args.repo, + now, + args.apply, + args.notify, + ) + if row: + report.append(row) + except (RuntimeError, ValueError, KeyError) as error: + LOG.error("PR #%s: %s", content["number"], error) + failures.append(content["number"]) + if args.apply: + show_age(project_api, meta, fields) + args.report.write_text( + json.dumps( + { + "checked_at": iso(now), + "applied": args.apply, + "notifications_enabled": args.notify, + "items": report, + "failures": failures, + }, + indent=2, + ) + + "\n" + ) + if failures: + raise SystemExit(1) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + main() diff --git a/.github/workflows/project-triage.yml b/.github/workflows/project-triage.yml new file mode 100644 index 000000000..6624092c1 --- /dev/null +++ b/.github/workflows/project-triage.yml @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +name: Refresh PR triage + +on: + schedule: + - cron: "43 16 * * *" + workflow_dispatch: + inputs: + apply: + description: Update Project fields (otherwise preview only) + type: boolean + default: false + notify: + description: Post eligible PR reminders (requires apply) + type: boolean + default: false + pull_request: + paths: + - .github/scripts/project_triage.py + - .github/workflows/project-triage.yml + - tests/python/automation/** + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Test PR triage policy + run: python3 -m unittest discover -s tests/python/automation -v + + refresh: + needs: test + if: >- + github.event_name != 'pull_request' && + github.repository == 'NVIDIA/IsaacCapture' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + pull-requests: write + checks: read + statuses: read + concurrency: + group: project-triage-322 + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Refresh Project metrics and reminders + env: + PROJECT_TOKEN: ${{ secrets.PROJECT_WRITE_TOKEN }} + REPO_TOKEN: ${{ github.token }} + APPLY: ${{ github.event_name == 'schedule' || inputs.apply }} + NOTIFY: ${{ github.event_name == 'schedule' || inputs.notify }} + shell: bash + run: | + set -euo pipefail + args=(--report "$RUNNER_TEMP/triage-report.json") + if [[ "$APPLY" == true ]]; then args+=(--apply); fi + if [[ "$NOTIFY" == true ]]; then args+=(--notify); fi + python3 .github/scripts/project_triage.py "${args[@]}" + - name: Summarize run + if: always() + shell: bash + run: | + if [[ -f "$RUNNER_TEMP/triage-report.json" ]]; then + jq '{checked_at,applied,notifications_enabled,processed:(.items|length),failed:(.failures|length)}' \ + "$RUNNER_TEMP/triage-report.json" >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/tests/python/automation/pyproject.toml b/tests/python/automation/pyproject.toml new file mode 100644 index 000000000..71103e62d --- /dev/null +++ b/tests/python/automation/pyproject.toml @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +[project] +name = "isaaccapture-automation-tests" +version = "0.0.0" +requires-python = ">=3.11" +dependencies = [] + +[tool.uv] +package = false diff --git a/tests/python/automation/test_project_triage.py b/tests/python/automation/test_project_triage.py new file mode 100644 index 000000000..7bbb90d3b --- /dev/null +++ b/tests/python/automation/test_project_triage.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Policy and side-effect boundary tests without GitHub credentials.""" + +import importlib.util +import json +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest.mock import patch + +ROOT = next( + p + for p in Path(__file__).resolve().parents + if (p / ".github/scripts/project_triage.py").is_file() +) +SPEC = importlib.util.spec_from_file_location( + "project_triage", ROOT / ".github/scripts/project_triage.py" +) +triage = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(triage) +NOW = datetime(2026, 10, 10, 16, 43, tzinfo=timezone.utc) +PERSON = {"type": "User", "login": "author"} +BOT = {"type": "Bot", "login": "github-actions[bot]"} + + +def pr(**kwargs): + result = dict( + number=42, + url="https://github.com/example/repo/pull/42", + state="OPEN", + isDraft=False, + createdAt=triage.iso(NOW - timedelta(days=20)), + headRefOid="abc", + mergeable="MERGEABLE", + mergeStateStatus="CLEAN", + reviewDecision="APPROVED", + commits={"nodes": [{"commit": {"statusCheckRollup": {"state": "SUCCESS"}}}]}, + ) + result.update(kwargs) + return result + + +def comment(when, user=PERSON, body="An update"): + return {"created_at": triage.iso(when), "user": user, "body": body} + + +class PolicyTests(unittest.TestCase): + def test_age_threshold_is_strict_and_not_idle(self): + self.assertEqual( + triage.reasons(pr(createdAt=triage.iso(NOW - timedelta(days=14))), {}, NOW), + [], + ) + self.assertEqual( + len( + triage.reasons( + pr(createdAt=triage.iso(NOW - timedelta(days=14, seconds=1))), + {}, + NOW, + ) + ), + 1, + ) + + def test_ready_threshold_and_combined_reasons(self): + state = {"since": triage.iso(NOW - timedelta(days=7))} + self.assertEqual(len(triage.reasons(pr(), state, NOW)), 2) + state["since"] = triage.iso(NOW - timedelta(days=7) + timedelta(seconds=1)) + self.assertEqual(len(triage.reasons(pr(), state, NOW)), 1) + + def test_closed_merged_and_draft_never_nudged(self): + for data in [pr(state="CLOSED"), pr(state="MERGED"), pr(isDraft=True)]: + with self.subTest(data=data): + self.assertEqual( + triage.reasons(data, {"since": "2026-01-01T00:00:00Z"}, NOW), [] + ) + self.assertEqual(triage.ready_state(data, {}, NOW), {}) + + def test_all_readiness_conditions_required(self): + self.assertTrue(triage.ready(pr())) + for update in [ + dict(mergeable="UNKNOWN"), + dict(mergeStateStatus="BLOCKED"), + dict(mergeStateStatus="UNSTABLE"), + dict(reviewDecision="REVIEW_REQUIRED"), + dict(reviewDecision="CHANGES_REQUESTED"), + dict(commits={"nodes": []}), + dict( + commits={ + "nodes": [{"commit": {"statusCheckRollup": {"state": "PENDING"}}}] + } + ), + ]: + with self.subTest(update=update): + self.assertFalse(triage.ready(pr(**update))) + + def test_ready_clock_starts_at_observation_and_resets(self): + state = triage.ready_state(pr(), {}, NOW) + self.assertEqual(state["since"], triage.iso(NOW)) + next_day = NOW + timedelta(days=1) + self.assertEqual( + triage.ready_state(pr(), state, next_day)["since"], state["since"] + ) + self.assertEqual( + triage.ready_state(pr(headRefOid="new"), state, next_day)["since"], + triage.iso(next_day), + ) + self.assertEqual( + triage.ready_state(pr(), state, NOW + timedelta(days=3))["since"], + triage.iso(NOW + timedelta(days=3)), + ) + + def test_bot_and_reminder_do_not_reset_idle(self): + old = NOW - timedelta(days=10) + comments = [ + comment(old), + comment(NOW, BOT), + comment(NOW, PERSON, triage.MARKER), + ] + self.assertEqual(triage.latest_activity(pr(), comments, [], [], []), old) + + def test_reviews_inline_and_human_commits_count(self): + reviews = [ + {"user": PERSON, "submitted_at": triage.iso(NOW - timedelta(days=4))} + ] + inline = [comment(NOW - timedelta(days=3))] + commits = [ + { + "author": PERSON, + "commit": {"committer": {"date": triage.iso(NOW - timedelta(days=2))}}, + } + ] + self.assertEqual( + triage.latest_activity(pr(), [], reviews, inline, commits), + NOW - timedelta(days=2), + ) + + def test_weekly_cooldown_including_uncertain_previous_send(self): + self.assertTrue(triage.due([], NOW)) + self.assertFalse( + triage.due([comment(NOW - timedelta(days=6), BOT, triage.MARKER)], NOW) + ) + self.assertTrue( + triage.due([comment(NOW - timedelta(days=7), BOT, triage.MARKER)], NOW) + ) + + def test_responsibility_fallback(self): + self.assertEqual( + triage.recipients({"assignees": [PERSON]}, [], True), ["author"] + ) + self.assertEqual(triage.recipients({"user": PERSON}, [], False), ["author"]) + self.assertEqual( + triage.recipients( + {"user": PERSON}, + [{"user": {"type": "User", "login": "reviewer"}, "state": "APPROVED"}], + True, + ), + ["reviewer"], + ) + + def test_api_paginates_beyond_first_hundred(self): + api = triage.GitHub("not-a-real-token") + with patch.object( + api, "call", side_effect=[[{}] * 100, [{"last": True}]] + ) as call: + self.assertEqual(len(api.pages("example")), 101) + self.assertIn("page=2", call.call_args.args[0]) + + def test_dry_run_has_no_mutations(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + with ( + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(api, "call") as call, + ): + result = triage.process( + api, api, {"id": "project"}, {}, item, "example/repo", NOW, False, False + ) + self.assertTrue(result["reminder_due"]) + call.assert_not_called() + + def test_draft_transition_before_send_suppresses_comment(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + with ( + patch.object(triage, "snapshot", side_effect=[pr(), pr(isDraft=True)]), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field"), + patch.object(api, "call") as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + call.assert_not_called() + + def test_existing_reminder_prevents_send_after_ambiguous_run(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + with ( + patch.object(triage, "snapshot", return_value=pr()), + patch.object( + api, + "pages", + side_effect=[[], [], [], [], [comment(NOW, BOT, triage.MARKER)]], + ), + patch.object(triage, "update_field"), + patch.object(api, "call") as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + call.assert_not_called() + + def test_merge_before_last_rest_check_suppresses_send(self): + item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + with ( + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field"), + patch.object( + api, "call", return_value={"state": "closed", "draft": False} + ) as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + call.assert_called_once_with("repos/example/repo/pulls/42") + + def test_live_reminder_combines_rules_and_mentions(self): + previous = { + "head": "abc", + "since": triage.iso(NOW - timedelta(days=8)), + "observed": triage.iso(NOW - timedelta(days=1)), + } + item = { + "id": "item", + "content": {"number": 42}, + "fieldValues": { + "nodes": [ + {"field": {"name": "Triage state"}, "text": json.dumps(previous)} + ] + }, + } + api = triage.GitHub("not-a-real-token") + fields = {key: {} for key in triage.FIELDS} + detail = {"state": "open", "draft": False, "assignees": [PERSON]} + with ( + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field"), + patch.object(api, "call", side_effect=[detail, {}]) as call, + ): + triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + True, + ) + body = call.call_args.args[1]["body"] + self.assertIn("@author", body) + self.assertIn("7 consecutive", body) + self.assertIn("14 consecutive", body) + self.assertIn(triage.MARKER, body) + + +if __name__ == "__main__": + unittest.main() From 5f9ec48465dd5402463cfd147496bb1f5dd4450b Mon Sep 17 00:00:00 2001 From: Ivan Yang Date: Sat, 10 Oct 2026 12:50:19 -0700 Subject: [PATCH 2/5] ci: skip closed Project items before loading PR activity Signed-off-by: Ivan Yang --- .github/scripts/project_triage.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/scripts/project_triage.py b/.github/scripts/project_triage.py index 3ef46c6d9..8c6c41fdd 100644 --- a/.github/scripts/project_triage.py +++ b/.github/scripts/project_triage.py @@ -192,7 +192,7 @@ def project_data(api, org, number): """query($id:ID!,$cursor:String) { node(id:$id) { ... on ProjectV2 { items(first:100,after:$cursor) { pageInfo { hasNextPage endCursor } nodes { - id isArchived content { ... on PullRequest { number repository { nameWithOwner } } } + id isArchived content { ... on PullRequest { number state repository { nameWithOwner } } } fieldValues(first:100) { nodes { ... on ProjectV2ItemFieldNumberValue { number field { ... on ProjectV2Field { name } } } ... on ProjectV2ItemFieldDateValue { date field { ... on ProjectV2Field { name } } } @@ -376,6 +376,7 @@ def main(): content = item.get("content") or {} if ( item["isArchived"] + or content.get("state") != "OPEN" or content.get("repository", {}).get("nameWithOwner") != args.repo ): continue From 49ec9dd3e70a3bda5927f84d3f64862c93abebb3 Mon Sep 17 00:00:00 2001 From: Ivan Yang Date: Sat, 10 Oct 2026 12:52:48 -0700 Subject: [PATCH 3/5] fix(ci): use the custom field enum for Project field creation Signed-off-by: Ivan Yang --- .github/scripts/project_triage.py | 3 ++- .../python/automation/test_project_triage.py | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/scripts/project_triage.py b/.github/scripts/project_triage.py index 8c6c41fdd..3d486e3cc 100644 --- a/.github/scripts/project_triage.py +++ b/.github/scripts/project_triage.py @@ -218,7 +218,8 @@ def ensure_fields(api, project, apply): raise RuntimeError(f"Unexpected type for {name}") elif apply: field = api.graphql( - """mutation($project:ID!,$name:String!,$type:ProjectV2FieldType!) { + # Creation accepts CustomFieldType; FieldType also includes built-ins. + """mutation($project:ID!,$name:String!,$type:ProjectV2CustomFieldType!) { createProjectV2Field(input:{projectId:$project,name:$name,dataType:$type}) { projectV2Field { ... on ProjectV2Field { id name dataType } } } diff --git a/tests/python/automation/test_project_triage.py b/tests/python/automation/test_project_triage.py index 7bbb90d3b..fab371ae9 100644 --- a/tests/python/automation/test_project_triage.py +++ b/tests/python/automation/test_project_triage.py @@ -46,6 +46,27 @@ def comment(when, user=PERSON, body="An update"): class PolicyTests(unittest.TestCase): + def test_field_creation_uses_custom_field_enum(self): + api = triage.GitHub("not-a-real-token") + project = {"id": "project", "fields": {"nodes": []}} + with patch.object( + api, + "graphql", + side_effect=lambda query, **variables: { + "createProjectV2Field": { + "projectV2Field": { + "id": variables["name"], + "name": variables["name"], + "dataType": variables["type"], + } + } + }, + ) as call: + self.assertEqual( + len(triage.ensure_fields(api, project, True)), len(triage.FIELDS) + ) + self.assertIn("$type:ProjectV2CustomFieldType!", call.call_args.args[0]) + def test_age_threshold_is_strict_and_not_idle(self): self.assertEqual( triage.reasons(pr(createdAt=triage.iso(NOW - timedelta(days=14))), {}, NOW), From f0dfdf6a467548c5e29eafff4542c0752a30afd7 Mon Sep 17 00:00:00 2001 From: Ivan Yang Date: Sat, 10 Oct 2026 12:57:28 -0700 Subject: [PATCH 4/5] fix(ci): preserve configured Project column order Signed-off-by: Ivan Yang --- .github/scripts/project_triage.py | 7 +++--- .../python/automation/test_project_triage.py | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.github/scripts/project_triage.py b/.github/scripts/project_triage.py index 3d486e3cc..3f1516252 100644 --- a/.github/scripts/project_triage.py +++ b/.github/scripts/project_triage.py @@ -262,15 +262,16 @@ def update_field(api, project_id, item_id, field, value): def show_age(api, project, fields): views = api.graphql( """query($id:ID!) { node(id:$id) { ... on ProjectV2 { - views(first:100) { nodes { id layout fields(first:100) { nodes { + views(first:100) { nodes { id layout configuration { visibleFields(first:100) { nodes { ... on ProjectV2Field { id } ... on ProjectV2SingleSelectField { id } ... on ProjectV2IterationField { id } - } } } } + } } } } } } } }""", id=project["id"], )["node"]["views"]["nodes"] for view in views: - visible = [f["id"] for f in view["fields"]["nodes"]] + # view.fields uses project-wide order, not the user's configured column order. + visible = [f["id"] for f in view["configuration"]["visibleFields"]["nodes"]] idle, age = fields["Idle"]["id"], fields["Age"]["id"] if view["layout"] != "TABLE_LAYOUT" or idle not in visible or age in visible: continue diff --git a/tests/python/automation/test_project_triage.py b/tests/python/automation/test_project_triage.py index fab371ae9..8868294d6 100644 --- a/tests/python/automation/test_project_triage.py +++ b/tests/python/automation/test_project_triage.py @@ -46,6 +46,31 @@ def comment(when, user=PERSON, body="An update"): class PolicyTests(unittest.TestCase): + def test_age_visibility_preserves_configured_column_order(self): + api = triage.GitHub("not-a-real-token") + view = { + "id": "view", + "layout": "TABLE_LAYOUT", + "configuration": { + "visibleFields": { + "nodes": [ + {"id": "custom-first"}, + {"id": "idle"}, + {"id": "custom-last"}, + ] + } + }, + } + fields = {"Idle": {"id": "idle"}, "Age": {"id": "age"}} + with patch.object( + api, "graphql", side_effect=[{"node": {"views": {"nodes": [view]}}}, {}] + ) as call: + triage.show_age(api, {"id": "project"}, fields) + self.assertEqual( + call.call_args.kwargs["fields"], + ["custom-first", "idle", "age", "custom-last"], + ) + def test_field_creation_uses_custom_field_enum(self): api = triage.GitHub("not-a-real-token") project = {"id": "project", "fields": {"nodes": []}} From de34874716bae8c40c75c0ced8b13271f4dbcb79 Mon Sep 17 00:00:00 2001 From: Ivan Yang Date: Sat, 10 Oct 2026 13:22:47 -0700 Subject: [PATCH 5/5] fix(ci): handle triage state and API failure boundaries Signed-off-by: Ivan Yang --- .github/AGENTS.md | 1 + .github/scripts/PR_TRIAGE.md | 77 +++++----- .github/scripts/project_triage.py | 77 ++++++++-- .../python/automation/test_project_triage.py | 135 +++++++++++++++++- 4 files changed, 234 insertions(+), 56 deletions(-) diff --git a/.github/AGENTS.md b/.github/AGENTS.md index c362fb06b..a51d1017d 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -9,3 +9,4 @@ SPDX-License-Identifier: Apache-2.0 - Derive merge readiness from the PR's checks, reviews and mergeability, not a manually maintained Project status. - Keep elapsed PR age distinct from inactivity and observed merge-readiness duration. - Public PR comments and workflow summaries must not expose private Project notes or fields. +- Automation documentation describes current behavior and operation; omit development history, machine-specific details and board snapshots. diff --git a/.github/scripts/PR_TRIAGE.md b/.github/scripts/PR_TRIAGE.md index c2b0657ff..4e4b0bd3e 100644 --- a/.github/scripts/PR_TRIAGE.md +++ b/.github/scripts/PR_TRIAGE.md @@ -5,67 +5,68 @@ SPDX-License-Identifier: Apache-2.0 # PR triage metrics and reminders -`project-triage.yml` runs daily at 16:43 UTC (09:43 PDT / 08:43 PST), after -merging to the default branch. GitHub schedules may be delayed. Manual runs -default to a read-only preview; enable `apply` to update fields and `notify` -to send comments. The workflow never merges or closes PRs. +The [Refresh PR triage workflow](../workflows/project-triage.yml) runs daily at +16:43 UTC from the default branch. It updates open PRs in the configured Project +and repository. Archived items, issues, closed PRs and merged PRs are skipped. +Draft PRs receive metric updates but no reminders. -## Fields +## Metrics - **Idle**: full 24-hour periods since the latest human comment, submitted review, inline review comment, or human-linked commit's committer timestamp. - Falls back to PR creation. Bot activity, CI and these reminder comments do - not reset it. Editing an old comment does not reset it. Historical Git - timestamps cannot establish when a rebased commit was pushed. -- **Age**: full 24-hour periods since PR creation, independent of Idle. + Falls back to PR creation. Bot activity, CI, reminder comments and edits to + existing comments do not reset it. Commit timestamps do not identify push times. +- **Age**: full 24-hour periods since PR creation. - **Ready days / Ready since**: time since first observing the current head as non-draft, mergeable, `CLEAN`, review `APPROVED` and check rollup `SUCCESS`. - No historical readiness is inferred on first run. Failed, pending, unknown, - draft or new-head states reset this clock. An observation gap exceeding 48 - hours also resets it. Changes between daily observations may not be detected. -- **Triage state**: internal JSON storing the Ready head and observation times. + A new head, an unmet condition, or an observation gap exceeding 48 hours resets + the clock. Daily observations cannot establish readiness before the first run + or detect every transition between runs. +- **Triage state**: automation-managed readiness observations. Invalid values + restart the readiness clock. -The script adds missing fields and places Age immediately after Idle in table -views that already show Idle. Archived items, other repositories, issues and -closed/merged PRs are skipped. Draft PRs still receive Idle and Age updates. +Missing fields are created automatically. Age is added after Idle in table +views that show Idle, unless Age is already visible; existing column order is +preserved. ## Reminders -Only open, non-draft PRs receive comments: +Only open, non-draft PRs receive reminders after either threshold: -1. Ready for at least 7 consecutive days and still unmerged. -2. Open for more than 14 consecutive days, regardless of recent activity. +- Observed ready to merge for at least 7 consecutive days. +- Created more than 14 days ago, regardless of recent activity. -These are calendar-day rules, not business-day or holiday calculations. Both -reasons share one comment and one seven-day cooldown. The comment marker makes -retries after an ambiguous send discoverable; writes are never blindly retried. -The PR is read again immediately before posting to avoid nudging a newly closed -or draft PR. No API can make that check and comment one atomic operation. +Thresholds use calendar days. Both reasons share one comment and a seven-day +cooldown. Eligibility is checked again before posting. The workflow never merges +or closes PRs. -Mention PR assignees first, otherwise requested reviewers; for merge-ready PRs, -fall back to approving reviewers, then the author. All targets and comment text -come from public PR data, never private Project notes. The rules apply equally -to internal and external contributions; there is no automatic close policy. +Comments mention assignees, otherwise requested reviewers. Merge-ready PRs +fall back to approving reviewers, then the author. Other PRs fall back to the +author. Comments use public PR data only. -## Authentication and verification +## Operation -`PROJECT_WRITE_TOKEN` needs the `project` scope and appropriate organization -authorization. It is used only for Project GraphQL requests. The workflow's -`GITHUB_TOKEN` reads repository data and posts comments with `pull-requests: write`. -No Project credential is exposed to PR-triggered test jobs. +In **Actions → Refresh PR triage → Run workflow**, select the default branch. +Manual runs preview by default; enable `apply` to update fields and additionally +enable `notify` to send reminders. Scheduled runs enable both. GitHub schedules +may be delayed. Failed API requests fail the run; completed updates remain. -Run policy tests with: +The repository secret `PROJECT_WRITE_TOKEN` requires the `project` scope, +organization authorization and write access to the Project. It is used only for +Project requests. The workflow's `GITHUB_TOKEN` reads PR data and posts comments +with `pull-requests: write`. PR-triggered jobs run tests without Project credentials. + +Run tests from the repository root: ```sh python3 -m unittest discover -s tests/python/automation -v ``` -For a local preview, provide `PROJECT_TOKEN` and `REPO_TOKEN` securely in the -environment, then run: +For a local preview, provide `PROJECT_TOKEN` and `REPO_TOKEN` in the environment: ```sh python3 .github/scripts/project_triage.py --report /tmp/triage-report.json ``` -Add `--apply` for Project writes; `--notify` additionally posts due reminders. -API failures produce a failed run and never manufacture zero-valued metrics. +Add `--apply` to update fields; `--notify` also requires `--apply` and posts reminders. +The preview report contains per-PR metrics; workflow summaries show counts only. diff --git a/.github/scripts/project_triage.py b/.github/scripts/project_triage.py index 3f1516252..abb0b0077 100644 --- a/.github/scripts/project_triage.py +++ b/.github/scripts/project_triage.py @@ -1,6 +1,6 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Refresh PR age/idle fields and send bounded, public-data-only reminders.""" +"""Update PR triage metrics and post weekly reminders.""" import argparse import json @@ -21,7 +21,7 @@ } PR_QUERY = """query($owner:String!,$repo:String!,$number:Int!) { repository(owner:$owner,name:$repo) { pullRequest(number:$number) { - number url state isDraft createdAt closedAt headRefOid + state isDraft createdAt headRefOid mergeable mergeStateStatus reviewDecision commits(last:1) { nodes { commit { statusCheckRollup { state } } } } } } @@ -57,8 +57,10 @@ def latest_activity(pr, comments, reviews, inline, commits): if human(review.get("user")) and review.get("submitted_at"): dates.append(timestamp(review["submitted_at"])) for commit in commits: - if human(commit.get("author")) or human(commit.get("committer")): - dates.append(timestamp(commit["commit"]["committer"]["date"])) + if human((commit.get("author") or {}).get("user")) or human( + (commit.get("committer") or {}).get("user") + ): + dates.append(timestamp(commit["committedDate"])) return max(dates) @@ -75,6 +77,22 @@ def ready(pr): ) +def read_state(value): + # Project text fields are editable; invalid state must restart observation. + try: + state = json.loads(value or "{}") + if not isinstance(state, dict) or not all( + isinstance(state.get(key), str) for key in ("head", "since", "observed") + ): + return {} + since, observed = timestamp(state["since"]), timestamp(state["observed"]) + if since.tzinfo is None or observed.tzinfo is None or since > observed: + return {} + return state + except (ValueError, TypeError): + return {} + + def ready_state(pr, previous, now): # Daily observation cannot reconstruct earlier Ready periods or unseen transitions. if not ready(pr): @@ -139,15 +157,18 @@ def call(self, endpoint, payload=None, method=None): args += ["--method", method] if payload is not None: args += ["--input", "-"] - proc = subprocess.run( - args, - input=json.dumps(payload) if payload is not None else None, - text=True, - capture_output=True, - env=self.env, - check=False, - timeout=90, - ) + try: + proc = subprocess.run( + args, + input=json.dumps(payload) if payload is not None else None, + text=True, + capture_output=True, + env=self.env, + check=False, + timeout=90, + ) + except subprocess.TimeoutExpired as error: + raise RuntimeError(f"GitHub request timed out: {endpoint}") from error if proc.returncode: raise RuntimeError( f"GitHub request failed: {endpoint}: {proc.stderr.strip()}" @@ -293,6 +314,32 @@ def snapshot(api, owner, repo, number): ] +def pr_commits(api, owner, repo, number): + # REST pull-request commits stop at 250; this connection supports the full history. + result, cursor = [], None + while True: + page = api.graphql( + """query($owner:String!,$repo:String!,$number:Int!,$cursor:String) { + repository(owner:$owner,name:$repo) { pullRequest(number:$number) { + commits(first:100,after:$cursor) { + pageInfo { hasNextPage endCursor } + nodes { commit { + committedDate author { user { login } } committer { user { login } } + } } + } + } } + }""", + owner=owner, + repo=repo, + number=number, + cursor=cursor, + )["repository"]["pullRequest"]["commits"] + result.extend(node["commit"] for node in page["nodes"]) + if not page["pageInfo"]["hasNextPage"]: + return result + cursor = page["pageInfo"]["endCursor"] + + def process(project_api, repo_api, meta, fields, item, repo, now, apply, notify): owner, name = repo.split("/") number = item["content"]["number"] @@ -303,14 +350,14 @@ def process(project_api, repo_api, meta, fields, item, repo, now, apply, notify) comments = repo_api.pages(f"{root}/issues/{number}/comments") reviews = repo_api.pages(f"{root}/pulls/{number}/reviews") inline = repo_api.pages(f"{root}/pulls/{number}/comments") - commits = repo_api.pages(f"{root}/pulls/{number}/commits") + commits = pr_commits(repo_api, owner, name, number) existing = {} for value in item["fieldValues"]["nodes"]: if value.get("field", {}).get("name") in FIELDS: existing[value["field"]["name"]] = next( value[k] for k in ("number", "date", "text") if k in value ) - previous = json.loads(existing.get("Triage state") or "{}") + previous = read_state(existing.get("Triage state")) state = ready_state(pr, previous, now) idle = days(now, latest_activity(pr, comments, reviews, inline, commits)) updates = { diff --git a/tests/python/automation/test_project_triage.py b/tests/python/automation/test_project_triage.py index 8868294d6..abd5b5da5 100644 --- a/tests/python/automation/test_project_triage.py +++ b/tests/python/automation/test_project_triage.py @@ -4,6 +4,7 @@ import importlib.util import json +import subprocess import unittest from datetime import datetime, timedelta, timezone from pathlib import Path @@ -156,6 +157,67 @@ def test_ready_clock_starts_at_observation_and_resets(self): triage.iso(NOW + timedelta(days=3)), ) + def test_invalid_stored_state_is_discarded(self): + valid = { + "head": "abc", + "since": triage.iso(NOW - timedelta(days=8)), + "observed": triage.iso(NOW - timedelta(days=1)), + } + self.assertEqual(triage.read_state(json.dumps(valid)), valid) + invalid = [ + None, + "{", + "null", + "[]", + "42", + '"text"', + "{}", + json.dumps(dict(valid, since=42)), + json.dumps(dict(valid, observed="invalid")), + json.dumps(dict(valid, since="2026-10-01T00:00:00")), + json.dumps(dict(valid, since=triage.iso(NOW))), + ] + for value in invalid: + with self.subTest(value=value): + self.assertEqual(triage.read_state(value), {}) + + def test_invalid_stored_state_does_not_block_metrics(self): + item = { + "id": "item", + "content": {"number": 42}, + "fieldValues": { + "nodes": [{"field": {"name": "Triage state"}, "text": "[]"}] + }, + } + api = triage.GitHub("not-a-real-token") + fields = {key: {"name": key} for key in triage.FIELDS} + with ( + patch.object(triage, "pr_commits", return_value=[]), + patch.object(triage, "snapshot", return_value=pr()), + patch.object(api, "pages", return_value=[]), + patch.object(triage, "update_field") as update, + ): + result = triage.process( + api, + api, + {"id": "project"}, + fields, + item, + "example/repo", + NOW, + True, + False, + ) + self.assertEqual(result["age"], 20) + self.assertEqual(result["idle"], 20) + self.assertEqual(result["ready_days"], 0) + state = next( + call.args[4] + for call in update.call_args_list + if call.args[3]["name"] == "Triage state" + ) + self.assertEqual(json.loads(state)["since"], triage.iso(NOW)) + def test_bot_and_reminder_do_not_reset_idle(self): old = NOW - timedelta(days=10) comments = [ @@ -172,8 +234,8 @@ def test_reviews_inline_and_human_commits_count(self): inline = [comment(NOW - timedelta(days=3))] commits = [ { - "author": PERSON, - "commit": {"committer": {"date": triage.iso(NOW - timedelta(days=2))}}, + "author": {"user": PERSON}, + "committedDate": triage.iso(NOW - timedelta(days=2)), } ] self.assertEqual( @@ -212,10 +274,73 @@ def test_api_paginates_beyond_first_hundred(self): self.assertEqual(len(api.pages("example")), 101) self.assertIn("page=2", call.call_args.args[0]) + def test_commit_history_includes_activity_after_250_commits(self): + old = { + "author": {"user": PERSON}, + "committedDate": triage.iso(NOW - timedelta(days=19)), + } + recent = dict(old, committedDate=triage.iso(NOW)) + pages = [] + for index, commits in enumerate( + ([old] * 100, [old] * 100, [old] * 50 + [recent]) + ): + pages.append( + { + "repository": { + "pullRequest": { + "commits": { + "nodes": [{"commit": commit} for commit in commits], + "pageInfo": { + "hasNextPage": index < 2, + "endCursor": str(index), + }, + } + } + } + } + ) + api = triage.GitHub("not-a-real-token") + with patch.object(api, "graphql", side_effect=pages) as query: + commits = triage.pr_commits(api, "example", "repo", 42) + self.assertEqual(len(commits), 251) + self.assertEqual(triage.latest_activity(pr(), [], [], [], commits), NOW) + self.assertEqual( + [call.kwargs["cursor"] for call in query.call_args_list], + [None, "0", "1"], + ) + + def test_bot_and_unlinked_commits_do_not_reset_idle(self): + commits = [ + { + "author": {"user": BOT}, + "committer": None, + "committedDate": triage.iso(NOW), + }, + { + "author": None, + "committer": {"user": None}, + "committedDate": triage.iso(NOW), + }, + ] + self.assertEqual( + triage.latest_activity(pr(), [], [], [], commits), + triage.timestamp(pr()["createdAt"]), + ) + + def test_timeout_is_a_reportable_api_failure_without_retry(self): + api = triage.GitHub("not-a-real-token") + with patch.object( + triage.subprocess, "run", side_effect=subprocess.TimeoutExpired("gh", 90) + ) as run: + with self.assertRaisesRegex(RuntimeError, "GitHub request timed out"): + api.call("repos/example/repo/issues/42/comments", {"body": "reminder"}) + run.assert_called_once() + def test_dry_run_has_no_mutations(self): item = {"id": "item", "content": {"number": 42}, "fieldValues": {"nodes": []}} api = triage.GitHub("not-a-real-token") with ( + patch.object(triage, "pr_commits", return_value=[]), patch.object(triage, "snapshot", return_value=pr()), patch.object(api, "pages", return_value=[]), patch.object(api, "call") as call, @@ -231,6 +356,7 @@ def test_draft_transition_before_send_suppresses_comment(self): api = triage.GitHub("not-a-real-token") fields = {key: {} for key in triage.FIELDS} with ( + patch.object(triage, "pr_commits", return_value=[]), patch.object(triage, "snapshot", side_effect=[pr(), pr(isDraft=True)]), patch.object(api, "pages", return_value=[]), patch.object(triage, "update_field"), @@ -254,11 +380,12 @@ def test_existing_reminder_prevents_send_after_ambiguous_run(self): api = triage.GitHub("not-a-real-token") fields = {key: {} for key in triage.FIELDS} with ( + patch.object(triage, "pr_commits", return_value=[]), patch.object(triage, "snapshot", return_value=pr()), patch.object( api, "pages", - side_effect=[[], [], [], [], [comment(NOW, BOT, triage.MARKER)]], + side_effect=[[], [], [], [comment(NOW, BOT, triage.MARKER)]], ), patch.object(triage, "update_field"), patch.object(api, "call") as call, @@ -281,6 +408,7 @@ def test_merge_before_last_rest_check_suppresses_send(self): api = triage.GitHub("not-a-real-token") fields = {key: {} for key in triage.FIELDS} with ( + patch.object(triage, "pr_commits", return_value=[]), patch.object(triage, "snapshot", return_value=pr()), patch.object(api, "pages", return_value=[]), patch.object(triage, "update_field"), @@ -320,6 +448,7 @@ def test_live_reminder_combines_rules_and_mentions(self): fields = {key: {} for key in triage.FIELDS} detail = {"state": "open", "draft": False, "assignees": [PERSON]} with ( + patch.object(triage, "pr_commits", return_value=[]), patch.object(triage, "snapshot", return_value=pr()), patch.object(api, "pages", return_value=[]), patch.object(triage, "update_field"),