From df94a0d23e61c720ddb87dfe824d7a522da36ee7 Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Fri, 30 Oct 2026 08:28:24 -0500 Subject: [PATCH 1/7] fix: make PR issue requirements conditional Signed-off-by: Connor Snitker --- .github/workflows/contribution-policy.yml | 18 ++++-- tests/test_contribution_policy.py | 70 +++++++++++++++++++++-- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml index 7bb1243..ef58806 100644 --- a/.github/workflows/contribution-policy.yml +++ b/.github/workflows/contribution-policy.yml @@ -33,13 +33,23 @@ jobs: author = pr.get('user') or {} is_dependabot = author.get('login') == 'dependabot[bot]' and author.get('type') == 'Bot' errors = [] - if not re.match(r'^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|spec)(\([^\r\n()]+\))?!?: \S', pr['title']): + title = re.match(r'^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|spec)(\([^\r\n()]+\))?(?P!)?: \S', pr['title']) + if not title: errors.append('Use a Conventional Commit PR title, such as docs: explain the setup.') body = re.sub(r'', '', pr.get('body') or '') - if not is_dependabot and not re.search(r'\b(?:fix(?:es|ed)?|close[sd]?|resolve[sd]?)\s+(?:#\d+|[\w.-]+/[\w.-]+#\d+|https://github\.com/[\w.-]+/[\w.-]+/issues/\d+)\b', body, re.I): - errors.append('Link a real issue using Fixes, Closes, or Resolves.') + labels = {label['name'] for label in pr.get('labels', [])} + breaking = (title and title.group('breaking')) or re.search(r'(?im)^[ \t]*BREAKING[ -]CHANGE:', body) + issue_required = bool(labels & {'normative', 'issue-required'} or breaking) + has_issue = re.search(r'(?]*>|\[[^\]]*\]|\.{3}|…', reason) + if not has_issue: + if issue_required: + errors.append('Link an issue or proposal for normative, breaking, or issue-required PRs; a No issue needed explanation or Dependabot authorship does not exempt them.') + elif not is_dependabot and not has_explanation: + errors.append('Reference an issue (for example, Refs #123) or add No issue needed: followed by a brief reason. Use Fixes or Closes only when this PR completes the issue.') if os.environ['SPECIFICATION_REPOSITORY'] == 'true': - labels = {label['name'] for label in pr.get('labels', [])} if len(labels & {'editorial', 'normative'}) != 1: errors.append('Apply exactly one specification label: editorial or normative.') for error in errors: diff --git a/tests/test_contribution_policy.py b/tests/test_contribution_policy.py index 0054a0f..a9cc56f 100644 --- a/tests/test_contribution_policy.py +++ b/tests/test_contribution_policy.py @@ -30,6 +30,68 @@ def test_valid_pull_request(self): def test_template_comments_are_not_issue_links(self): self.assertNotEqual(self.run_policy(body="\nFixes #").returncode, 0) + def test_closing_and_non_closing_issue_references(self): + for body in [ + "Fixes #123", "Closes #123", "Resolves #123", "Refs #123", "Related: #123", + "Refs agentnameservice/ans-spec#123", + "Related: https://github.com/agentnameservice/ans-spec/issues/123", + "[Proposal](https://github.com/agentnameservice/ans-spec/issues/123)", + ]: + with self.subTest(body=body): + self.assertEqual(self.run_policy(body=body).returncode, 0) + + def test_issue_reference_placeholders_do_not_pass(self): + for body in ["Fixes #", "Refs #NNN", "Refs #0", "Refs #123abc", "Refs owner/repo#NNN", "https://github.com/owner/repo/issues/NNN"]: + with self.subTest(body=body): + self.assertNotEqual(self.run_policy(body=body).returncode, 0) + + def test_no_issue_explanation_allows_self_contained_changes(self): + for title in ["feat(cli): add JSON output", "fix: correct a bug", "docs: fix a typo"]: + with self.subTest(title=title): + result = self.run_policy(title=title, body="## Related issue\n\nNo issue needed: Self-contained change using existing behavior.") + self.assertEqual(result.returncode, 0, result.stdout) + self.assertEqual(self.run_policy(body="no issue needed: Local test coverage.", labels=["editorial"], specification=True).returncode, 0) + + def test_empty_and_placeholder_explanations_do_not_pass(self): + for body in [ + None, "", "No issue needed:", "No issue needed: \t", + "No issue needed:\n\n## Summary\nFix a typo.", + "No issue needed:\r\n## Summary\r\nFix a typo.", + "No issue needed: ", "", + "No issue needed: ", "No issue needed: [brief reason]", + "No issue needed: ...", "No issue needed: …", + ]: + with self.subTest(body=body): + result = self.run_policy(body=body) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Reference an issue", result.stdout) + + def test_mandatory_issue_conditions_override_exemptions(self): + for title, labels, footer in [ + ("spec: update requirements", ["normative"], ""), + ("feat: add integration", ["issue-required"], ""), + ("feat!: remove old option", [], ""), + ("feat(cli)!: remove old option", [], ""), + ("feat: remove old option", [], "\n\nBREAKING CHANGE: The old option is removed."), + ("feat: remove old option", [], "\n\nBREAKING-CHANGE: The old option is removed."), + ]: + for author, author_type in [("contributor", "User"), ("dependabot[bot]", "Bot")]: + for body, allowed in [("", False), ("No issue needed: Self-contained change.", False), ("Refs #123", True)]: + with self.subTest(title=title, labels=labels, footer=footer, author=author, body=body): + result = self.run_policy(title=title, labels=labels, body=body + footer, author=author, author_type=author_type) + self.assertEqual(result.returncode, 0 if allowed else 1, result.stdout) + if not allowed: + self.assertIn("Link an issue or proposal", result.stdout) + + def test_issue_required_label_removal_allows_explanation(self): + body = "No issue needed: Self-contained change." + self.assertNotEqual(self.run_policy(body=body, labels=["issue-required"]).returncode, 0) + self.assertEqual(self.run_policy(body=body).returncode, 0) + + def test_breaking_change_template_comment_does_not_require_issue(self): + body = "\nNo issue needed: Local test coverage." + self.assertEqual(self.run_policy(body=body).returncode, 0) + def test_normative_and_editorial_are_exclusive(self): self.assertEqual(self.run_policy(labels=["normative"], specification=True).returncode, 0) for labels in [[], ["normative", "editorial"]]: @@ -49,20 +111,20 @@ def test_issue_exemption_requires_dependabot_author(self): with self.subTest(author=author, author_type=author_type): result = self.run_policy(title="chore(deps): bump js-yaml", body="Bumps js-yaml.", author=author, author_type=author_type, actor="dependabot[bot]") self.assertNotEqual(result.returncode, 0) - self.assertIn("Link a real issue", result.stdout) + self.assertIn("Reference an issue", result.stdout) def test_dependabot_still_requires_conventional_title(self): result = self.run_policy(title="Bump js-yaml", body="", author="dependabot[bot]", author_type="Bot") self.assertNotEqual(result.returncode, 0) self.assertIn("Conventional Commit", result.stdout) - self.assertNotIn("Link a real issue", result.stdout) + self.assertNotIn("Reference an issue", result.stdout) def test_dependabot_still_requires_specification_label(self): for labels in [[], ["editorial", "normative"], ["editorial"], ["normative"]]: with self.subTest(labels=labels): - result = self.run_policy(body="", labels=labels, specification=True, author="dependabot[bot]", author_type="Bot") + result = self.run_policy(body="Refs #123" if "normative" in labels else "", labels=labels, specification=True, author="dependabot[bot]", author_type="Bot") self.assertEqual(result.returncode, 0 if len(labels) == 1 else 1, result.stdout) - self.assertNotIn("Link a real issue", result.stdout) + self.assertNotIn("Reference an issue", result.stdout) def test_events_without_a_pull_request_are_rejected(self): for event_name in ["merge_group", "workflow_dispatch"]: From 47319edb60823ae0de834139bf1bf3ed4190574a Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Fri, 30 Oct 2026 09:29:48 -0500 Subject: [PATCH 2/7] feat: add shared governance forms and inactive PR automation Signed-off-by: Connor Snitker --- .github/ISSUE_TEMPLATE/nomination.yml | 40 +++ .github/ISSUE_TEMPLATE/proposal.yml | 39 +++ .github/ISSUE_TEMPLATE/spec-tracking.yml | 37 +++ .github/workflows/inactive-prs.yml | 33 +++ .gitvote.yml | 18 ++ README.md | 53 ++++ .../ISSUE_TEMPLATE/spec-release.yml | 37 +++ safe-settings/suborgs/defaults.yml | 30 +++ scripts/inactive-prs.mjs | 134 ++++++++++ tests/contribution-tooling.test.mjs | 58 ++++ tests/inactive-prs.test.mjs | 252 ++++++++++++++++++ .../ans-inactive-prs.properties.json | 5 + workflow-templates/ans-inactive-prs.yml | 20 ++ 13 files changed, 756 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/nomination.yml create mode 100644 .github/ISSUE_TEMPLATE/proposal.yml create mode 100644 .github/ISSUE_TEMPLATE/spec-tracking.yml create mode 100644 .github/workflows/inactive-prs.yml create mode 100644 .gitvote.yml create mode 100644 repo-templates/specification/ISSUE_TEMPLATE/spec-release.yml create mode 100644 scripts/inactive-prs.mjs create mode 100644 tests/contribution-tooling.test.mjs create mode 100644 tests/inactive-prs.test.mjs create mode 100644 workflow-templates/ans-inactive-prs.properties.json create mode 100644 workflow-templates/ans-inactive-prs.yml diff --git a/.github/ISSUE_TEMPLATE/nomination.yml b/.github/ISSUE_TEMPLATE/nomination.yml new file mode 100644 index 0000000..cd510ef --- /dev/null +++ b/.github/ISSUE_TEMPLATE/nomination.yml @@ -0,0 +1,40 @@ +name: Reviewer or Maintainer nomination +description: Propose a repository role with supporting contribution evidence. +title: 'Nomination: ' +labels: [nomination] +body: + - type: input + id: nominee + attributes: + label: Nominee + placeholder: '@handle' + validations: + required: true + - type: dropdown + id: role + attributes: + label: Role + options: [Reviewer, Maintainer] + validations: + required: true + - type: input + id: scope + attributes: + label: Repository or review scope + description: Identify the repository and any CODEOWNERS paths covered by this nomination. + validations: + required: true + - type: textarea + id: evidence + attributes: + label: Contribution evidence + description: Link relevant reviews, implementations, releases, and sustained maintenance work. + validations: + required: true + - type: textarea + id: decision + attributes: + label: Sponsor and decision process + description: Name the sponsor and link the applicable eligibility and approval process, including its deadline. Accepted access changes go through reviewed Peribolos configuration and CODEOWNERS updates. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/proposal.yml b/.github/ISSUE_TEMPLATE/proposal.yml new file mode 100644 index 0000000..b2c4565 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/proposal.yml @@ -0,0 +1,39 @@ +name: Proposal +description: Discuss a change that needs agreement on scope, compatibility, or support. +title: 'Proposal: ' +labels: [proposal] +body: + - type: textarea + id: problem + attributes: + label: Problem + description: What is missing or incorrect, and who is affected? + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposed change + description: Describe the resulting behavior. Show current and proposed wording for specification changes. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + validations: + required: true + - type: textarea + id: compatibility + attributes: + label: Compatibility and maintenance + description: Identify affected interfaces, deployments, schemas, implementations, and ongoing support obligations. + validations: + required: true + - type: textarea + id: decision + attributes: + label: Decision process + description: Link the applicable governance process and identify the decision owner, review deadline, and related tracking issues. An issue label does not itself approve the proposal. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/spec-tracking.yml b/.github/ISSUE_TEMPLATE/spec-tracking.yml new file mode 100644 index 0000000..429caf1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/spec-tracking.yml @@ -0,0 +1,37 @@ +name: Specification tracking +description: Track implementation work associated with a specification change. +title: 'Spec tracking: ' +labels: [spec-tracking] +body: + - type: input + id: specification + attributes: + label: Specification change + description: Link the specification PR or accepted proposal. + validations: + required: true + - type: input + id: release + attributes: + label: Target specification release + validations: + required: true + - type: textarea + id: implementation + attributes: + label: Implementation work + description: Describe the required behavior and link implementation PRs as they become available. + validations: + required: true + - type: textarea + id: verification + attributes: + label: Conformance and verification + description: Identify affected conformance vectors and how the implementation will be verified. + validations: + required: true + - type: textarea + id: coordination + attributes: + label: Release dependencies or deferrals + description: State whether this work blocks a release. Link any approved deferral and its decision owner. diff --git a/.github/workflows/inactive-prs.yml b/.github/workflows/inactive-prs.yml new file mode 100644 index 0000000..71140cf --- /dev/null +++ b/.github/workflows/inactive-prs.yml @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +name: Inactive pull requests + +on: + workflow_call: + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: ans-inactive-prs + cancel-in-progress: false + +jobs: + sweep: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: agentnameservice/.github + ref: main + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + - run: npm ci --ignore-scripts --no-audit --no-fund + - name: Update author-response timers and process reminders + env: + GH_TOKEN: ${{ github.token }} + run: node scripts/inactive-prs.mjs diff --git a/.gitvote.yml b/.gitvote.yml new file mode 100644 index 0000000..93243fb --- /dev/null +++ b/.gitvote.yml @@ -0,0 +1,18 @@ +profiles: + default: + duration: 7days + pass_threshold: 51 + pass_threshold_base: allowed_voters + allowed_voters: + teams: [tsc] + close_on_passing: false + periodic_status_check: 3 days + two-thirds: + duration: 7days + # Rounding up to 66.67 would reject exactly two thirds of three, six, or nine voters. + pass_threshold: 66.66 + pass_threshold_base: allowed_voters + allowed_voters: + teams: [tsc] + close_on_passing: false + periodic_status_check: 3 days diff --git a/README.md b/README.md index 6b7f95b..30369e6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,59 @@ repository; the shared defaults maintain those labels after enrollment. GoReleaser publishes their generated package updates directly to `main`. Their Maintainer team access is still managed through Peribolos. +## Shared contribution tooling + +| Feature | How repositories receive it | +| --- | --- | +| Shared labels | Safe Settings applies `safe-settings/suborgs/defaults.yml` only to repositories in the settings scope, when organization management runs in apply mode. Labels do not inherit from `.github` on their own. | +| Issue forms | GitHub uses this repository's `.github/ISSUE_TEMPLATE/` as organization defaults for repositories without local issue forms. Local issue-template directories override that fallback; copy any shared forms they need. | +| Contribution and inactivity workflows | Install a caller from `workflow-templates/` in each repository's `.github/workflows/`. Workflows are not inherited or automatically copied. Callers use the centrally maintained workflow on `main`. | +| Specification release form | Copy `repo-templates/specification/ISSUE_TEMPLATE/spec-release.yml` into `ans-spec` or `trust-index-spec`, alongside the other forms that repository needs. | +| GitVote | With the [GitVote App](https://github.com/apps/git-vote) installed, the root `.gitvote.yml` is an organization fallback. A repository's own configuration takes precedence. | + +The proposal, specification-tracking, and nomination forms collect the evidence +and decision process for a change. Labels record its status; they do not approve +proposals, grant team access, or enforce release acceptance. Governance-only +forms and quarterly health checks belong in the governance repository. + +The contribution check accepts an issue reference or a `No issue needed:` +explanation. `normative`, `issue-required`, and explicitly breaking PRs require +a reference even for Dependabot. Create `issue-required` in repositories using +that override before relying on it; the shared label configuration maintains it +once those repositories are enrolled. + +### Inactive pull requests + +Create the `awaiting-author` and `stale` labels before installing the inactivity +caller. It starts a 60-day response period when it observes a non-draft PR with +changes requested and no pending review requests. Existing PRs start a fresh +period when the workflow first tracks them. After 60 days it posts a reminder; +after another 30 days without an author response it closes the PR, leaving +related issues open. Authors can reopen a PR when ready to continue. + +Pushes, author comments (including review-thread replies and review summaries), +and new change requests restart the full period. A pending review request, +approval, draft conversion, or closure removes both labels. Bot comments do not +restart the clock. Removing `stale` manually +restarts the period. The timer uses the server timestamp of `awaiting-author`, +and a close requires a reminder for the current head commit. General PR update +timestamps and contributor-supplied commit dates do not determine inactivity. + +The caller handles trusted base-repository events, including +`pull_request_target`, and runs a daily sweep. The reusable workflow checks out +only `agentnameservice/.github` at `main`; it never checks out or executes the +contributor's branch. Its token is scoped to the calling repository. + +### GitVote + +Keep the `tsc` team aligned with the eligible voting members before using +`/vote` (majority) or `/vote-two-thirds`. Both profiles count all eligible voters +and remain open for seven days. The thresholds implement majority and two-thirds +for up to nine eligible members, including vacancies. Special voter exclusions +or a different electorate require a matching profile. These defaults do not +start votes automatically or replace proposal review periods and other required +approvals. The GitVote App must have access to the voting repositories and team. + ## Development ```sh diff --git a/repo-templates/specification/ISSUE_TEMPLATE/spec-release.yml b/repo-templates/specification/ISSUE_TEMPLATE/spec-release.yml new file mode 100644 index 0000000..b7f3b8a --- /dev/null +++ b/repo-templates/specification/ISSUE_TEMPLATE/spec-release.yml @@ -0,0 +1,37 @@ +name: Specification release +description: Coordinate a release in ans-spec or trust-index-spec. +title: 'Spec release: ' +labels: [release] +body: + - type: input + id: version + attributes: + label: Version and previous tag + validations: + required: true + - type: textarea + id: changes + attributes: + label: Specification changes + description: Link normative changes and identify affected schemas, APIs, and conformance vectors. + validations: + required: true + - type: textarea + id: implementations + attributes: + label: Implementation readiness + description: Link tracking issues and conformance results for affected implementations and SDKs. + validations: + required: true + - type: textarea + id: deferrals + attributes: + label: Approved deferrals + description: Link the decision, responsible Maintainers, and intended catch-up release for each deferral. + - type: textarea + id: approval + attributes: + label: Release approval + description: Link the applicable release requirements and record the required approvals before tagging. + validations: + required: true diff --git a/safe-settings/suborgs/defaults.yml b/safe-settings/suborgs/defaults.yml index b9457ac..1d94e18 100644 --- a/safe-settings/suborgs/defaults.yml +++ b/safe-settings/suborgs/defaults.yml @@ -30,6 +30,33 @@ rulesets: labels: include: + - name: issue-required + color: fbca04 + description: A tracking issue or proposal is required before merge + - name: proposal + color: '0e8a16' + description: A proposed change requiring a recorded decision + - name: needs-proposal + color: fbca04 + description: A proposal decision is needed before implementation can merge + - name: accepted + color: '0e8a16' + description: Proposal accepted + - name: accepted-with-changes + color: '0e8a16' + description: Proposal accepted with changes recorded in the decision + - name: declined + color: b60205 + description: Proposal declined with reasons recorded in the decision + - name: nomination + color: 1d76db + description: Reviewer or Maintainer nomination + - name: spec-tracking + color: d93f0b + description: Implementation work associated with a specification change + - name: release + color: '0052cc' + description: Release coordination - name: editorial color: '0075ca' description: No normative specification change @@ -39,6 +66,9 @@ labels: - name: stale color: ededed description: Awaiting author response under the ANS inactivity policy + - name: awaiting-author + color: fbca04 + description: Changes requested with no pending review request; inactivity clock starts here - name: needs-info color: d876e3 description: Additional information is needed diff --git a/scripts/inactive-prs.mjs b/scripts/inactive-prs.mjs new file mode 100644 index 0000000..34c09f5 --- /dev/null +++ b/scripts/inactive-prs.mjs @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: Apache-2.0 +import fs from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import { github, optional } from "./github.mjs"; + +const DAY = 24 * 60 * 60 * 1000; +const WAITING = "awaiting-author"; +const STALE = "stale"; +const REMINDER = //; + +async function snapshot(gh, owner, repo, number) { + const { repository } = await gh.graphql(` + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + state isDraft headRefOid updatedAt author { login } + reviewDecision reviewRequests(first: 1) { totalCount } + labels(first: 100) { nodes { name } pageInfo { hasNextPage } } + } + } + }`, { owner, repo, number }); + const pr = repository.pullRequest; + const labels = pr.labels.pageInfo.hasNextPage + ? await gh.paginate(gh.rest.issues.listLabelsOnIssue, { owner, repo, issue_number: number, per_page: 100 }) + : pr.labels.nodes; + return { ...pr, labels: new Set(labels.map((label) => label.name)) }; +} + +function waiting(pr) { + return pr.state === "OPEN" && !pr.isDraft && + pr.reviewDecision === "CHANGES_REQUESTED" && pr.reviewRequests.totalCount === 0; +} + +export async function processPullRequest(gh, owner, repo, number, event = {}, now = Date.now()) { + const params = { owner, repo, issue_number: number }; + const pr = await snapshot(gh, owner, repo, number); + async function remove(label) { + if (pr.labels.has(label)) + await optional(() => gh.rest.issues.removeLabel({ ...params, name: label })); + } + async function restart() { + await remove(STALE); + await remove(WAITING); + await gh.rest.issues.addLabels({ ...params, labels: [WAITING] }); + return "started a new 60-day author-response period"; + } + async function unchanged() { + const current = await snapshot(gh, owner, repo, number); + return waiting(current) && current.headRefOid === pr.headRefOid && current.updatedAt === pr.updatedAt; + } + if (!waiting(pr)) { + await remove(STALE); + await remove(WAITING); + return "not waiting on the author"; + } + if (!pr.labels.has(WAITING) || event.action === "synchronize" || + (event.action === "unlabeled" && event.label?.name === STALE)) { + return restart(); + } + + const events = await gh.paginate(gh.rest.issues.listEvents, { ...params, per_page: 100 }); + const startedAt = Math.max(0, ...events + .filter((entry) => entry.event === "labeled" && entry.label?.name === WAITING) + .map((entry) => Date.parse(entry.created_at))); + if (!startedAt) return "no author-response timestamp available; left unchanged"; + if (events.some((entry) => entry.event === "unlabeled" && entry.label?.name === STALE && + Date.parse(entry.created_at) > startedAt)) return restart(); + const since = new Date(startedAt).toISOString(); + const [comments, reviewComments, reviews] = await Promise.all([ + gh.paginate(gh.rest.issues.listComments, { ...params, since, per_page: 100 }), + gh.paginate(gh.rest.pulls.listReviewComments, { owner, repo, pull_number: number, since, per_page: 100 }), + gh.paginate(gh.rest.pulls.listReviews, { owner, repo, pull_number: number, per_page: 100 }), + ]); + const authorResponded = [...comments, ...reviewComments].some((comment) => + comment.user?.login === pr.author?.login && Date.parse(comment.updated_at) > startedAt); + const reviewActivity = reviews.some((review) => Date.parse(review.submitted_at) > startedAt && + (review.state === "CHANGES_REQUESTED" || (review.user?.login === pr.author?.login && review.body?.trim()))); + if (authorResponded || reviewActivity) return restart(); + + const reminder = comments + .filter((comment) => comment.user?.login === "github-actions[bot]" && + REMINDER.test(comment.body) && Date.parse(comment.created_at) >= startedAt + 60 * DAY) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0]; + // Bind the warning to the code that was reviewed, including pushes missed by event delivery. + if (reminder && reminder.body.match(REMINDER)[1] !== pr.headRefOid) return restart(); + if (!await unchanged()) return "PR changed during inspection; left unchanged"; + + if (pr.labels.has(STALE) && reminder) { + if (now - Date.parse(reminder.created_at) < 30 * DAY) return "within the reminder period"; + await gh.rest.pulls.update({ owner, repo, pull_number: number, state: "closed" }); + await remove(STALE); + await remove(WAITING); + await gh.rest.issues.createComment({ + ...params, + body: "Closed after 60 days waiting on the author and a further 30 days without an author response to the reminder. Reopen this PR when ready to continue and re-request review. Related issues remain open.", + }); + return "closed after the author-response and reminder periods"; + } + if (now - startedAt < 60 * DAY) { + await remove(STALE); + return "within the author-response period"; + } + // A manually added stale label cannot substitute for a dated reminder from this workflow. + if (!reminder) { + await gh.rest.issues.createComment({ + ...params, + body: `This PR has been waiting on its author for 60 days with no author response. Please push an update, reply, or re-request review. If it is still waiting on the author in 30 days, it will be closed; it can be reopened later.\n\n`, + }); + } + await gh.rest.issues.addLabels({ ...params, labels: [STALE] }); + return "reminded the author and marked stale"; +} + +export async function run(gh, repository, eventName, event) { + const [owner, repo] = repository.split("/"); + if (!owner || !repo) throw new Error("GITHUB_REPOSITORY must identify the caller repository"); + if (eventName === "issue_comment" && !event.issue?.pull_request) return; + if (["labeled", "unlabeled"].includes(event.action) && + ![WAITING, STALE].includes(event.label?.name)) return; + const number = event.pull_request?.number || event.issue?.number; + if (!number && !["schedule", "workflow_dispatch"].includes(eventName)) return; + const numbers = number ? [number] : (await gh.paginate(gh.rest.pulls.list, { + owner, repo, state: "open", per_page: 100, + })).map((pr) => pr.number); + for (const candidate of numbers) { + const result = await processPullRequest(gh, owner, repo, candidate, event); + console.log(`#${candidate}: ${result}`); + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const event = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8")); + await run(github(), process.env.GITHUB_REPOSITORY, process.env.GITHUB_EVENT_NAME, event); +} diff --git a/tests/contribution-tooling.test.mjs b/tests/contribution-tooling.test.mjs new file mode 100644 index 0000000..0118694 --- /dev/null +++ b/tests/contribution-tooling.test.mjs @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import yaml from "js-yaml"; + +const read = (path) => yaml.load(fs.readFileSync(new URL(`../${path}`, import.meta.url), "utf8")); + +test("every shared form and automation label is provisioned by Safe Settings", () => { + const labels = read("safe-settings/suborgs/defaults.yml").labels.include; + const names = new Set(labels.map((label) => label.name)); + assert.equal(names.size, labels.length); + for (const name of ["issue-required", "awaiting-author", "stale", "proposal", "needs-proposal", "accepted", "accepted-with-changes", "declined"]) + assert.ok(names.has(name), name); + for (const directory of [".github/ISSUE_TEMPLATE/", "repo-templates/specification/ISSUE_TEMPLATE/"]) { + for (const file of fs.readdirSync(new URL(`../${directory}`, import.meta.url))) { + const form = read(directory + file); + for (const name of form.labels || []) assert.ok(names.has(name), `${file}: ${name}`); + assert.ok(form.name && form.description); + const ids = form.body.filter((field) => field.id).map((field) => field.id); + assert.equal(new Set(ids).size, ids.length, file); + } + } +}); + +test("GitVote thresholds implement majority and two-thirds for every eligible TSC size", () => { + const profiles = read(".gitvote.yml").profiles; + for (const [name, required] of [ + ["default", (n) => Math.floor(n / 2) + 1], + ["two-thirds", (n) => Math.ceil(2 * n / 3)], + ]) { + const profile = profiles[name]; + assert.equal(profile.pass_threshold_base, "allowed_voters"); + assert.deepEqual(profile.allowed_voters, { teams: ["tsc"] }); + assert.equal(profile.close_on_passing, false); + assert.equal(profile.duration, "7days"); + for (let eligible = 1; eligible <= 9; eligible++) { + for (let yes = 0; yes <= eligible; yes++) { + assert.equal(100 * yes / eligible >= profile.pass_threshold, yes >= required(eligible), `${name}: ${yes}/${eligible}`); + } + } + } +}); + +test("the privileged caller uses central code and handles push and author-response events", () => { + const caller = read("workflow-templates/ans-inactive-prs.yml"); + const workflow = read(".github/workflows/inactive-prs.yml"); + assert.equal(caller.jobs.inactive.uses, "agentnameservice/.github/.github/workflows/inactive-prs.yml@main"); + assert.ok(caller.on.pull_request_target.types.includes("synchronize")); + assert.ok(caller.on.pull_request_target.types.includes("review_requested")); + assert.ok(caller.on.pull_request_target.types.includes("unlabeled")); + assert.deepEqual(caller.on.issue_comment.types, ["created", "edited"]); + assert.ok(caller.on.schedule.length); + assert.deepEqual(caller.permissions, workflow.permissions); + const checkout = workflow.jobs.sweep.steps.find((step) => step.uses?.startsWith("actions/checkout@")); + assert.deepEqual(checkout.with, { repository: "agentnameservice/.github", ref: "main", "persist-credentials": false }); + assert.equal(workflow.concurrency["cancel-in-progress"], false); +}); diff --git a/tests/inactive-prs.test.mjs b/tests/inactive-prs.test.mjs new file mode 100644 index 0000000..7bd1d6e --- /dev/null +++ b/tests/inactive-prs.test.mjs @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: Apache-2.0 +import test from "node:test"; +import assert from "node:assert/strict"; +import { processPullRequest, run } from "../scripts/inactive-prs.mjs"; + +const DAY = 86_400_000; +const START = Date.parse("2026-01-01T00:00:00Z"); +function fixture() { + let now = START; + const labels = new Set(); + const events = []; + const comments = []; + const reviewComments = []; + const reviews = []; + const calls = []; + const pr = { + state: "OPEN", isDraft: false, headRefOid: "a".repeat(40), + updatedAt: new Date(now).toISOString(), author: { login: "author" }, + reviewDecision: "CHANGES_REQUESTED", reviewRequests: { totalCount: 0 }, + }; + const stamp = () => { pr.updatedAt = new Date(now).toISOString(); }; + const gh = { + graphql: async () => ({ repository: { pullRequest: { + ...pr, labels: { nodes: [...labels].map((name) => ({ name })), pageInfo: { hasNextPage: false } }, + } } }), + paginate: async (route, params) => { + calls.push([route, params]); + if (route === "events") return events; + if (route === "comments") return comments; + if (route === "reviewComments") return reviewComments; + if (route === "reviews") return reviews; + if (route === "pulls") return [{ number: 1 }]; + throw new Error(`Unexpected route: ${route}`); + }, + rest: { + issues: { + listEvents: "events", listComments: "comments", + addLabels: async ({ labels: additions }) => { + for (const name of additions) { + if (!labels.has(name)) events.push({ event: "labeled", label: { name }, created_at: new Date(now).toISOString() }); + labels.add(name); + } + stamp(); + }, + removeLabel: async ({ name }) => { labels.delete(name); stamp(); }, + createComment: async ({ body }) => { + comments.push({ body, user: { login: "github-actions[bot]" }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); + stamp(); + }, + }, + pulls: { + list: "pulls", listReviewComments: "reviewComments", listReviews: "reviews", + update: async ({ state }) => { pr.state = state.toUpperCase(); stamp(); }, + }, + }, + }; + return { + gh, pr, labels, events, comments, reviewComments, reviews, calls, + day: (day) => { now = START + day * DAY; }, + tick: (event = {}) => processPullRequest(gh, "agentnameservice", "sample", 1, event, now), + reply: (collection = comments, login = "author") => { + collection.push({ body: "Update", user: { login }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); + stamp(); + }, + }; +} + +test("a PR gets a full 60-day response period and 30-day reminder period", async () => { + const f = fixture(); + await f.tick(); + assert.deepEqual([...f.labels], ["awaiting-author"]); + assert.equal(f.comments.length, 0); + f.day(59); await f.tick(); + assert.equal(f.comments.length, 0); + f.day(60); await f.tick(); + assert.ok(f.labels.has("stale")); + assert.equal(f.comments.length, 1); + f.day(89); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); + assert.equal(f.labels.size, 0); + assert.match(f.comments.at(-1).body, /Related issues remain open/); +}); + +test("drafts, closed PRs, approvals, and pending reviews clear author-wait labels", async () => { + for (const update of [ + { isDraft: true }, { state: "CLOSED" }, { reviewDecision: "APPROVED" }, + { reviewDecision: null }, { reviewDecision: "REVIEW_REQUIRED" }, { reviewRequests: { totalCount: 1 } }, + ]) { + const f = fixture(); + await f.tick(); f.day(60); await f.tick(); + Object.assign(f.pr, update); + f.day(100); await f.tick(); + assert.equal(f.labels.size, 0); + assert.equal(f.comments.length, 1); + } +}); + +test("author replies in either comment channel reset the complete period", async () => { + for (const channel of ["comments", "reviewComments"]) { + const f = fixture(); + await f.tick(); f.day(60); await f.tick(); + f.day(70); f.reply(f[channel]); await f.tick(); + assert.ok(!f.labels.has("stale")); + f.day(100); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.ok(!f.labels.has("stale")); + f.day(130); await f.tick(); + assert.ok(f.labels.has("stale")); + f.day(159); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + f.day(160); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); + } +}); + +test("edited author replies reset the period", async () => { + const f = fixture(); + f.reply(); await f.tick(); f.day(60); await f.tick(); + f.comments[0].updated_at = new Date(START + 70 * DAY).toISOString(); + f.day(70); await f.tick(); + assert.ok(!f.labels.has("stale")); +}); + +test("new change requests and author review summaries restart the full period", async () => { + for (const review of [ + { state: "CHANGES_REQUESTED", user: { login: "reviewer" }, body: "Please adjust the API." }, + { state: "COMMENTED", user: { login: "author" }, body: "Here is the remaining work." }, + ]) { + const f = fixture(); + await f.tick(); f.day(60); await f.tick(); + f.reviews.push({ ...review, submitted_at: new Date(START + 80 * DAY).toISOString() }); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.ok(!f.labels.has("stale")); + } +}); + +test("bot and reviewer comments do not reset the author-response clock", async () => { + const f = fixture(); + await f.tick(); f.day(50); f.reply(f.comments, "another-bot[bot]"); await f.tick(); + f.day(60); await f.tick(); + assert.ok(f.labels.has("stale")); + f.day(80); f.reply(f.comments, "reviewer"); await f.tick(); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); +}); + +test("a push or manual removal of stale restarts the full response period", async () => { + for (const event of [{ action: "synchronize" }, { action: "unlabeled", label: { name: "stale" } }]) { + const f = fixture(); + await f.tick(); f.day(60); await f.tick(); + f.day(80); await f.tick(event); + assert.ok(!f.labels.has("stale")); + f.day(110); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.ok(!f.labels.has("stale")); + } +}); + +test("a changed head cannot be closed using a warning for earlier code", async () => { + const f = fixture(); + await f.tick(); f.day(60); await f.tick(); + f.pr.headRefOid = "b".repeat(40); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.ok(!f.labels.has("stale")); +}); + +test("label history preserves a manual deferral if its workflow event was missed", async () => { + const f = fixture(); + await f.tick(); f.day(60); await f.tick(); + f.labels.delete("stale"); + f.events.push({ event: "unlabeled", label: { name: "stale" }, created_at: new Date(START + 80 * DAY).toISOString() }); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.ok(!f.labels.has("stale")); +}); + +test("a manually applied stale label cannot bypass the reminder", async () => { + const f = fixture(); + await f.tick(); f.day(10); f.labels.add("stale"); await f.tick(); + assert.ok(!f.labels.has("stale")); + f.day(90); f.labels.add("stale"); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.equal(f.comments.length, 1); + f.day(119); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + f.day(120); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); +}); + +test("contributor text cannot impersonate the workflow's reminder", async () => { + const f = fixture(); + await f.tick(); + f.day(60); f.reply(f.comments, "contributor"); + f.comments[0].body = ``; + f.labels.add("stale"); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.equal(f.comments.length, 2); +}); + +test("missing timer history does not guess a closure date", async () => { + const f = fixture(); + f.labels.add("awaiting-author"); f.labels.add("stale"); + f.day(200); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.equal(f.comments.length, 0); +}); + +test("an update during inspection prevents automatic closure", async () => { + const f = fixture(); + await f.tick(); f.day(60); await f.tick(); f.day(90); + const read = f.gh.graphql; + let calls = 0; + f.gh.graphql = async (...args) => { + if (++calls === 2) f.pr.reviewRequests.totalCount = 1; + return read(...args); + }; + await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.equal(f.comments.length, 1); +}); + +test("scheduled sweeps paginate open PRs and activity", async (t) => { + t.mock.method(console, "log", () => {}); + const f = fixture(); + const paginate = f.gh.paginate; + f.gh.paginate = async (route, params) => route === "pulls" + ? Array.from({ length: 201 }, (_, i) => ({ number: i + 1 })) + : paginate(route, params); + let processed = 0; + f.gh.graphql = async () => { + processed++; + return { repository: { pullRequest: { ...f.pr, reviewDecision: "APPROVED", labels: { nodes: [], pageInfo: { hasNextPage: false } } } } }; + }; + await run(f.gh, "agentnameservice/sample", "schedule", {}); + assert.equal(processed, 201); + const activity = fixture(); + await activity.tick(); activity.day(60); await activity.tick(); + for (const route of ["events", "comments", "reviewComments", "reviews"]) + assert.ok(activity.calls.some(([name, params]) => name === route && params.per_page === 100)); +}); + +test("ordinary issues and unrelated labels do not run PR automation", async () => { + const f = fixture(); + f.gh.graphql = async () => { throw new Error("Should not inspect a PR"); }; + await run(f.gh, "agentnameservice/sample", "issue_comment", { issue: { number: 1 } }); + await run(f.gh, "agentnameservice/sample", "pull_request_target", { action: "labeled", label: { name: "proposal" }, pull_request: { number: 1 } }); +}); diff --git a/workflow-templates/ans-inactive-prs.properties.json b/workflow-templates/ans-inactive-prs.properties.json new file mode 100644 index 0000000..2ba4f46 --- /dev/null +++ b/workflow-templates/ans-inactive-prs.properties.json @@ -0,0 +1,5 @@ +{ + "name": "ANS inactive pull requests", + "description": "Remind authors after 60 days awaiting changes and close after another 30 days without a response. Requires awaiting-author and stale labels.", + "categories": ["Automation"] +} diff --git a/workflow-templates/ans-inactive-prs.yml b/workflow-templates/ans-inactive-prs.yml new file mode 100644 index 0000000..5371ea3 --- /dev/null +++ b/workflow-templates/ans-inactive-prs.yml @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: Apache-2.0 +name: Inactive pull requests + +on: + schedule: + - cron: '17 4 * * *' + workflow_dispatch: + pull_request_target: + types: [synchronize, reopened, closed, ready_for_review, converted_to_draft, review_requested, review_request_removed, labeled, unlabeled] + issue_comment: + types: [created, edited] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + inactive: + uses: agentnameservice/.github/.github/workflows/inactive-prs.yml@main From 203ae0a23a44b22e6ecf6d9f251c26fb8e40be7f Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Fri, 30 Oct 2026 09:39:22 -0500 Subject: [PATCH 3/7] fix: default inactivity automation to observation mode Signed-off-by: Connor Snitker --- .github/workflows/inactive-prs.yml | 6 +++ README.md | 15 ++++-- scripts/inactive-prs.mjs | 32 ++++++++---- tests/contribution-tooling.test.mjs | 3 ++ tests/inactive-prs.test.mjs | 49 ++++++++++++++++++- .../ans-inactive-prs.properties.json | 2 +- workflow-templates/ans-inactive-prs.yml | 2 + 7 files changed, 93 insertions(+), 16 deletions(-) diff --git a/.github/workflows/inactive-prs.yml b/.github/workflows/inactive-prs.yml index 71140cf..3fa4302 100644 --- a/.github/workflows/inactive-prs.yml +++ b/.github/workflows/inactive-prs.yml @@ -3,6 +3,11 @@ name: Inactive pull requests on: workflow_call: + inputs: + dry_run: + description: Report proposed actions without changing pull requests + type: boolean + default: true permissions: contents: read @@ -30,4 +35,5 @@ jobs: - name: Update author-response timers and process reminders env: GH_TOKEN: ${{ github.token }} + DRY_RUN: ${{ inputs.dry_run }} run: node scripts/inactive-prs.mjs diff --git a/README.md b/README.md index 30369e6..6caeff8 100644 --- a/README.md +++ b/README.md @@ -116,10 +116,17 @@ once those repositories are enrolled. ### Inactive pull requests -Create the `awaiting-author` and `stale` labels before installing the inactivity -caller. It starts a 60-day response period when it observes a non-draft PR with -changes requested and no pending review requests. Existing PRs start a fresh -period when the workflow first tracks them. After 60 days it posts a reminder; +The caller defaults to `dry_run: true`. Each run reports proposed actions in +the Actions log and job summary without changing labels, posting comments, or +closing PRs. Dry-run does not create or reset timers. Review the +classifications before changing the caller to `dry_run: false`; this requires +an explicit configuration change and never happens automatically. + +Create the `awaiting-author` and `stale` labels before enabling active mode. +The first active run starts a 60-day response period when it observes a +non-draft PR with changes requested and no pending review requests. Existing PRs start a fresh +period when the workflow first adds `awaiting-author`, regardless of their age. +An existing timer label retains its timestamp. After 60 days it posts a reminder; after another 30 days without an author response it closes the PR, leaving related issues open. Authors can reopen a PR when ready to continue. diff --git a/scripts/inactive-prs.mjs b/scripts/inactive-prs.mjs index 34c09f5..68ec38b 100644 --- a/scripts/inactive-prs.mjs +++ b/scripts/inactive-prs.mjs @@ -31,18 +31,18 @@ function waiting(pr) { pr.reviewDecision === "CHANGES_REQUESTED" && pr.reviewRequests.totalCount === 0; } -export async function processPullRequest(gh, owner, repo, number, event = {}, now = Date.now()) { +export async function processPullRequest(gh, owner, repo, number, event = {}, now = Date.now(), dryRun = true) { const params = { owner, repo, issue_number: number }; const pr = await snapshot(gh, owner, repo, number); async function remove(label) { - if (pr.labels.has(label)) + if (!dryRun && pr.labels.has(label)) await optional(() => gh.rest.issues.removeLabel({ ...params, name: label })); } async function restart() { await remove(STALE); await remove(WAITING); - await gh.rest.issues.addLabels({ ...params, labels: [WAITING] }); - return "started a new 60-day author-response period"; + if (!dryRun) await gh.rest.issues.addLabels({ ...params, labels: [WAITING] }); + return dryRun ? "would start a new 60-day author-response period" : "started a new 60-day author-response period"; } async function unchanged() { const current = await snapshot(gh, owner, repo, number); @@ -51,7 +51,8 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no if (!waiting(pr)) { await remove(STALE); await remove(WAITING); - return "not waiting on the author"; + return dryRun && (pr.labels.has(STALE) || pr.labels.has(WAITING)) + ? "not waiting on the author; would remove inactivity labels" : "not waiting on the author"; } if (!pr.labels.has(WAITING) || event.action === "synchronize" || (event.action === "unlabeled" && event.label?.name === STALE)) { @@ -87,6 +88,7 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no if (pr.labels.has(STALE) && reminder) { if (now - Date.parse(reminder.created_at) < 30 * DAY) return "within the reminder period"; + if (dryRun) return "would close after the author-response and reminder periods"; await gh.rest.pulls.update({ owner, repo, pull_number: number, state: "closed" }); await remove(STALE); await remove(WAITING); @@ -98,8 +100,10 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no } if (now - startedAt < 60 * DAY) { await remove(STALE); - return "within the author-response period"; + return dryRun && pr.labels.has(STALE) + ? "within the author-response period; would remove the premature stale label" : "within the author-response period"; } + if (dryRun) return reminder ? "would restore stale; the reminder was already posted" : "would remind the author and mark stale"; // A manually added stale label cannot substitute for a dated reminder from this workflow. if (!reminder) { await gh.rest.issues.createComment({ @@ -111,7 +115,7 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no return "reminded the author and marked stale"; } -export async function run(gh, repository, eventName, event) { +export async function run(gh, repository, eventName, event, dryRun = true) { const [owner, repo] = repository.split("/"); if (!owner || !repo) throw new Error("GITHUB_REPOSITORY must identify the caller repository"); if (eventName === "issue_comment" && !event.issue?.pull_request) return; @@ -122,13 +126,21 @@ export async function run(gh, repository, eventName, event) { const numbers = number ? [number] : (await gh.paginate(gh.rest.pulls.list, { owner, repo, state: "open", per_page: 100, })).map((pr) => pr.number); + const results = []; for (const candidate of numbers) { - const result = await processPullRequest(gh, owner, repo, candidate, event); - console.log(`#${candidate}: ${result}`); + const result = await processPullRequest(gh, owner, repo, candidate, event, Date.now(), dryRun); + const line = `#${candidate}: ${result}`; + console.log(line); + results.push(line); } + return results; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const event = JSON.parse(await fs.readFile(process.env.GITHUB_EVENT_PATH, "utf8")); - await run(github(), process.env.GITHUB_REPOSITORY, process.env.GITHUB_EVENT_NAME, event); + const dryRun = process.env.DRY_RUN !== "false"; + const results = await run(github(), process.env.GITHUB_REPOSITORY, process.env.GITHUB_EVENT_NAME, event, dryRun); + if (process.env.GITHUB_STEP_SUMMARY && results?.length) + await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, + `## Inactive PRs (${dryRun ? "dry-run; no changes made" : "active"})\n\n${results.map((line) => `- ${line}`).join("\n")}\n`); } diff --git a/tests/contribution-tooling.test.mjs b/tests/contribution-tooling.test.mjs index 0118694..7c30523 100644 --- a/tests/contribution-tooling.test.mjs +++ b/tests/contribution-tooling.test.mjs @@ -46,6 +46,9 @@ test("the privileged caller uses central code and handles push and author-respon const caller = read("workflow-templates/ans-inactive-prs.yml"); const workflow = read(".github/workflows/inactive-prs.yml"); assert.equal(caller.jobs.inactive.uses, "agentnameservice/.github/.github/workflows/inactive-prs.yml@main"); + assert.equal(caller.jobs.inactive.with.dry_run, true); + assert.equal(workflow.on.workflow_call.inputs.dry_run.default, true); + assert.equal(workflow.jobs.sweep.steps.at(-1).env.DRY_RUN, "${{ inputs.dry_run }}"); assert.ok(caller.on.pull_request_target.types.includes("synchronize")); assert.ok(caller.on.pull_request_target.types.includes("review_requested")); assert.ok(caller.on.pull_request_target.types.includes("unlabeled")); diff --git a/tests/inactive-prs.test.mjs b/tests/inactive-prs.test.mjs index 7bd1d6e..5988054 100644 --- a/tests/inactive-prs.test.mjs +++ b/tests/inactive-prs.test.mjs @@ -57,7 +57,7 @@ function fixture() { return { gh, pr, labels, events, comments, reviewComments, reviews, calls, day: (day) => { now = START + day * DAY; }, - tick: (event = {}) => processPullRequest(gh, "agentnameservice", "sample", 1, event, now), + tick: (event = {}, dryRun = false) => processPullRequest(gh, "agentnameservice", "sample", 1, event, now, dryRun), reply: (collection = comments, login = "author") => { collection.push({ body: "Update", user: { login }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); stamp(); @@ -65,6 +65,53 @@ function fixture() { }; } +test("observation is the default and does not start a timer", async () => { + const f = fixture(); + const result = await processPullRequest(f.gh, "agentnameservice", "sample", 1); + assert.match(result, /would start/); + assert.equal(f.labels.size, 0); + assert.equal(f.events.length, 0); + assert.equal(f.comments.length, 0); +}); + +test("dry-run performs no writes for any inactivity transition", async () => { + const scenarios = [ + async () => {}, + async (f) => { await f.tick(); f.pr.isDraft = true; }, + async (f) => { await f.tick(); f.day(10); f.labels.add("stale"); }, + async (f) => { await f.tick(); f.day(60); }, + async (f) => { await f.tick(); f.day(60); await f.tick(); f.day(90); }, + async (f) => { await f.tick(); f.day(60); await f.tick(); f.day(70); f.reply(); }, + async (f) => { await f.tick(); f.day(60); await f.tick(); f.pr.headRefOid = "b".repeat(40); }, + ]; + for (const prepare of scenarios) { + const f = fixture(); + await prepare(f); + const forbidden = async () => { assert.fail("Dry-run attempted a write"); }; + f.gh.rest.issues.addLabels = forbidden; + f.gh.rest.issues.removeLabel = forbidden; + f.gh.rest.issues.createComment = forbidden; + f.gh.rest.pulls.update = forbidden; + assert.match(await f.tick({}, true), /would/); + } +}); + +test("a timer begins at activation after an observation period", async () => { + const f = fixture(); + await f.tick({}, true); + f.day(100); await f.tick({}, true); + assert.equal(f.labels.size, 0); + await f.tick(); + f.day(159); await f.tick(); + assert.equal(f.comments.length, 0); + f.day(160); await f.tick(); + assert.ok(f.labels.has("stale")); + f.day(189); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + f.day(190); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); +}); + test("a PR gets a full 60-day response period and 30-day reminder period", async () => { const f = fixture(); await f.tick(); diff --git a/workflow-templates/ans-inactive-prs.properties.json b/workflow-templates/ans-inactive-prs.properties.json index 2ba4f46..17e9537 100644 --- a/workflow-templates/ans-inactive-prs.properties.json +++ b/workflow-templates/ans-inactive-prs.properties.json @@ -1,5 +1,5 @@ { "name": "ANS inactive pull requests", - "description": "Remind authors after 60 days awaiting changes and close after another 30 days without a response. Requires awaiting-author and stale labels.", + "description": "Observe proposed inactivity actions first; active mode reminds after 60 days and closes after 30 more. Active mode requires awaiting-author and stale labels.", "categories": ["Automation"] } diff --git a/workflow-templates/ans-inactive-prs.yml b/workflow-templates/ans-inactive-prs.yml index 5371ea3..2d15476 100644 --- a/workflow-templates/ans-inactive-prs.yml +++ b/workflow-templates/ans-inactive-prs.yml @@ -18,3 +18,5 @@ permissions: jobs: inactive: uses: agentnameservice/.github/.github/workflows/inactive-prs.yml@main + with: + dry_run: true From 4e3f4327b068cfd863a02c243a88e41f59846def Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Fri, 30 Oct 2026 09:46:00 -0500 Subject: [PATCH 4/7] docs: align contribution guidance with conditional issue policy Signed-off-by: Connor Snitker --- CONTRIBUTING.md | 33 ++++++++++++++----- PULL_REQUEST_TEMPLATE.md | 22 ++++++------- .../ans-contribution-policy.properties.json | 2 +- 3 files changed, 36 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 82d2206..3b243f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,14 +8,31 @@ Repository-specific contribution instructions may add requirements. ## Pull requests -Open a tracking issue and reference it with a closing keyword such as `Fixes -#123`. Use a [Conventional Commits](https://www.conventionalcommits.org/) title. -Explain the change and how you tested it, and complete the PR template. For -changes in `ans-spec` or `trust-index-spec`, use exactly one of the -`editorial` or `normative` labels. - -Dependabot PRs do not need a separate tracking issue. Conventional Commit titles, -specification labels, and required review, DCO and CI checks still apply. +Use a [Conventional Commits](https://www.conventionalcommits.org/) title. +Explain the problem, change, and verification in the PR template. In `ans-spec` +and `trust-index-spec`, apply exactly one of `editorial` or `normative`. + +A separate issue or proposal is required for normative specification changes, +breaking changes or deprecations, work requiring coordination across PRs or +repositories, and new capabilities needing agreement on scope or ongoing +support. Existing governance and proposal approval requirements still apply. + +Self-contained changes may go directly to a PR: contained bug fixes, editorial +corrections, tests, behavior-preserving refactoring, routine maintenance, and +straightforward additions such as CLI convenience options or internal helpers. +The change's impact and coordination needs determine whether an issue is needed, +not its size or title prefix. + +Link existing issues when relevant. Use `Refs #123` for ongoing work and `Fixes +#123` or `Closes #123` only when the PR completes the issue. If no issue is needed, +write `No issue needed:` followed by a brief reason in the PR description. +Maintainers confirm the classification during review and may apply +`issue-required` to require a reference. + +Routine Dependabot updates need neither an issue nor an explanation. Normative, +explicitly breaking, and `issue-required` PRs still require a reference, +including Dependabot PRs. Review, DCO, CI, and private security reporting +requirements continue to apply. ## Developer Certificate of Origin diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md index 01c640a..bb81cee 100644 --- a/PULL_REQUEST_TEMPLATE.md +++ b/PULL_REQUEST_TEMPLATE.md @@ -1,21 +1,19 @@ ## Related issue - - -Fixes # + ## Summary @@ -41,5 +39,5 @@ Fixes # - [ ] The PR title follows [Conventional Commits](https://www.conventionalcommits.org) — release notes are generated from it - [ ] Tests cover the change -- [ ] The linked issue above uses a closing keyword +- [ ] I linked the relevant issue or proposal, or explained why no separate issue is needed - [ ] Every commit is signed off (`git commit -s`) certifying the [DCO](https://developercertificate.org) diff --git a/workflow-templates/ans-contribution-policy.properties.json b/workflow-templates/ans-contribution-policy.properties.json index 4086f84..8939795 100644 --- a/workflow-templates/ans-contribution-policy.properties.json +++ b/workflow-templates/ans-contribution-policy.properties.json @@ -1,5 +1,5 @@ { "name": "ANS contribution policy", - "description": "Check Conventional Commit PR titles, linked issues, and specification labels using the shared ANS policy.", + "description": "Check Conventional Commit PR titles, conditional issue references, and specification labels using the shared ANS policy.", "categories": ["Automation"] } From fba48acd42701bc59eea04d847eb9b99812e5750 Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Sun, 1 Nov 2026 05:52:29 -0600 Subject: [PATCH 5/7] fix: address shared governance tooling review Signed-off-by: Connor Snitker --- .github/ISSUE_TEMPLATE/change.yml | 2 +- .github/workflows/contribution-policy.yml | 2 +- .github/workflows/inactive-prs.yml | 6 +- CONTRIBUTING.md | 2 + README.md | 31 +++++--- .../ISSUE_TEMPLATE/nomination.yml | 4 +- .../organization}/ISSUE_TEMPLATE/proposal.yml | 4 +- .../ISSUE_TEMPLATE/spec-tracking.yml | 0 scripts/inactive-prs.mjs | 21 ++++-- tests/contribution-tooling.test.mjs | 16 ++++- tests/inactive-prs.test.mjs | 72 ++++++++++++++++--- tests/test_contribution_policy.py | 12 +++- workflow-templates/ans-inactive-prs.yml | 4 ++ 13 files changed, 135 insertions(+), 41 deletions(-) rename {.github => repo-templates/organization}/ISSUE_TEMPLATE/nomination.yml (80%) rename {.github => repo-templates/organization}/ISSUE_TEMPLATE/proposal.yml (82%) rename {.github => repo-templates/organization}/ISSUE_TEMPLATE/spec-tracking.yml (100%) diff --git a/.github/ISSUE_TEMPLATE/change.yml b/.github/ISSUE_TEMPLATE/change.yml index f93d01f..906b3de 100644 --- a/.github/ISSUE_TEMPLATE/change.yml +++ b/.github/ISSUE_TEMPLATE/change.yml @@ -1,5 +1,5 @@ name: Change request -description: Discuss a change before opening a pull request. +description: Suggest a self-contained improvement to existing behavior. body: - type: textarea id: problem diff --git a/.github/workflows/contribution-policy.yml b/.github/workflows/contribution-policy.yml index ef58806..1007af3 100644 --- a/.github/workflows/contribution-policy.yml +++ b/.github/workflows/contribution-policy.yml @@ -40,7 +40,7 @@ jobs: labels = {label['name'] for label in pr.get('labels', [])} breaking = (title and title.group('breaking')) or re.search(r'(?im)^[ \t]*BREAKING[ -]CHANGE:', body) issue_required = bool(labels & {'normative', 'issue-required'} or breaking) - has_issue = re.search(r'(?]*>|\[[^\]]*\]|\.{3}|…', reason) diff --git a/.github/workflows/inactive-prs.yml b/.github/workflows/inactive-prs.yml index 3fa4302..5f10743 100644 --- a/.github/workflows/inactive-prs.yml +++ b/.github/workflows/inactive-prs.yml @@ -14,10 +14,6 @@ permissions: issues: write pull-requests: write -concurrency: - group: ans-inactive-prs - cancel-in-progress: false - jobs: sweep: runs-on: ubuntu-latest @@ -26,7 +22,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: repository: agentnameservice/.github - ref: main + ref: ${{ job.workflow_sha }} persist-credentials: false - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3b243f0..3b7b9ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -26,6 +26,8 @@ not its size or title prefix. Link existing issues when relevant. Use `Refs #123` for ongoing work and `Fixes #123` or `Closes #123` only when the PR completes the issue. If no issue is needed, write `No issue needed:` followed by a brief reason in the PR description. +Use an explicit relationship such as `Refs #123`, or an ANS issue URL; incidental +issue numbers and upstream changelog URLs do not count as tracking references. Maintainers confirm the classification during review and may apply `issue-required` to require a reference. diff --git a/README.md b/README.md index 6caeff8..55f30ce 100644 --- a/README.md +++ b/README.md @@ -98,13 +98,15 @@ Their Maintainer team access is still managed through Peribolos. | Feature | How repositories receive it | | --- | --- | | Shared labels | Safe Settings applies `safe-settings/suborgs/defaults.yml` only to repositories in the settings scope, when organization management runs in apply mode. Labels do not inherit from `.github` on their own. | -| Issue forms | GitHub uses this repository's `.github/ISSUE_TEMPLATE/` as organization defaults for repositories without local issue forms. Local issue-template directories override that fallback; copy any shared forms they need. | +| Issue forms | Bug and change-request forms in `.github/ISSUE_TEMPLATE/` are organization defaults where repositories have no local forms. Proposal, nomination and specification-tracking forms are opt-in under `repo-templates/organization/ISSUE_TEMPLATE/`; install them only after their labels exist. Local template directories override the fallback, so copy all forms that repository needs. | | Contribution and inactivity workflows | Install a caller from `workflow-templates/` in each repository's `.github/workflows/`. Workflows are not inherited or automatically copied. Callers use the centrally maintained workflow on `main`. | | Specification release form | Copy `repo-templates/specification/ISSUE_TEMPLATE/spec-release.yml` into `ans-spec` or `trust-index-spec`, alongside the other forms that repository needs. | | GitVote | With the [GitVote App](https://github.com/apps/git-vote) installed, the root `.gitvote.yml` is an organization fallback. A repository's own configuration takes precedence. | -The proposal, specification-tracking, and nomination forms collect the evidence -and decision process for a change. Labels record its status; they do not approve +Proposal forms cover coordinated changes and decisions; the default change-request +form covers self-contained improvements. Proposal, specification-tracking, and +nomination forms collect evidence and the decision process, with optional links +to published governance rules. Labels record status; they do not approve proposals, grant team access, or enforce release acceptance. Governance-only forms and quarterly health checks belong in the governance repository. @@ -112,7 +114,9 @@ The contribution check accepts an issue reference or a `No issue needed:` explanation. `normative`, `issue-required`, and explicitly breaking PRs require a reference even for Dependabot. Create `issue-required` in repositories using that override before relying on it; the shared label configuration maintains it -once those repositories are enrolled. +once those repositories are enrolled. Repository-local contribution requirements +continue to apply until updated; align each repository's `CONTRIBUTING.md` with +the [shared contribution guide](CONTRIBUTING.md) when adopting the shared check. ### Inactive pull requests @@ -125,23 +129,28 @@ an explicit configuration change and never happens automatically. Create the `awaiting-author` and `stale` labels before enabling active mode. The first active run starts a 60-day response period when it observes a non-draft PR with changes requested and no pending review requests. Existing PRs start a fresh -period when the workflow first adds `awaiting-author`, regardless of their age. -An existing timer label retains its timestamp. After 60 days it posts a reminder; +period when the workflow adds `awaiting-author` and records the current commit +in a bot comment, regardless of their age. A missing or mismatched period marker +starts a fresh period. After 60 days it posts a reminder; after another 30 days without an author response it closes the PR, leaving related issues open. Authors can reopen a PR when ready to continue. -Pushes, author comments (including review-thread replies and review summaries), -and new change requests restart the full period. A pending review request, +Pushes are detected by comparing the current commit with the period marker, +even when the push event was missed. Pushes, author comments (including review-thread +replies and review summaries), and new change requests restart the full period. A pending review request, approval, draft conversion, or closure removes both labels. Bot comments do not restart the clock. Removing `stale` manually -restarts the period. The timer uses the server timestamp of `awaiting-author`, +restarts the period; the workflow's own label cleanup does not. The timer uses +the server timestamp of `awaiting-author`, and a close requires a reminder for the current head commit. General PR update timestamps and contributor-supplied commit dates do not determine inactivity. The caller handles trusted base-repository events, including `pull_request_target`, and runs a daily sweep. The reusable workflow checks out -only `agentnameservice/.github` at `main`; it never checks out or executes the -contributor's branch. Its token is scoped to the calling repository. +only `agentnameservice/.github` at the commit that defines the executing workflow, +using `job.workflow_sha`; it never checks out or executes the contributor's branch. +The caller queues runs in a single concurrency group. Its token is scoped to the +calling repository. ### GitVote diff --git a/.github/ISSUE_TEMPLATE/nomination.yml b/repo-templates/organization/ISSUE_TEMPLATE/nomination.yml similarity index 80% rename from .github/ISSUE_TEMPLATE/nomination.yml rename to repo-templates/organization/ISSUE_TEMPLATE/nomination.yml index cd510ef..0410594 100644 --- a/.github/ISSUE_TEMPLATE/nomination.yml +++ b/repo-templates/organization/ISSUE_TEMPLATE/nomination.yml @@ -35,6 +35,6 @@ body: id: decision attributes: label: Sponsor and decision process - description: Name the sponsor and link the applicable eligibility and approval process, including its deadline. Accepted access changes go through reviewed Peribolos configuration and CODEOWNERS updates. + description: Name the sponsor and link published eligibility and approval rules if available, including the decision deadline. Accepted access changes go through reviewed Peribolos configuration and CODEOWNERS updates. validations: - required: true + required: false diff --git a/.github/ISSUE_TEMPLATE/proposal.yml b/repo-templates/organization/ISSUE_TEMPLATE/proposal.yml similarity index 82% rename from .github/ISSUE_TEMPLATE/proposal.yml rename to repo-templates/organization/ISSUE_TEMPLATE/proposal.yml index b2c4565..4ceb63a 100644 --- a/.github/ISSUE_TEMPLATE/proposal.yml +++ b/repo-templates/organization/ISSUE_TEMPLATE/proposal.yml @@ -34,6 +34,6 @@ body: id: decision attributes: label: Decision process - description: Link the applicable governance process and identify the decision owner, review deadline, and related tracking issues. An issue label does not itself approve the proposal. + description: Link the published governance process if available, and identify the decision owner, review deadline, and related tracking issues. An issue label does not itself approve the proposal. validations: - required: true + required: false diff --git a/.github/ISSUE_TEMPLATE/spec-tracking.yml b/repo-templates/organization/ISSUE_TEMPLATE/spec-tracking.yml similarity index 100% rename from .github/ISSUE_TEMPLATE/spec-tracking.yml rename to repo-templates/organization/ISSUE_TEMPLATE/spec-tracking.yml diff --git a/scripts/inactive-prs.mjs b/scripts/inactive-prs.mjs index 68ec38b..4428be7 100644 --- a/scripts/inactive-prs.mjs +++ b/scripts/inactive-prs.mjs @@ -6,6 +6,7 @@ import { github, optional } from "./github.mjs"; const DAY = 24 * 60 * 60 * 1000; const WAITING = "awaiting-author"; const STALE = "stale"; +const STARTED = //; const REMINDER = //; async function snapshot(gh, owner, repo, number) { @@ -41,7 +42,13 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no async function restart() { await remove(STALE); await remove(WAITING); - if (!dryRun) await gh.rest.issues.addLabels({ ...params, labels: [WAITING] }); + if (!dryRun) { + await gh.rest.issues.addLabels({ ...params, labels: [WAITING] }); + await gh.rest.issues.createComment({ + ...params, + body: `Waiting for an author response to the requested changes. This starts a new 60-day response period; please push an update, reply, or re-request review when ready.\n\n`, + }); + } return dryRun ? "would start a new 60-day author-response period" : "started a new 60-day author-response period"; } async function unchanged() { @@ -55,7 +62,7 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no ? "not waiting on the author; would remove inactivity labels" : "not waiting on the author"; } if (!pr.labels.has(WAITING) || event.action === "synchronize" || - (event.action === "unlabeled" && event.label?.name === STALE)) { + (event.action === "unlabeled" && event.label?.name === STALE && event.sender?.login !== "github-actions[bot]")) { return restart(); } @@ -65,13 +72,19 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no .map((entry) => Date.parse(entry.created_at))); if (!startedAt) return "no author-response timestamp available; left unchanged"; if (events.some((entry) => entry.event === "unlabeled" && entry.label?.name === STALE && - Date.parse(entry.created_at) > startedAt)) return restart(); - const since = new Date(startedAt).toISOString(); + entry.actor?.login !== "github-actions[bot]" && Date.parse(entry.created_at) > startedAt)) return restart(); + // Label and comment timestamps can share the same second; the API filters strictly after since. + const since = new Date(startedAt - 1000).toISOString(); const [comments, reviewComments, reviews] = await Promise.all([ gh.paginate(gh.rest.issues.listComments, { ...params, since, per_page: 100 }), gh.paginate(gh.rest.pulls.listReviewComments, { owner, repo, pull_number: number, since, per_page: 100 }), gh.paginate(gh.rest.pulls.listReviews, { owner, repo, pull_number: number, per_page: 100 }), ]); + const started = comments + .filter((comment) => comment.user?.login === "github-actions[bot]" && + STARTED.test(comment.body) && Date.parse(comment.created_at) >= startedAt) + .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0]; + if (!started || started.body.match(STARTED)[1] !== pr.headRefOid) return restart(); const authorResponded = [...comments, ...reviewComments].some((comment) => comment.user?.login === pr.author?.login && Date.parse(comment.updated_at) > startedAt); const reviewActivity = reviews.some((review) => Date.parse(review.submitted_at) > startedAt && diff --git a/tests/contribution-tooling.test.mjs b/tests/contribution-tooling.test.mjs index 7c30523..54a7a1f 100644 --- a/tests/contribution-tooling.test.mjs +++ b/tests/contribution-tooling.test.mjs @@ -12,7 +12,7 @@ test("every shared form and automation label is provisioned by Safe Settings", ( assert.equal(names.size, labels.length); for (const name of ["issue-required", "awaiting-author", "stale", "proposal", "needs-proposal", "accepted", "accepted-with-changes", "declined"]) assert.ok(names.has(name), name); - for (const directory of [".github/ISSUE_TEMPLATE/", "repo-templates/specification/ISSUE_TEMPLATE/"]) { + for (const directory of [".github/ISSUE_TEMPLATE/", "repo-templates/organization/ISSUE_TEMPLATE/", "repo-templates/specification/ISSUE_TEMPLATE/"]) { for (const file of fs.readdirSync(new URL(`../${directory}`, import.meta.url))) { const form = read(directory + file); for (const name of form.labels || []) assert.ok(names.has(name), `${file}: ${name}`); @@ -23,6 +23,15 @@ test("every shared form and automation label is provisioned by Safe Settings", ( } }); +test("governance forms are opt-in and unpublished process links are optional", () => { + for (const name of ["proposal", "nomination", "spec-tracking"]) { + assert.equal(fs.existsSync(new URL(`../.github/ISSUE_TEMPLATE/${name}.yml`, import.meta.url)), false); + const form = read(`repo-templates/organization/ISSUE_TEMPLATE/${name}.yml`); + const process = form.body.find((field) => field.id === "decision" || field.id === "coordination"); + assert.notEqual(process.validations?.required, true); + } +}); + test("GitVote thresholds implement majority and two-thirds for every eligible TSC size", () => { const profiles = read(".gitvote.yml").profiles; for (const [name, required] of [ @@ -56,6 +65,7 @@ test("the privileged caller uses central code and handles push and author-respon assert.ok(caller.on.schedule.length); assert.deepEqual(caller.permissions, workflow.permissions); const checkout = workflow.jobs.sweep.steps.find((step) => step.uses?.startsWith("actions/checkout@")); - assert.deepEqual(checkout.with, { repository: "agentnameservice/.github", ref: "main", "persist-credentials": false }); - assert.equal(workflow.concurrency["cancel-in-progress"], false); + assert.deepEqual(checkout.with, { repository: "agentnameservice/.github", ref: "${{ job.workflow_sha }}", "persist-credentials": false }); + assert.deepEqual(caller.jobs.inactive.concurrency, { group: "ans-inactive-prs", queue: "max", "cancel-in-progress": false }); + assert.equal(workflow.concurrency, undefined); }); diff --git a/tests/inactive-prs.test.mjs b/tests/inactive-prs.test.mjs index 5988054..87d6da5 100644 --- a/tests/inactive-prs.test.mjs +++ b/tests/inactive-prs.test.mjs @@ -26,7 +26,7 @@ function fixture() { paginate: async (route, params) => { calls.push([route, params]); if (route === "events") return events; - if (route === "comments") return comments; + if (route === "comments") return comments.filter((comment) => !params.since || Date.parse(comment.updated_at) > Date.parse(params.since)); if (route === "reviewComments") return reviewComments; if (route === "reviews") return reviews; if (route === "pulls") return [{ number: 1 }]; @@ -42,7 +42,11 @@ function fixture() { } stamp(); }, - removeLabel: async ({ name }) => { labels.delete(name); stamp(); }, + removeLabel: async ({ name }) => { + labels.delete(name); + events.push({ event: "unlabeled", label: { name }, actor: { login: "github-actions[bot]" }, created_at: new Date(now).toISOString() }); + stamp(); + }, createComment: async ({ body }) => { comments.push({ body, user: { login: "github-actions[bot]" }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); stamp(); @@ -103,7 +107,7 @@ test("a timer begins at activation after an observation period", async () => { assert.equal(f.labels.size, 0); await f.tick(); f.day(159); await f.tick(); - assert.equal(f.comments.length, 0); + assert.equal(f.comments.length, 1); f.day(160); await f.tick(); assert.ok(f.labels.has("stale")); f.day(189); await f.tick(); @@ -116,12 +120,12 @@ test("a PR gets a full 60-day response period and 30-day reminder period", async const f = fixture(); await f.tick(); assert.deepEqual([...f.labels], ["awaiting-author"]); - assert.equal(f.comments.length, 0); + assert.equal(f.comments.length, 1); f.day(59); await f.tick(); - assert.equal(f.comments.length, 0); + assert.equal(f.comments.length, 1); f.day(60); await f.tick(); assert.ok(f.labels.has("stale")); - assert.equal(f.comments.length, 1); + assert.equal(f.comments.length, 2); f.day(89); await f.tick(); assert.equal(f.pr.state, "OPEN"); f.day(90); await f.tick(); @@ -140,7 +144,7 @@ test("drafts, closed PRs, approvals, and pending reviews clear author-wait label Object.assign(f.pr, update); f.day(100); await f.tick(); assert.equal(f.labels.size, 0); - assert.equal(f.comments.length, 1); + assert.equal(f.comments.length, 2); } }); @@ -215,6 +219,52 @@ test("a changed head cannot be closed using a warning for earlier code", async ( assert.ok(!f.labels.has("stale")); }); +test("a missed push before the reminder starts a fresh full response period", async () => { + const f = fixture(); + await f.tick(); + f.day(30); f.pr.headRefOid = "b".repeat(40); + f.day(60); await f.tick(); + assert.ok(!f.labels.has("stale")); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + f.day(120); await f.tick(); + assert.ok(f.labels.has("stale")); + f.day(149); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + f.day(150); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); +}); + +test("bot cleanup does not defer the author-response clock", async () => { + const f = fixture(); + await f.tick(); + f.day(10); f.labels.add("stale"); await f.tick(); + f.day(11); await f.tick({ action: "unlabeled", label: { name: "stale" }, sender: { login: "github-actions[bot]" } }); + f.day(60); await f.tick(); + assert.ok(f.labels.has("stale")); + f.day(90); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); +}); + +test("a missing or contributor-forged period marker starts a fresh timer", async () => { + for (const forged of [false, true]) { + const f = fixture(); + await f.tick(); + f.comments.length = 0; + if (forged) { + f.day(50); f.reply(f.comments, "contributor"); + f.comments[0].body = ``; + } + f.day(90); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + assert.ok(!f.labels.has("stale")); + f.day(149); await f.tick(); + assert.ok(!f.labels.has("stale")); + f.day(150); await f.tick(); + assert.ok(f.labels.has("stale")); + } +}); + test("label history preserves a manual deferral if its workflow event was missed", async () => { const f = fixture(); await f.tick(); f.day(60); await f.tick(); @@ -231,7 +281,7 @@ test("a manually applied stale label cannot bypass the reminder", async () => { assert.ok(!f.labels.has("stale")); f.day(90); f.labels.add("stale"); await f.tick(); assert.equal(f.pr.state, "OPEN"); - assert.equal(f.comments.length, 1); + assert.equal(f.comments.length, 2); f.day(119); await f.tick(); assert.equal(f.pr.state, "OPEN"); f.day(120); await f.tick(); @@ -242,11 +292,11 @@ test("contributor text cannot impersonate the workflow's reminder", async () => const f = fixture(); await f.tick(); f.day(60); f.reply(f.comments, "contributor"); - f.comments[0].body = ``; + f.comments.at(-1).body = ``; f.labels.add("stale"); f.day(90); await f.tick(); assert.equal(f.pr.state, "OPEN"); - assert.equal(f.comments.length, 2); + assert.equal(f.comments.length, 3); }); test("missing timer history does not guess a closure date", async () => { @@ -268,7 +318,7 @@ test("an update during inspection prevents automatic closure", async () => { }; await f.tick(); assert.equal(f.pr.state, "OPEN"); - assert.equal(f.comments.length, 1); + assert.equal(f.comments.length, 2); }); test("scheduled sweeps paginate open PRs and activity", async (t) => { diff --git a/tests/test_contribution_policy.py b/tests/test_contribution_policy.py index a9cc56f..537a9a0 100644 --- a/tests/test_contribution_policy.py +++ b/tests/test_contribution_policy.py @@ -45,6 +45,16 @@ def test_issue_reference_placeholders_do_not_pass(self): with self.subTest(body=body): self.assertNotEqual(self.run_policy(body=body).returncode, 0) + def test_incidental_numbers_and_upstream_urls_do_not_count_as_tracking(self): + for body in [ + "This supersedes PR #12.", "See step #2.", "Upstream changelog: #797.", + "https://github.com/nodeca/js-yaml/issues/797", + ]: + with self.subTest(body=body): + self.assertNotEqual(self.run_policy(body=body).returncode, 0) + result = self.run_policy(body=body, labels=["issue-required"], author="dependabot[bot]", author_type="Bot") + self.assertNotEqual(result.returncode, 0) + def test_no_issue_explanation_allows_self_contained_changes(self): for title in ["feat(cli): add JSON output", "fix: correct a bug", "docs: fix a typo"]: with self.subTest(title=title): @@ -76,7 +86,7 @@ def test_mandatory_issue_conditions_override_exemptions(self): ("feat: remove old option", [], "\n\nBREAKING-CHANGE: The old option is removed."), ]: for author, author_type in [("contributor", "User"), ("dependabot[bot]", "Bot")]: - for body, allowed in [("", False), ("No issue needed: Self-contained change.", False), ("Refs #123", True)]: + for body, allowed in [("", False), ("No issue needed: Self-contained change.", False), ("Bumps js-yaml from 4.3.1 to 4.3.2.\n\nCount empty mappings toward the limit, #797.", False), ("Refs #123", True)]: with self.subTest(title=title, labels=labels, footer=footer, author=author, body=body): result = self.run_policy(title=title, labels=labels, body=body + footer, author=author, author_type=author_type) self.assertEqual(result.returncode, 0 if allowed else 1, result.stdout) diff --git a/workflow-templates/ans-inactive-prs.yml b/workflow-templates/ans-inactive-prs.yml index 2d15476..66c7418 100644 --- a/workflow-templates/ans-inactive-prs.yml +++ b/workflow-templates/ans-inactive-prs.yml @@ -17,6 +17,10 @@ permissions: jobs: inactive: + concurrency: + group: ans-inactive-prs + queue: max + cancel-in-progress: false uses: agentnameservice/.github/.github/workflows/inactive-prs.yml@main with: dry_run: true From d9f02dac411d3479cf551115d9f3dcf53e0fd0b7 Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Sun, 1 Nov 2026 06:21:05 -0600 Subject: [PATCH 6/7] fix: publish shared forms after label provisioning Signed-off-by: Connor Snitker --- .../organization => .github}/ISSUE_TEMPLATE/nomination.yml | 0 .../organization => .github}/ISSUE_TEMPLATE/proposal.yml | 0 .../ISSUE_TEMPLATE/spec-tracking.yml | 0 README.md | 2 +- tests/contribution-tooling.test.mjs | 7 +++---- 5 files changed, 4 insertions(+), 5 deletions(-) rename {repo-templates/organization => .github}/ISSUE_TEMPLATE/nomination.yml (100%) rename {repo-templates/organization => .github}/ISSUE_TEMPLATE/proposal.yml (100%) rename {repo-templates/organization => .github}/ISSUE_TEMPLATE/spec-tracking.yml (100%) diff --git a/repo-templates/organization/ISSUE_TEMPLATE/nomination.yml b/.github/ISSUE_TEMPLATE/nomination.yml similarity index 100% rename from repo-templates/organization/ISSUE_TEMPLATE/nomination.yml rename to .github/ISSUE_TEMPLATE/nomination.yml diff --git a/repo-templates/organization/ISSUE_TEMPLATE/proposal.yml b/.github/ISSUE_TEMPLATE/proposal.yml similarity index 100% rename from repo-templates/organization/ISSUE_TEMPLATE/proposal.yml rename to .github/ISSUE_TEMPLATE/proposal.yml diff --git a/repo-templates/organization/ISSUE_TEMPLATE/spec-tracking.yml b/.github/ISSUE_TEMPLATE/spec-tracking.yml similarity index 100% rename from repo-templates/organization/ISSUE_TEMPLATE/spec-tracking.yml rename to .github/ISSUE_TEMPLATE/spec-tracking.yml diff --git a/README.md b/README.md index 55f30ce..422c0e5 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ Their Maintainer team access is still managed through Peribolos. | Feature | How repositories receive it | | --- | --- | | Shared labels | Safe Settings applies `safe-settings/suborgs/defaults.yml` only to repositories in the settings scope, when organization management runs in apply mode. Labels do not inherit from `.github` on their own. | -| Issue forms | Bug and change-request forms in `.github/ISSUE_TEMPLATE/` are organization defaults where repositories have no local forms. Proposal, nomination and specification-tracking forms are opt-in under `repo-templates/organization/ISSUE_TEMPLATE/`; install them only after their labels exist. Local template directories override the fallback, so copy all forms that repository needs. | +| Issue forms | Forms in `.github/ISSUE_TEMPLATE/` automatically become organization defaults when merged, wherever repositories have no local issue forms. Provision the labels they use before publishing them. Local template directories override the fallback, so copy all forms that repository needs. | | Contribution and inactivity workflows | Install a caller from `workflow-templates/` in each repository's `.github/workflows/`. Workflows are not inherited or automatically copied. Callers use the centrally maintained workflow on `main`. | | Specification release form | Copy `repo-templates/specification/ISSUE_TEMPLATE/spec-release.yml` into `ans-spec` or `trust-index-spec`, alongside the other forms that repository needs. | | GitVote | With the [GitVote App](https://github.com/apps/git-vote) installed, the root `.gitvote.yml` is an organization fallback. A repository's own configuration takes precedence. | diff --git a/tests/contribution-tooling.test.mjs b/tests/contribution-tooling.test.mjs index 54a7a1f..e05aa82 100644 --- a/tests/contribution-tooling.test.mjs +++ b/tests/contribution-tooling.test.mjs @@ -12,7 +12,7 @@ test("every shared form and automation label is provisioned by Safe Settings", ( assert.equal(names.size, labels.length); for (const name of ["issue-required", "awaiting-author", "stale", "proposal", "needs-proposal", "accepted", "accepted-with-changes", "declined"]) assert.ok(names.has(name), name); - for (const directory of [".github/ISSUE_TEMPLATE/", "repo-templates/organization/ISSUE_TEMPLATE/", "repo-templates/specification/ISSUE_TEMPLATE/"]) { + for (const directory of [".github/ISSUE_TEMPLATE/", "repo-templates/specification/ISSUE_TEMPLATE/"]) { for (const file of fs.readdirSync(new URL(`../${directory}`, import.meta.url))) { const form = read(directory + file); for (const name of form.labels || []) assert.ok(names.has(name), `${file}: ${name}`); @@ -23,10 +23,9 @@ test("every shared form and automation label is provisioned by Safe Settings", ( } }); -test("governance forms are opt-in and unpublished process links are optional", () => { +test("shared governance forms are org defaults and unpublished process links are optional", () => { for (const name of ["proposal", "nomination", "spec-tracking"]) { - assert.equal(fs.existsSync(new URL(`../.github/ISSUE_TEMPLATE/${name}.yml`, import.meta.url)), false); - const form = read(`repo-templates/organization/ISSUE_TEMPLATE/${name}.yml`); + const form = read(`.github/ISSUE_TEMPLATE/${name}.yml`); const process = form.body.find((field) => field.id === "decision" || field.id === "coordination"); assert.notEqual(process.validations?.required, true); } From fec26faac9b8825fadf62396f81d3a49ebdd3b9f Mon Sep 17 00:00:00 2001 From: Connor Snitker Date: Mon, 2 Nov 2026 07:41:39 -0600 Subject: [PATCH 7/7] fix: reuse the inactivity period comment Signed-off-by: Connor Snitker --- README.md | 5 ++-- scripts/inactive-prs.mjs | 24 ++++++++++------- tests/inactive-prs.test.mjs | 52 +++++++++++++++++++++++++++++++++++-- 3 files changed, 67 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 422c0e5..7fb235a 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,9 @@ Create the `awaiting-author` and `stale` labels before enabling active mode. The first active run starts a 60-day response period when it observes a non-draft PR with changes requested and no pending review requests. Existing PRs start a fresh period when the workflow adds `awaiting-author` and records the current commit -in a bot comment, regardless of their age. A missing or mismatched period marker -starts a fresh period. After 60 days it posts a reminder; +in a bot comment, regardless of their age. Restarts update that same comment; +a new comment is created only when the period marker is missing. A missing or +mismatched period marker starts a fresh period. After 60 days it posts a reminder; after another 30 days without an author response it closes the PR, leaving related issues open. Authors can reopen a PR when ready to continue. diff --git a/scripts/inactive-prs.mjs b/scripts/inactive-prs.mjs index 4428be7..3e5987d 100644 --- a/scripts/inactive-prs.mjs +++ b/scripts/inactive-prs.mjs @@ -40,16 +40,20 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no await optional(() => gh.rest.issues.removeLabel({ ...params, name: label })); } async function restart() { + if (dryRun) return "would start a new 60-day author-response period"; + const comments = await gh.paginate(gh.rest.issues.listComments, { ...params, per_page: 100 }); + const marker = comments + .filter((comment) => comment.user?.login === "github-actions[bot]" && STARTED.test(comment.body)) + .sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))[0]; await remove(STALE); await remove(WAITING); - if (!dryRun) { - await gh.rest.issues.addLabels({ ...params, labels: [WAITING] }); - await gh.rest.issues.createComment({ - ...params, - body: `Waiting for an author response to the requested changes. This starts a new 60-day response period; please push an update, reply, or re-request review when ready.\n\n`, - }); - } - return dryRun ? "would start a new 60-day author-response period" : "started a new 60-day author-response period"; + await gh.rest.issues.addLabels({ ...params, labels: [WAITING] }); + const body = `Waiting for an author response to the requested changes. This starts a new 60-day response period; please push an update, reply, or re-request review when ready.\n\n`; + if (marker) + await gh.rest.issues.updateComment({ owner, repo, comment_id: marker.id, body }); + else + await gh.rest.issues.createComment({ ...params, body }); + return "started a new 60-day author-response period"; } async function unchanged() { const current = await snapshot(gh, owner, repo, number); @@ -82,8 +86,8 @@ export async function processPullRequest(gh, owner, repo, number, event = {}, no ]); const started = comments .filter((comment) => comment.user?.login === "github-actions[bot]" && - STARTED.test(comment.body) && Date.parse(comment.created_at) >= startedAt) - .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))[0]; + STARTED.test(comment.body) && Date.parse(comment.updated_at) >= startedAt) + .sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))[0]; if (!started || started.body.match(STARTED)[1] !== pr.headRefOid) return restart(); const authorResponded = [...comments, ...reviewComments].some((comment) => comment.user?.login === pr.author?.login && Date.parse(comment.updated_at) > startedAt); diff --git a/tests/inactive-prs.test.mjs b/tests/inactive-prs.test.mjs index 87d6da5..2ac99e0 100644 --- a/tests/inactive-prs.test.mjs +++ b/tests/inactive-prs.test.mjs @@ -7,6 +7,7 @@ const DAY = 86_400_000; const START = Date.parse("2026-01-01T00:00:00Z"); function fixture() { let now = START; + let nextCommentId = 1; const labels = new Set(); const events = []; const comments = []; @@ -48,7 +49,14 @@ function fixture() { stamp(); }, createComment: async ({ body }) => { - comments.push({ body, user: { login: "github-actions[bot]" }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); + comments.push({ id: nextCommentId++, body, user: { login: "github-actions[bot]" }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); + stamp(); + }, + updateComment: async ({ comment_id, body }) => { + const comment = comments.find((entry) => entry.id === comment_id); + assert.ok(comment, `Unknown comment: ${comment_id}`); + comment.body = body; + comment.updated_at = new Date(now).toISOString(); stamp(); }, }, @@ -63,7 +71,7 @@ function fixture() { day: (day) => { now = START + day * DAY; }, tick: (event = {}, dryRun = false) => processPullRequest(gh, "agentnameservice", "sample", 1, event, now, dryRun), reply: (collection = comments, login = "author") => { - collection.push({ body: "Update", user: { login }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); + collection.push({ id: nextCommentId++, body: "Update", user: { login }, created_at: new Date(now).toISOString(), updated_at: new Date(now).toISOString() }); stamp(); }, }; @@ -95,6 +103,7 @@ test("dry-run performs no writes for any inactivity transition", async () => { f.gh.rest.issues.addLabels = forbidden; f.gh.rest.issues.removeLabel = forbidden; f.gh.rest.issues.createComment = forbidden; + f.gh.rest.issues.updateComment = forbidden; f.gh.rest.pulls.update = forbidden; assert.match(await f.tick({}, true), /would/); } @@ -166,6 +175,45 @@ test("author replies in either comment channel reset the complete period", async } }); +test("replies and pushes reuse one period comment while preserving the full deadline", async () => { + const f = fixture(); + await f.tick(); + const marker = { ...f.comments[0] }; + f.day(1); f.reply(); await f.tick(); + f.day(3); f.reply(); await f.tick(); + f.day(5); f.pr.headRefOid = "b".repeat(40); await f.tick({ action: "synchronize" }); + const markers = f.comments.filter((comment) => comment.user.login === "github-actions[bot]"); + assert.equal(markers.length, 1); + assert.equal(markers[0].id, marker.id); + assert.equal(markers[0].created_at, marker.created_at); + assert.equal(markers[0].updated_at, new Date(START + 5 * DAY).toISOString()); + assert.ok(markers[0].body.includes(`waiting head:${f.pr.headRefOid}`)); + f.day(64); await f.tick(); + assert.ok(!f.labels.has("stale")); + f.day(65); await f.tick(); + assert.ok(f.labels.has("stale")); + f.day(94); await f.tick(); + assert.equal(f.pr.state, "OPEN"); + f.day(95); await f.tick(); + assert.equal(f.pr.state, "CLOSED"); +}); + +test("a new waiting period reuses the comment from before reviewer handoff", async () => { + const f = fixture(); + await f.tick(); + const markerId = f.comments[0].id; + f.day(10); f.pr.reviewRequests.totalCount = 1; await f.tick(); + assert.equal(f.labels.size, 0); + f.day(20); f.pr.reviewRequests.totalCount = 0; await f.tick(); + assert.equal(f.comments.length, 1); + assert.equal(f.comments[0].id, markerId); + assert.equal(f.comments[0].updated_at, new Date(START + 20 * DAY).toISOString()); + f.day(79); await f.tick(); + assert.ok(!f.labels.has("stale")); + f.day(80); await f.tick(); + assert.ok(f.labels.has("stale")); +}); + test("edited author replies reset the period", async () => { const f = fixture(); f.reply(); await f.tick(); f.day(60); await f.tick();