From 50e638f8a1845150a75711a65c1411744d38c6bd Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Wed, 22 Jul 2026 20:49:05 +0530 Subject: [PATCH 1/5] feat(ci): add /assign GitHub Action with stale cleanup Implement the documented self-assignment slash command and a scheduled cleanup for auto-assigned issues with no PR activity for two weeks. Fixes #2630. --- .github/scripts/assign-issue.sh | 182 ++++++++++++++++ .github/scripts/unassign-stale-issues.sh | 234 +++++++++++++++++++++ .github/workflows/assign-stale-cleanup.yml | 45 ++++ .github/workflows/assign.yml | 57 +++++ CONTRIBUTING.md | 10 +- scripts/tests/assign-workflow.test.js | 157 ++++++++++++++ 6 files changed, 683 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/assign-issue.sh create mode 100755 .github/scripts/unassign-stale-issues.sh create mode 100644 .github/workflows/assign-stale-cleanup.yml create mode 100644 .github/workflows/assign.yml create mode 100644 scripts/tests/assign-workflow.test.js diff --git a/.github/scripts/assign-issue.sh b/.github/scripts/assign-issue.sh new file mode 100755 index 0000000000..516a8dae7a --- /dev/null +++ b/.github/scripts/assign-issue.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +# Assign an issue to a commenter who posted an exact `/assign` command. +# Invoked by .github/workflows/assign.yml. +set -euo pipefail + +MARKER='' +AUTO_ASSIGNED_LABEL='auto-assigned' +MAX_ASSIGNMENTS=3 +TRUSTED_FILE='.github/trusted-contributors.txt' + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "‼️ Missing \$${name} - this must be run from GitHub Actions" >&2 + exit 1 + fi +} + +require_env GITHUB_REPOSITORY +require_env ISSUE_NUMBER +require_env COMMENTER_LOGIN + +# Prefer GH_TOKEN when both are set (gh CLI convention). +if [[ -n "${GH_TOKEN:-}" ]]; then + export GH_TOKEN +elif [[ -n "${GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${GITHUB_TOKEN}" +else + echo "‼️ Missing \$GH_TOKEN / \$GITHUB_TOKEN" >&2 + exit 1 +fi + +USER_LOGIN="${COMMENTER_LOGIN}" +ISSUE_NUMBER="${ISSUE_NUMBER}" +REPO="${GITHUB_REPOSITORY}" + +post_sticky_feedback() { + local message="$1" + local body + body="$(printf '%s\n%s\n' "${MARKER}" "${message}")" + + local existing_id="" + existing_id="$( + gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" --paginate \ + --jq ".[] | select(.body != null and (.body | contains(\"${MARKER}\"))) | .id" 2>/dev/null \ + | head -n 1 || true + )" + + if [[ -n "${existing_id}" ]]; then + if ! gh api --method PATCH "repos/${REPO}/issues/comments/${existing_id}" \ + -f body="${body}" >/dev/null 2>&1; then + echo " ⚠️ Failed to update sticky feedback comment" >&2 + fi + else + if ! gh issue comment "${ISSUE_NUMBER}" --repo "${REPO}" --body "${body}" >/dev/null 2>&1; then + echo " ⚠️ Failed to post sticky feedback comment" >&2 + fi + fi +} + +echo "🔄 Processing /assign for issue #${ISSUE_NUMBER} by @${USER_LOGIN}" + +# --- Guard: already assigned --- +assignee_count="$( + gh issue view "${ISSUE_NUMBER}" --repo "${REPO}" --json assignees \ + --jq '.assignees | length' 2>/dev/null || echo "0" +)" + +if [[ "${assignee_count}" -gt 0 ]]; then + current="$( + gh issue view "${ISSUE_NUMBER}" --repo "${REPO}" --json assignees \ + --jq '[.assignees[].login] | join(", ")' 2>/dev/null || echo "someone" + )" + echo "⚠️ Issue #${ISSUE_NUMBER} is already assigned to: ${current}" + post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: this issue is already assigned to **${current}**." + exit 0 +fi + +# --- Cap: max concurrent open assignments --- +open_assigned_count="$( + gh search issues --repo "${REPO}" --assignee "${USER_LOGIN}" --state open \ + --json number --jq 'length' 2>/dev/null || echo "0" +)" + +if [[ "${open_assigned_count}" -ge "${MAX_ASSIGNMENTS}" ]]; then + echo "⚠️ @${USER_LOGIN} already has ${open_assigned_count} open assigned issues (max ${MAX_ASSIGNMENTS})" + post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: you already have **${open_assigned_count}** open assigned issues (maximum is **${MAX_ASSIGNMENTS}** per CONTRIBUTING.md)." + exit 0 +fi + +# --- Eligibility --- +is_eligible=false +eligibility_reason="" + +merged_pr_count="$( + gh search prs --repo "${REPO}" --author "${USER_LOGIN}" --merged \ + --json number --jq 'length' 2>/dev/null || echo "0" +)" + +if [[ "${merged_pr_count}" -gt 0 ]]; then + is_eligible=true + eligibility_reason="prior merged PR(s)" +fi + +if [[ "${is_eligible}" != "true" ]]; then + prior_assigned_count="$( + gh search issues --repo "${REPO}" --assignee "${USER_LOGIN}" --state all \ + --json number --jq 'length' 2>/dev/null || echo "0" + )" + if [[ "${prior_assigned_count}" -gt 0 ]]; then + is_eligible=true + eligibility_reason="prior issue assignment" + fi +fi + +if [[ "${is_eligible}" != "true" && -f "${TRUSTED_FILE}" ]]; then + if grep -Fqx "${USER_LOGIN}" "${TRUSTED_FILE}"; then + is_eligible=true + eligibility_reason="trusted contributor list" + fi +fi + +# Owners/members/collaborators are always eligible (author_association not +# always available here; treat repository permission via association env). +AUTHOR_ASSOCIATION="${AUTHOR_ASSOCIATION:-}" +case "${AUTHOR_ASSOCIATION}" in + OWNER | MEMBER | COLLABORATOR) + is_eligible=true + eligibility_reason="repository ${AUTHOR_ASSOCIATION}" + ;; +esac + +if [[ "${is_eligible}" != "true" ]]; then + echo "⚠️ @${USER_LOGIN} is not eligible for self-assignment" + post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: eligibility requires at least one merged PR in this repository, a prior issue assignment, or membership in \`.github/trusted-contributors.txt\`. Please open a PR first or ask a maintainer to assign you." + exit 0 +fi + +echo "✅ Eligible via: ${eligibility_reason}" + +# --- Assign --- +if ! gh issue edit "${ISSUE_NUMBER}" --repo "${REPO}" --add-assignee "${USER_LOGIN}" >/dev/null 2>&1; then + echo "⚠️ gh issue edit --add-assignee failed for @${USER_LOGIN}" +fi + +# Verify assignment took effect (GitHub silently ignores non-collaborators). +assigned_logins="$( + gh issue view "${ISSUE_NUMBER}" --repo "${REPO}" --json assignees \ + --jq '[.assignees[].login] | join(",")' 2>/dev/null || echo "" +)" + +IFS=',' read -ra ASSIGNED_ARRAY <<<"${assigned_logins}" +is_assigned=false +for login in "${ASSIGNED_ARRAY[@]}"; do + if [[ "${login}" == "${USER_LOGIN}" ]]; then + is_assigned=true + break + fi +done + +if [[ "${is_assigned}" != "true" ]]; then + echo "❌ Assignment did not take effect for @${USER_LOGIN} (likely lacks write access)" + post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: GitHub only allows assigning users with write access to the repository. A maintainer may need to add you as a collaborator or assign the issue manually. Eligibility check passed (${eligibility_reason})." + exit 0 +fi + +# --- Tracking label --- +gh label create "${AUTO_ASSIGNED_LABEL}" \ + --repo "${REPO}" \ + --color "0E8A16" \ + --description "Assigned via /assign automation" \ + >/dev/null 2>&1 || true + +if ! gh issue edit "${ISSUE_NUMBER}" --repo "${REPO}" --add-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1; then + echo " ⚠️ Failed to add ${AUTO_ASSIGNED_LABEL} label" >&2 +fi + +echo "✅ Assigned @${USER_LOGIN} to issue #${ISSUE_NUMBER}" +post_sticky_feedback "✅ Assigned @${USER_LOGIN} to this issue via \`/assign\` (${eligibility_reason}). + +Please open a linked PR within **2 weeks**, or the automation may unassign stale auto-assignments (maintainers are exempt)." +exit 0 diff --git a/.github/scripts/unassign-stale-issues.sh b/.github/scripts/unassign-stale-issues.sh new file mode 100755 index 0000000000..b7cca21acb --- /dev/null +++ b/.github/scripts/unassign-stale-issues.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +# Unassign stale auto-assigned issues with no linked PR activity for 2 weeks. +# Invoked by .github/workflows/assign-stale-cleanup.yml. +# Never unassigns acoliver. +set -euo pipefail + +AUTO_ASSIGNED_LABEL='auto-assigned' +STALE_DAYS=14 +EXEMPT_LOGIN='acoliver' + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "‼️ Missing \$${name} - this must be run from GitHub Actions" >&2 + exit 1 + fi +} + +require_env GITHUB_REPOSITORY + +if [[ -n "${GH_TOKEN:-}" ]]; then + export GH_TOKEN +elif [[ -n "${GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${GITHUB_TOKEN}" +else + echo "‼️ Missing \$GH_TOKEN / \$GITHUB_TOKEN" >&2 + exit 1 +fi + +REPO="${GITHUB_REPOSITORY}" + +retry_gh() { + local attempt + for attempt in 1 2 3 4; do + if "$@"; then + return 0 + fi + if [[ "${attempt}" -lt 4 ]]; then + echo "Attempt ${attempt} failed, retrying: $*" >&2 + sleep 5 + fi + done + echo "All retries exhausted for: $*" >&2 + return 1 +} + +# ISO-8601 UTC timestamp STALE_DAYS ago (GNU date on ubuntu-latest; BSD fallback). +threshold_iso() { + if date -u -d "${STALE_DAYS} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null; then + return 0 + fi + date -u -v-"${STALE_DAYS}"d +%Y-%m-%dT%H:%M:%SZ +} + +iso_to_epoch() { + local iso="$1" + # Normalize fractional seconds / Z for date parsing. + local normalized="${iso%Z}" + normalized="${normalized%%.*}" + if date -u -d "${normalized}Z" +%s 2>/dev/null; then + return 0 + fi + date -u -j -f "%Y-%m-%dT%H:%M:%S" "${normalized}" +%s 2>/dev/null +} + +assignment_age_ok_to_unassign() { + local issue_number="$1" + local assignee="$2" + local threshold_epoch="$3" + + # Prefer the most recent "assigned" timeline event for this assignee. + local assigned_at="" + assigned_at="$( + gh api "repos/${REPO}/issues/${issue_number}/timeline" --paginate \ + --jq "[.[] | select(.event == \"assigned\" and .assignee.login == \"${assignee}\") | .created_at] | last // empty" \ + 2>/dev/null || true + )" + + if [[ -z "${assigned_at}" ]]; then + # Fall back to issue updatedAt when timeline is unavailable. + assigned_at="$( + gh issue view "${issue_number}" --repo "${REPO}" --json updatedAt \ + --jq '.updatedAt' 2>/dev/null || true + )" + fi + + if [[ -z "${assigned_at}" ]]; then + echo " ⚠️ Could not determine assignment time for #${issue_number}; skipping" >&2 + return 1 + fi + + local assigned_epoch + assigned_epoch="$(iso_to_epoch "${assigned_at}" || true)" + if [[ -z "${assigned_epoch}" ]]; then + echo " ⚠️ Could not parse assignment time '${assigned_at}' for #${issue_number}; skipping" >&2 + return 1 + fi + + if [[ "${assigned_epoch}" -gt "${threshold_epoch}" ]]; then + echo " ⏳ Assignment for #${issue_number} is newer than ${STALE_DAYS} days; keeping" + return 1 + fi + return 0 +} + +has_linked_pr_activity() { + local issue_number="$1" + local assignee="$2" + + # Any PR by the assignee that mentions this issue number in title/body. + local pr_hits + pr_hits="$( + gh search prs \ + --repo "${REPO}" \ + --author "${assignee}" \ + --json number \ + --jq 'length' \ + "#${issue_number} in:title,body" \ + 2>/dev/null || echo "0" + )" + + if [[ "${pr_hits}" == "0" || -z "${pr_hits}" ]]; then + pr_hits="$( + gh pr list --repo "${REPO}" --author "${assignee}" --state all --limit 50 \ + --json number,title,body \ + --jq "[.[] | select((.title // \"\" | contains(\"#${issue_number}\")) or (.body // \"\" | test(\"(?i)(closes?|fixes?|resolves?)?\\s*#${issue_number}\\b\")))] | length" \ + 2>/dev/null || echo "0" + )" + fi + + [[ "${pr_hits}" =~ ^[0-9]+$ ]] && [[ "${pr_hits}" -gt 0 ]] +} + +process_issue() { + local issue_number="$1" + local assignees_csv="$2" + local threshold_epoch="$3" + + echo "🔄 Checking auto-assigned issue #${issue_number} (assignees: ${assignees_csv})" + + if [[ -z "${assignees_csv}" ]]; then + echo " No assignees; removing stale ${AUTO_ASSIGNED_LABEL} label" + retry_gh gh issue edit "${issue_number}" --repo "${REPO}" \ + --remove-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1 || true + return 0 + fi + + IFS=',' read -ra ASSIGNEES <<<"${assignees_csv}" + + # Exempt: never unassign acoliver (ticket exception). + for login in "${ASSIGNEES[@]}"; do + if [[ "${login}" == "${EXEMPT_LOGIN}" ]]; then + echo " 🛡️ Skipping #${issue_number}: assignee @${EXEMPT_LOGIN} is exempt" + return 0 + fi + done + + local keep=false + local assignee + for assignee in "${ASSIGNEES[@]}"; do + [[ -z "${assignee}" ]] && continue + + if ! assignment_age_ok_to_unassign "${issue_number}" "${assignee}" "${threshold_epoch}"; then + keep=true + continue + fi + + if has_linked_pr_activity "${issue_number}" "${assignee}"; then + echo " ✅ @${assignee} has linked PR activity for #${issue_number}; keeping" + keep=true + continue + fi + + echo " 🧹 Unassigning stale @${assignee} from #${issue_number}" + if retry_gh gh issue edit "${issue_number}" --repo "${REPO}" \ + --remove-assignee "${assignee}" >/dev/null 2>&1; then + retry_gh gh issue comment "${issue_number}" --repo "${REPO}" --body "$(cat </dev/null 2>&1 || true + else + echo " ⚠️ Failed to unassign @${assignee} from #${issue_number}" >&2 + keep=true + fi + done + + if [[ "${keep}" != "true" ]]; then + # Re-check assignees after removals. + local remaining + remaining="$( + gh issue view "${issue_number}" --repo "${REPO}" --json assignees \ + --jq '.assignees | length' 2>/dev/null || echo "1" + )" + if [[ "${remaining}" -eq 0 ]]; then + retry_gh gh issue edit "${issue_number}" --repo "${REPO}" \ + --remove-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1 || true + fi + fi +} + +echo "📥 Scanning open issues with label:${AUTO_ASSIGNED_LABEL}" + +THRESHOLD_ISO="$(threshold_iso)" +THRESHOLD_EPOCH="$(iso_to_epoch "${THRESHOLD_ISO}")" +echo " Stale threshold: ${THRESHOLD_ISO} (epoch ${THRESHOLD_EPOCH})" + +# List candidates: open + auto-assigned label. +mapfile -t CANDIDATES < <( + gh issue list --repo "${REPO}" --state open --label "${AUTO_ASSIGNED_LABEL}" \ + --limit 200 \ + --json number,assignees \ + --jq '.[] | "\(.number)\t\([.assignees[].login] | join(","))"' \ + 2>/dev/null || true +) + +if [[ "${#CANDIDATES[@]}" -eq 0 ]]; then + echo "✅ No auto-assigned open issues found" + exit 0 +fi + +for row in "${CANDIDATES[@]}"; do + [[ -z "${row}" ]] && continue + issue_number="${row%%$'\t'*}" + assignees_csv="${row#*$'\t'}" + # Isolate per-issue failures so one bad issue does not abort the run. + if ! process_issue "${issue_number}" "${assignees_csv}" "${THRESHOLD_EPOCH}"; then + echo " ⚠️ Error processing #${issue_number}; continuing" >&2 + fi +done + +echo "✅ Stale auto-assignment cleanup finished" +exit 0 diff --git a/.github/workflows/assign-stale-cleanup.yml b/.github/workflows/assign-stale-cleanup.yml new file mode 100644 index 0000000000..203011b8bf --- /dev/null +++ b/.github/workflows/assign-stale-cleanup.yml @@ -0,0 +1,45 @@ +name: Assign Stale Cleanup + +# Unassigns issues that were auto-assigned via /assign but have had no linked +# PR activity for two weeks. Never unassigns acoliver. + +on: + schedule: + # Daily at 07:00 UTC + - cron: '0 7 * * *' + workflow_dispatch: {} + +permissions: + contents: read + issues: write + +defaults: + run: + shell: bash + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + cleanup: + name: Unassign stale auto-assignments + runs-on: ubuntu-latest + timeout-minutes: 15 + # Restrict scheduled runs to the canonical upstream repository. + if: ${{ github.repository == 'vybestack/llxprt-code' }} + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run unassign-stale-issues script + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + chmod +x ./.github/scripts/unassign-stale-issues.sh + ./.github/scripts/unassign-stale-issues.sh diff --git a/.github/workflows/assign.yml b/.github/workflows/assign.yml new file mode 100644 index 0000000000..b3d04b5016 --- /dev/null +++ b/.github/workflows/assign.yml @@ -0,0 +1,57 @@ +name: Assign Issue + +# Implements the CONTRIBUTING.md `/assign` self-assignment convention. +# Comment body must be exactly `/assign` (optional trailing LF/CRLF/tab only). + +on: + issue_comment: + types: + - created + +permissions: + contents: read + issues: write + +defaults: + run: + shell: bash + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + +jobs: + assign: + name: Process /assign + runs-on: ubuntu-latest + timeout-minutes: 10 + # Exact `/assign` only (CONTRIBUTING.md). Support LF, CRLF, and trailing tab + # via toJSON equality (not startsWith) so `/assign foo` never matches. + # Ignore PR review comments — this workflow is for issues only. + if: | + github.event.issue.pull_request == null && + (github.event.comment.body == '/assign' || + toJSON(github.event.comment.body) == '"/assign\n"' || + toJSON(github.event.comment.body) == '"/assign\r\n"' || + toJSON(github.event.comment.body) == '"/assign\t"') + concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Run assign-issue script + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + COMMENTER_LOGIN: ${{ github.event.comment.user.login }} + AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} + run: | + set -euo pipefail + chmod +x ./.github/scripts/assign-issue.sh + ./.github/scripts/assign-issue.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 76567060f6..b9fdb7b1a5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -38,9 +38,15 @@ for this purpose. ### Self Assigning Issues -To assign an issue to yourself, simply add a comment with the text `/assign`. The comment must contain only that text and nothing else. This command will assign the issue to you, provided it is not already assigned. +To assign an issue to yourself, add a comment that contains **only** the text `/assign` (nothing else). A GitHub Action handles the request when: -Please note that you can have a maximum of 3 issues assigned to you at any given time. +1. The issue is not already assigned. +2. You are eligible: you have at least one **merged PR** in this repository, **or** you have previously been assigned an issue here, **or** you are listed in [`.github/trusted-contributors.txt`](.github/trusted-contributors.txt), **or** you are a repository owner/member/collaborator. +3. You currently have fewer than **3** open issues assigned to you. + +On success the issue is labeled `auto-assigned`. If GitHub cannot assign you (for example, you do not have write access), the action posts feedback explaining the outcome so a maintainer can help. + +Auto-assignments with **no linked PR activity for 2 weeks** may be unassigned automatically (maintainer `acoliver` is exempt). Comment `/assign` again if you still intend to work on the issue. ### Pull Request Guidelines diff --git a/scripts/tests/assign-workflow.test.js b/scripts/tests/assign-workflow.test.js new file mode 100644 index 0000000000..da627a2ecc --- /dev/null +++ b/scripts/tests/assign-workflow.test.js @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import yaml from 'js-yaml'; +import * as fs from 'fs'; +import * as path from 'path'; +import { normalize, readRootFile } from './ocr-review-workflow-helpers.js'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); + +function loadWorkflow(relPath) { + const source = readRootFile(relPath); + const parsed = yaml.load(source); + if (!parsed || typeof parsed !== 'object') { + throw new Error(`${relPath} did not parse to a YAML object`); + } + return { source, workflow: parsed }; +} + +describe('.github/workflows/assign.yml', () => { + const { source, workflow } = loadWorkflow('.github/workflows/assign.yml'); + const job = workflow.jobs?.assign; + + it('triggers on issue_comment created only', () => { + expect(workflow.on?.issue_comment?.types).toEqual(['created']); + expect(workflow.on?.pull_request).toBeUndefined(); + }); + + it('uses least-privilege permissions', () => { + expect(workflow.permissions).toEqual({ + contents: 'read', + issues: 'write', + }); + }); + + it('gates on exact /assign for issues (not PRs)', () => { + expect(job, 'assign job should exist').toBeTruthy(); + const condition = normalize(job.if); + expect(condition).toContain('github.event.issue.pull_request == null'); + expect(condition).toContain("github.event.comment.body == '/assign'"); + expect(condition).toContain( + `toJSON(github.event.comment.body) == '"/assign\\n"'`, + ); + expect(condition).toContain( + `toJSON(github.event.comment.body) == '"/assign\\r\\n"'`, + ); + // Must not use startsWith, which would accept `/assign foo`. + expect(source).not.toMatch( + /startsWith\(toJSON\(github\.event\.comment\.body\)/, + ); + }); + + it('scopes concurrency by workflow and issue number', () => { + expect(job.concurrency?.['cancel-in-progress']).toBe(true); + expect(normalize(job.concurrency?.group)).toBe( + '${{ github.workflow }}-${{ github.event.issue.number }}', + ); + }); + + it('checks out with the ratchet-pinned checkout action and runs assign-issue.sh', () => { + const checkout = job.steps?.find((s) => s.name === 'Checkout repository'); + expect(checkout?.uses).toBe( + 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8', + ); + expect(source).toContain( + 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5', + ); + + const runStep = job.steps?.find( + (s) => s.name === 'Run assign-issue script', + ); + expect(runStep?.run).toContain('./.github/scripts/assign-issue.sh'); + expect(runStep?.env?.ISSUE_NUMBER).toBe('${{ github.event.issue.number }}'); + expect(runStep?.env?.COMMENTER_LOGIN).toBe( + '${{ github.event.comment.user.login }}', + ); + expect(runStep?.env?.GH_TOKEN).toBe('${{ github.token }}'); + }); +}); + +describe('.github/workflows/assign-stale-cleanup.yml', () => { + const { workflow } = loadWorkflow( + '.github/workflows/assign-stale-cleanup.yml', + ); + const job = workflow.jobs?.cleanup; + + it('runs on a daily schedule and workflow_dispatch', () => { + expect(workflow.on?.schedule?.[0]?.cron).toBe('0 7 * * *'); + expect(workflow.on?.workflow_dispatch).toBeDefined(); + }); + + it('guards scheduled runs to the canonical upstream repository', () => { + expect(normalize(job?.if)).toContain( + "github.repository == 'vybestack/llxprt-code'", + ); + // Do not copy the historical typo from llxprt-scheduled-pr-triage.yml. + expect(normalize(job?.if)).not.toContain('llpxrt-code'); + }); + + it('uses least-privilege permissions and runs the cleanup script', () => { + expect(workflow.permissions).toEqual({ + contents: 'read', + issues: 'write', + }); + const runStep = job.steps?.find( + (s) => s.name === 'Run unassign-stale-issues script', + ); + expect(runStep?.run).toContain( + './.github/scripts/unassign-stale-issues.sh', + ); + }); +}); + +describe('.github/scripts assign automation', () => { + it('assign-issue.sh enforces eligibility, cap, label, and sticky feedback', () => { + const script = fs.readFileSync( + path.join(ROOT, '.github/scripts/assign-issue.sh'), + 'utf8', + ); + expect(script).toContain("MARKER=''"); + expect(script).toContain("AUTO_ASSIGNED_LABEL='auto-assigned'"); + expect(script).toContain('MAX_ASSIGNMENTS=3'); + expect(script).toContain('trusted-contributors.txt'); + expect(script).toContain('gh search prs'); + expect(script).toContain('--merged'); + expect(script).toContain('--add-assignee'); + expect(script).toContain('post_sticky_feedback'); + }); + + it('unassign-stale-issues.sh uses a 14-day window and exempts acoliver', () => { + const script = fs.readFileSync( + path.join(ROOT, '.github/scripts/unassign-stale-issues.sh'), + 'utf8', + ); + expect(script).toContain('STALE_DAYS=14'); + expect(script).toContain("EXEMPT_LOGIN='acoliver'"); + expect(script).toContain("AUTO_ASSIGNED_LABEL='auto-assigned'"); + expect(script).toContain('retry_gh'); + expect(script).toContain('--remove-assignee'); + }); +}); + +describe('CONTRIBUTING.md self-assign docs', () => { + it('documents /assign eligibility, cap, and stale cleanup', () => { + const docs = readRootFile('CONTRIBUTING.md'); + expect(docs).toContain('/assign'); + expect(docs).toContain('merged PR'); + expect(docs).toContain('trusted-contributors.txt'); + expect(docs).toContain('3'); + expect(docs).toContain('auto-assigned'); + expect(docs).toContain('2 weeks'); + }); +}); From 4ed9974285d35dc1b9f4cbb01f90b715e3362db8 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Thu, 23 Jul 2026 14:30:17 +0530 Subject: [PATCH 2/5] fix(ci): harden /assign against shellcheck and spam floods Satisfy shellcheck, add pull-requests:read, reject Bot actors, serialize per-actor concurrency, keep the 3-issue cap, and unassign stale co-assignees without skipping whole issues for acoliver. --- .github/scripts/assign-issue.sh | 51 ++++++++++------------ .github/scripts/unassign-stale-issues.sh | 38 +++++----------- .github/workflows/assign-stale-cleanup.yml | 1 + .github/workflows/assign.yml | 9 +++- CONTRIBUTING.md | 6 +-- scripts/tests/assign-workflow.test.js | 11 +++-- 6 files changed, 50 insertions(+), 66 deletions(-) diff --git a/.github/scripts/assign-issue.sh b/.github/scripts/assign-issue.sh index 516a8dae7a..0bc1136184 100755 --- a/.github/scripts/assign-issue.sh +++ b/.github/scripts/assign-issue.sh @@ -8,19 +8,14 @@ AUTO_ASSIGNED_LABEL='auto-assigned' MAX_ASSIGNMENTS=3 TRUSTED_FILE='.github/trusted-contributors.txt' -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "‼️ Missing \$${name} - this must be run from GitHub Actions" >&2 - exit 1 - fi -} - -require_env GITHUB_REPOSITORY -require_env ISSUE_NUMBER -require_env COMMENTER_LOGIN +# Inputs are provided by GitHub Actions; declare them for shellcheck. +# shellcheck disable=SC2154 +: "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" +# shellcheck disable=SC2154 +: "${ISSUE_NUMBER:?Missing ISSUE_NUMBER}" +# shellcheck disable=SC2154 +: "${COMMENTER_LOGIN:?Missing COMMENTER_LOGIN}" -# Prefer GH_TOKEN when both are set (gh CLI convention). if [[ -n "${GH_TOKEN:-}" ]]; then export GH_TOKEN elif [[ -n "${GITHUB_TOKEN:-}" ]]; then @@ -31,8 +26,9 @@ else fi USER_LOGIN="${COMMENTER_LOGIN}" -ISSUE_NUMBER="${ISSUE_NUMBER}" +ISSUE="${ISSUE_NUMBER}" REPO="${GITHUB_REPOSITORY}" +AUTHOR_ASSOCIATION="${AUTHOR_ASSOCIATION:-}" post_sticky_feedback() { local message="$1" @@ -41,7 +37,7 @@ post_sticky_feedback() { local existing_id="" existing_id="$( - gh api "repos/${REPO}/issues/${ISSUE_NUMBER}/comments" --paginate \ + gh api "repos/${REPO}/issues/${ISSUE}/comments" --paginate \ --jq ".[] | select(.body != null and (.body | contains(\"${MARKER}\"))) | .id" 2>/dev/null \ | head -n 1 || true )" @@ -52,31 +48,31 @@ post_sticky_feedback() { echo " ⚠️ Failed to update sticky feedback comment" >&2 fi else - if ! gh issue comment "${ISSUE_NUMBER}" --repo "${REPO}" --body "${body}" >/dev/null 2>&1; then + if ! gh issue comment "${ISSUE}" --repo "${REPO}" --body "${body}" >/dev/null 2>&1; then echo " ⚠️ Failed to post sticky feedback comment" >&2 fi fi } -echo "🔄 Processing /assign for issue #${ISSUE_NUMBER} by @${USER_LOGIN}" +echo "🔄 Processing /assign for issue #${ISSUE} by @${USER_LOGIN}" # --- Guard: already assigned --- assignee_count="$( - gh issue view "${ISSUE_NUMBER}" --repo "${REPO}" --json assignees \ + gh issue view "${ISSUE}" --repo "${REPO}" --json assignees \ --jq '.assignees | length' 2>/dev/null || echo "0" )" if [[ "${assignee_count}" -gt 0 ]]; then current="$( - gh issue view "${ISSUE_NUMBER}" --repo "${REPO}" --json assignees \ + gh issue view "${ISSUE}" --repo "${REPO}" --json assignees \ --jq '[.assignees[].login] | join(", ")' 2>/dev/null || echo "someone" )" - echo "⚠️ Issue #${ISSUE_NUMBER} is already assigned to: ${current}" + echo "⚠️ Issue #${ISSUE} is already assigned to: ${current}" post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: this issue is already assigned to **${current}**." exit 0 fi -# --- Cap: max concurrent open assignments --- +# --- Cap: max concurrent open assignments (spam / concurrency guard) --- open_assigned_count="$( gh search issues --repo "${REPO}" --assignee "${USER_LOGIN}" --state open \ --json number --jq 'length' 2>/dev/null || echo "0" @@ -120,14 +116,13 @@ if [[ "${is_eligible}" != "true" && -f "${TRUSTED_FILE}" ]]; then fi fi -# Owners/members/collaborators are always eligible (author_association not -# always available here; treat repository permission via association env). -AUTHOR_ASSOCIATION="${AUTHOR_ASSOCIATION:-}" case "${AUTHOR_ASSOCIATION}" in OWNER | MEMBER | COLLABORATOR) is_eligible=true eligibility_reason="repository ${AUTHOR_ASSOCIATION}" ;; + *) + ;; esac if [[ "${is_eligible}" != "true" ]]; then @@ -139,13 +134,12 @@ fi echo "✅ Eligible via: ${eligibility_reason}" # --- Assign --- -if ! gh issue edit "${ISSUE_NUMBER}" --repo "${REPO}" --add-assignee "${USER_LOGIN}" >/dev/null 2>&1; then +if ! gh issue edit "${ISSUE}" --repo "${REPO}" --add-assignee "${USER_LOGIN}" >/dev/null 2>&1; then echo "⚠️ gh issue edit --add-assignee failed for @${USER_LOGIN}" fi -# Verify assignment took effect (GitHub silently ignores non-collaborators). assigned_logins="$( - gh issue view "${ISSUE_NUMBER}" --repo "${REPO}" --json assignees \ + gh issue view "${ISSUE}" --repo "${REPO}" --json assignees \ --jq '[.assignees[].login] | join(",")' 2>/dev/null || echo "" )" @@ -164,18 +158,17 @@ if [[ "${is_assigned}" != "true" ]]; then exit 0 fi -# --- Tracking label --- gh label create "${AUTO_ASSIGNED_LABEL}" \ --repo "${REPO}" \ --color "0E8A16" \ --description "Assigned via /assign automation" \ >/dev/null 2>&1 || true -if ! gh issue edit "${ISSUE_NUMBER}" --repo "${REPO}" --add-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1; then +if ! gh issue edit "${ISSUE}" --repo "${REPO}" --add-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1; then echo " ⚠️ Failed to add ${AUTO_ASSIGNED_LABEL} label" >&2 fi -echo "✅ Assigned @${USER_LOGIN} to issue #${ISSUE_NUMBER}" +echo "✅ Assigned @${USER_LOGIN} to issue #${ISSUE}" post_sticky_feedback "✅ Assigned @${USER_LOGIN} to this issue via \`/assign\` (${eligibility_reason}). Please open a linked PR within **2 weeks**, or the automation may unassign stale auto-assignments (maintainers are exempt)." diff --git a/.github/scripts/unassign-stale-issues.sh b/.github/scripts/unassign-stale-issues.sh index b7cca21acb..37da5a99fc 100755 --- a/.github/scripts/unassign-stale-issues.sh +++ b/.github/scripts/unassign-stale-issues.sh @@ -8,15 +8,8 @@ AUTO_ASSIGNED_LABEL='auto-assigned' STALE_DAYS=14 EXEMPT_LOGIN='acoliver' -require_env() { - local name="$1" - if [[ -z "${!name:-}" ]]; then - echo "‼️ Missing \$${name} - this must be run from GitHub Actions" >&2 - exit 1 - fi -} - -require_env GITHUB_REPOSITORY +# shellcheck disable=SC2154 +: "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" if [[ -n "${GH_TOKEN:-}" ]]; then export GH_TOKEN @@ -107,7 +100,7 @@ has_linked_pr_activity() { local issue_number="$1" local assignee="$2" - # Any PR by the assignee that mentions this issue number in title/body. + # Prefer search API (unbounded) over a limited pr list. local pr_hits pr_hits="$( gh search prs \ @@ -119,15 +112,6 @@ has_linked_pr_activity() { 2>/dev/null || echo "0" )" - if [[ "${pr_hits}" == "0" || -z "${pr_hits}" ]]; then - pr_hits="$( - gh pr list --repo "${REPO}" --author "${assignee}" --state all --limit 50 \ - --json number,title,body \ - --jq "[.[] | select((.title // \"\" | contains(\"#${issue_number}\")) or (.body // \"\" | test(\"(?i)(closes?|fixes?|resolves?)?\\s*#${issue_number}\\b\")))] | length" \ - 2>/dev/null || echo "0" - )" - fi - [[ "${pr_hits}" =~ ^[0-9]+$ ]] && [[ "${pr_hits}" -gt 0 ]] } @@ -147,19 +131,18 @@ process_issue() { IFS=',' read -ra ASSIGNEES <<<"${assignees_csv}" - # Exempt: never unassign acoliver (ticket exception). - for login in "${ASSIGNEES[@]}"; do - if [[ "${login}" == "${EXEMPT_LOGIN}" ]]; then - echo " 🛡️ Skipping #${issue_number}: assignee @${EXEMPT_LOGIN} is exempt" - return 0 - fi - done - local keep=false local assignee for assignee in "${ASSIGNEES[@]}"; do [[ -z "${assignee}" ]] && continue + # Exempt acoliver only; other co-assignees can still be cleaned up. + if [[ "${assignee}" == "${EXEMPT_LOGIN}" ]]; then + echo " 🛡️ Keeping @${EXEMPT_LOGIN} on #${issue_number} (exempt)" + keep=true + continue + fi + if ! assignment_age_ok_to_unassign "${issue_number}" "${assignee}" "${threshold_epoch}"; then keep=true continue @@ -187,7 +170,6 @@ EOF done if [[ "${keep}" != "true" ]]; then - # Re-check assignees after removals. local remaining remaining="$( gh issue view "${issue_number}" --repo "${REPO}" --json assignees \ diff --git a/.github/workflows/assign-stale-cleanup.yml b/.github/workflows/assign-stale-cleanup.yml index 203011b8bf..8358dadc57 100644 --- a/.github/workflows/assign-stale-cleanup.yml +++ b/.github/workflows/assign-stale-cleanup.yml @@ -12,6 +12,7 @@ on: permissions: contents: read issues: write + pull-requests: read defaults: run: diff --git a/.github/workflows/assign.yml b/.github/workflows/assign.yml index b3d04b5016..0cef85012f 100644 --- a/.github/workflows/assign.yml +++ b/.github/workflows/assign.yml @@ -11,6 +11,9 @@ on: permissions: contents: read issues: write + # Required for `gh search prs --merged` eligibility checks under an explicit + # permissions block (public repos are not exempt once permissions: is set). + pull-requests: read defaults: run: @@ -26,15 +29,17 @@ jobs: timeout-minutes: 10 # Exact `/assign` only (CONTRIBUTING.md). Support LF, CRLF, and trailing tab # via toJSON equality (not startsWith) so `/assign foo` never matches. - # Ignore PR review comments — this workflow is for issues only. + # Ignore PR review comments and Bot accounts (spam hardening). if: | github.event.issue.pull_request == null && + github.event.comment.user.type != 'Bot' && (github.event.comment.body == '/assign' || toJSON(github.event.comment.body) == '"/assign\n"' || toJSON(github.event.comment.body) == '"/assign\r\n"' || toJSON(github.event.comment.body) == '"/assign\t"') + # Per-issue cancel + per-actor serialize to blunt /assign floods. concurrency: - group: ${{ github.workflow }}-${{ github.event.issue.number }} + group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ github.event.comment.user.login }} cancel-in-progress: true steps: - name: Checkout repository diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b9fdb7b1a5..9ca716bd3f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -42,11 +42,11 @@ To assign an issue to yourself, add a comment that contains **only** the text `/ 1. The issue is not already assigned. 2. You are eligible: you have at least one **merged PR** in this repository, **or** you have previously been assigned an issue here, **or** you are listed in [`.github/trusted-contributors.txt`](.github/trusted-contributors.txt), **or** you are a repository owner/member/collaborator. -3. You currently have fewer than **3** open issues assigned to you. +3. You currently have fewer than **3** open issues assigned to you (hard concurrency cap to limit spam / hoarding). -On success the issue is labeled `auto-assigned`. If GitHub cannot assign you (for example, you do not have write access), the action posts feedback explaining the outcome so a maintainer can help. +Bot accounts are ignored. On success the issue is labeled `auto-assigned`. If GitHub cannot assign you (for example, you do not have write access), the action posts feedback explaining the outcome so a maintainer can help. -Auto-assignments with **no linked PR activity for 2 weeks** may be unassigned automatically (maintainer `acoliver` is exempt). Comment `/assign` again if you still intend to work on the issue. +Auto-assignments with **no linked PR activity for 2 weeks** may be unassigned automatically (maintainer `acoliver` is exempt as an assignee; other co-assignees can still be cleaned up). Comment `/assign` again if you still intend to work on the issue. ### Pull Request Guidelines diff --git a/scripts/tests/assign-workflow.test.js b/scripts/tests/assign-workflow.test.js index da627a2ecc..184bd64b5a 100644 --- a/scripts/tests/assign-workflow.test.js +++ b/scripts/tests/assign-workflow.test.js @@ -30,17 +30,19 @@ describe('.github/workflows/assign.yml', () => { expect(workflow.on?.pull_request).toBeUndefined(); }); - it('uses least-privilege permissions', () => { + it('uses least-privilege permissions including pull-requests read', () => { expect(workflow.permissions).toEqual({ contents: 'read', issues: 'write', + 'pull-requests': 'read', }); }); - it('gates on exact /assign for issues (not PRs)', () => { + it('gates on exact /assign for issues (not PRs) and rejects bots', () => { expect(job, 'assign job should exist').toBeTruthy(); const condition = normalize(job.if); expect(condition).toContain('github.event.issue.pull_request == null'); + expect(condition).toContain("github.event.comment.user.type != 'Bot'"); expect(condition).toContain("github.event.comment.body == '/assign'"); expect(condition).toContain( `toJSON(github.event.comment.body) == '"/assign\\n"'`, @@ -54,10 +56,10 @@ describe('.github/workflows/assign.yml', () => { ); }); - it('scopes concurrency by workflow and issue number', () => { + it('scopes concurrency by workflow, issue, and actor', () => { expect(job.concurrency?.['cancel-in-progress']).toBe(true); expect(normalize(job.concurrency?.group)).toBe( - '${{ github.workflow }}-${{ github.event.issue.number }}', + '${{ github.workflow }}-${{ github.event.issue.number }}-${{ github.event.comment.user.login }}', ); }); @@ -105,6 +107,7 @@ describe('.github/workflows/assign-stale-cleanup.yml', () => { expect(workflow.permissions).toEqual({ contents: 'read', issues: 'write', + 'pull-requests': 'read', }); const runStep = job.steps?.find( (s) => s.name === 'Run unassign-stale-issues script', From 0204988f03559ebfa2817385257a2022930e1aa8 Mon Sep 17 00:00:00 2001 From: acoliver Date: Fri, 24 Jul 2026 01:52:17 -0300 Subject: [PATCH 3/5] fix(ci): make /assign state transitions race-safe --- .github/assignment-history.txt | 7 + .github/scripts/assign-issue.sh | 904 ++++++++++-- .github/scripts/record-assignment-history.sh | 94 ++ .github/scripts/unassign-stale-issues.sh | 522 +++++-- .github/workflows/assign-stale-cleanup.yml | 3 + .github/workflows/assign.yml | 58 +- CONTRIBUTING.md | 8 +- scripts/tests/assign-helpers.js | 413 ++++++ scripts/tests/assign-remediation.test.js | 678 +++++++++ scripts/tests/assign-remediation2.test.js | 931 +++++++++++++ scripts/tests/assign-remediation3.test.js | 849 ++++++++++++ scripts/tests/assign-remediation4.test.js | 752 ++++++++++ scripts/tests/assign-remediation5.test.js | 833 ++++++++++++ scripts/tests/assign-remediation6.test.js | 255 ++++ scripts/tests/assign-remediation7.test.js | 652 +++++++++ .../tests/assign-workflow-behaviors.test.js | 743 ++++++++++ scripts/tests/assign-workflow.test.js | 38 +- scripts/tests/fake-gh.py | 1207 +++++++++++++++++ 18 files changed, 8709 insertions(+), 238 deletions(-) create mode 100644 .github/assignment-history.txt create mode 100755 .github/scripts/record-assignment-history.sh create mode 100644 scripts/tests/assign-helpers.js create mode 100644 scripts/tests/assign-remediation.test.js create mode 100644 scripts/tests/assign-remediation2.test.js create mode 100644 scripts/tests/assign-remediation3.test.js create mode 100644 scripts/tests/assign-remediation4.test.js create mode 100644 scripts/tests/assign-remediation5.test.js create mode 100644 scripts/tests/assign-remediation6.test.js create mode 100644 scripts/tests/assign-remediation7.test.js create mode 100644 scripts/tests/assign-workflow-behaviors.test.js create mode 100644 scripts/tests/fake-gh.py diff --git a/.github/assignment-history.txt b/.github/assignment-history.txt new file mode 100644 index 0000000000..03d80f2b2c --- /dev/null +++ b/.github/assignment-history.txt @@ -0,0 +1,7 @@ +5i2urom +Ayush7614 +HumanBot000 +acoliver +e2720pjk +minmax-algo +renecannao diff --git a/.github/scripts/assign-issue.sh b/.github/scripts/assign-issue.sh index 0bc1136184..1076681b5f 100755 --- a/.github/scripts/assign-issue.sh +++ b/.github/scripts/assign-issue.sh @@ -1,19 +1,32 @@ #!/usr/bin/env bash # Assign an issue to a commenter who posted an exact `/assign` command. # Invoked by .github/workflows/assign.yml. +# +# Uses targeted REST mutations (POST /labels, POST /assignees) instead of +# whole-array PATCH to preserve concurrent human state and comma-bearing +# label names. Adds label first, then assignee; rolls back only this run's +# additions on partial failure. +# +# Durable history: after successful assignment/cap verification, this script +# directly creates/validates the per-user history label (asnhist--LOGIN). +# GitHub suppresses recursive issues:assigned workflows caused by +# GITHUB_TOKEN, so the record-history job may not fire for automated +# assignments. This script ensures durability regardless. set -euo pipefail MARKER='' AUTO_ASSIGNED_LABEL='auto-assigned' +AUTO_ASSIGNED_COLOR='0E8A16' +AUTO_ASSIGNED_DESC='Assigned via /assign automation' +HISTORY_PREFIX='asnhist--' +HISTORY_COLOR='0E8A16' +HISTORY_DESC='Issue assignment history index' MAX_ASSIGNMENTS=3 -TRUSTED_FILE='.github/trusted-contributors.txt' +BOT_LOGIN='github-actions[bot]' +HISTORY_FILE="${ASSIGNMENT_HISTORY_FILE:-.github/assignment-history.txt}" -# Inputs are provided by GitHub Actions; declare them for shellcheck. -# shellcheck disable=SC2154 : "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" -# shellcheck disable=SC2154 : "${ISSUE_NUMBER:?Missing ISSUE_NUMBER}" -# shellcheck disable=SC2154 : "${COMMENTER_LOGIN:?Missing COMMENTER_LOGIN}" if [[ -n "${GH_TOKEN:-}" ]]; then @@ -21,155 +34,850 @@ if [[ -n "${GH_TOKEN:-}" ]]; then elif [[ -n "${GITHUB_TOKEN:-}" ]]; then export GH_TOKEN="${GITHUB_TOKEN}" else - echo "‼️ Missing \$GH_TOKEN / \$GITHUB_TOKEN" >&2 + echo "‼ Missing \$GH_TOKEN / \$GITHUB_TOKEN" >&2 exit 1 fi USER_LOGIN="${COMMENTER_LOGIN}" ISSUE="${ISSUE_NUMBER}" REPO="${GITHUB_REPOSITORY}" -AUTHOR_ASSOCIATION="${AUTHOR_ASSOCIATION:-}" +export ASSIGNEE_LOGIN="${USER_LOGIN}" +export GITHUB_REPOSITORY="${REPO}" + +# --------------------------------------------------------------------------- +# Read helpers: all return raw JSON arrays via jq for exact membership checks. +# Tri-state: success (0, stdout=data), absence (1, no stdout), error (1+ via ||). +# --------------------------------------------------------------------------- + +# Returns the raw .assignees JSON array from the issue object. +get_issue_assignees_json() { + gh api "repos/${REPO}/issues/${ISSUE}" --jq '.assignees // []' +} + +# Returns the raw .labels JSON array from the issue object. +get_issue_labels_json() { + gh api "repos/${REPO}/issues/${ISSUE}" --jq '.labels // []' +} + +# Returns the issue state string (e.g. "open", "closed"). +# Tri-state: success (0, stdout=state), error (nonzero, no stdout). +get_issue_state() { + local raw + raw="$(gh api "repos/${REPO}/issues/${ISSUE}" --jq '.')" || return 1 + echo "${raw}" | jq -r '.state // empty' +} + +# Validate an issue state string is exactly "open" or "closed". +# Returns 0 if the state is recognized; nonzero otherwise. +is_valid_state() { + local s="$1" + [[ "${s}" == "open" || "${s}" == "closed" ]] +} + +# Count NON-PR issues assigned to a user using fully paginated REST. +# Uses /repos/{repo}/issues?assignee=...&state=...&per_page=100, merges pages +# externally, and explicitly excludes items where .pull_request != null. +# Args: user, state_filter (must be "open", "closed", or "all" — omitted +# state defaults to open on GitHub, so callers must be explicit). +# Returns: numeric count on stdout; exit nonzero on any API error. +rest_issue_count() { + local user="$1" + local state_filter="$2" + case "${state_filter}" in + open|closed|all) ;; + *) + echo "‼ rest_issue_count: invalid state_filter '${state_filter}'" >&2 + return 1 + ;; + esac + local url="repos/${REPO}/issues?assignee=${user}&state=${state_filter}&per_page=100" + local all_pages + all_pages="$(gh api "${url}" --paginate 2>/dev/null)" || return 1 + # Merge paginated output (one JSON array per page) and count non-PR items. + local count + count="$(echo "${all_pages}" | jq -s \ + 'if all(.[]; type == "array") then add else error("non-array page") end + | [.[]? | select(.pull_request == null)] | length')" || return 1 + if ! [[ "${count}" =~ ^[0-9]+$ ]]; then + echo "‼ Non-numeric REST issue count for @${user}" >&2 + return 1 + fi + echo "${count}" +} + +# Returns the count of open NON-PR issues assigned to a user (numeric string). +# Uses fully paginated REST /repos/{repo}/issues?assignee=...&state=open +# instead of the Search API (avoids stale-index issues for cap enforcement). +get_open_assigned_count() { + local user="$1" + rest_issue_count "${user}" "open" +} + +get_merged_pr_count() { + local user="$1" + local _stderr_file raw + _stderr_file="$(mktemp)" + raw="$(gh api "search/issues?q=repo:${REPO}+author:${user}+type:pr+is:merged&per_page=1" --jq '.' 2>"${_stderr_file}")" || { + local _diag + _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" + rm -f "${_stderr_file}" + echo "‼ Merged PR search failed for @${user}: ${_diag}" >&2 + return 1 + } + rm -f "${_stderr_file}" + # Parse full response: total_count numeric, incomplete_results exactly false. + local tc ir + tc="$(echo "${raw}" | jq -r '.total_count')" || return 1 + ir="$(echo "${raw}" | jq -r '.incomplete_results')" || return 1 + if ! [[ "${tc}" =~ ^[0-9]+$ ]]; then + echo "‼ Non-numeric or missing total_count for merged PR search for @${user}" >&2 + return 1 + fi + if [[ "${ir}" != "false" ]]; then + echo "‼ Search API returned incomplete_results (infra error) for @${user}" >&2 + return 1 + fi + echo "${tc}" +} + +# Count current NON-PR issue assignments for a user via REST (avoids Search +# API index lag). Returns numeric string. +# Tri-state: 0 = query succeeded (stdout=count); nonzero = error. +# Uses state=all so closed issues count as prior assignments (an omitted +# state defaults to open on GitHub and would miss closed assignments). +get_current_issue_count() { + local user="$1" + rest_issue_count "${user}" "all" +} + +# Static backfill exact-line lookup (no API calls). +has_backfill_assignment() { + local user="$1" + [[ -f "${HISTORY_FILE}" ]] || return 1 + grep -Fxq -- "${user}" "${HISTORY_FILE}" +} + +# O(1) GET for the per-login history label. +# Tri-state: 0 = label exists with correct definition; 1 = absent or +# collision (wrong definition); 2 = API error (non-404). +has_history_label() { + local user="$1" + local label_name="${HISTORY_PREFIX}${user}" + local _stderr_file raw + _stderr_file="$(mktemp)" + raw="$(gh api "repos/${REPO}/labels/${label_name}" --jq '.' 2>"${_stderr_file}")" || { + local _diag + _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" + rm -f "${_stderr_file}" + # Distinguish 404 (absence) from other failures (error). + # gh exits with code 1 for both, but stderr contains the HTTP status. + if echo "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then + return 1 # absence + fi + return 2 # error + } + rm -f "${_stderr_file}" + # Validate exact definition (collision detection). + local color desc + color="$(echo "${raw}" | jq -r '.color // ""')" || return 2 + desc="$(echo "${raw}" | jq -r '.description // ""')" || return 2 + if [[ "${color}" != "${HISTORY_COLOR}" ]] || [[ "${desc}" != "${HISTORY_DESC}" ]]; then + return 1 # collision with human label — treat as absent + fi + return 0 +} + +# Historical eligibility: current issue search, then backfill, then per-login label. +# Any API error propagates as a failure (not eligibility). +has_historical_assignment() { + local user="$1" + local current_count + current_count="$(get_current_issue_count "${user}")" || return 2 + if [[ "${current_count}" -gt 0 ]]; then + return 0 + fi + if has_backfill_assignment "${user}"; then + return 0 + fi + has_history_label "${user}" + case $? in + 0) return 0 ;; + 1) return 1 ;; # absent + *) return 2 ;; # error + esac +} + +# --------------------------------------------------------------------------- +# Label definition validation helpers. +# --------------------------------------------------------------------------- + +# Check if a label exists and validate its definition matches expected values. +# Returns: 0 = exists with correct definition; 1 = absent; 2 = exists but +# conflicting definition; 3 = API error. +validate_label_definition() { + local label_name="$1" + local expected_color="$2" + local expected_desc="$3" + local _stderr_file raw + _stderr_file="$(mktemp)" + raw="$(gh api "repos/${REPO}/labels/${label_name}" --jq '.' 2>"${_stderr_file}")" || { + local _diag + _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" + rm -f "${_stderr_file}" + if echo "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then + return 1 # absent + fi + return 3 # error + } + rm -f "${_stderr_file}" + local color desc + color="$(echo "${raw}" | jq -r '.color // ""')" || return 3 + desc="$(echo "${raw}" | jq -r '.description // ""')" || return 3 + if [[ "${color}" != "${expected_color}" ]] || [[ "${desc}" != "${expected_desc}" ]]; then + return 2 # conflicting definition + fi + return 0 +} + +ensure_label_exists() { + local label_name="$1" + local expected_color="$2" + local expected_desc="$3" + local rc + validate_label_definition "${label_name}" "${expected_color}" "${expected_desc}" + rc=$? + case ${rc} in + 0) return 0 ;; # exists, correct definition + 1) ;; # absent — proceed to create + 2) + echo "‼ Label '${label_name}' exists with conflicting definition" >&2 + return 1 + ;; + 3) return 1 ;; # API error + esac + # Create the label. + if ! gh api --method POST "repos/${REPO}/labels" \ + -f name="${label_name}" \ + -f color="${expected_color}" \ + -f description="${expected_desc}" \ + --silent >/dev/null 2>&1; then + # POST failed — re-check if it was created concurrently (race). + validate_label_definition "${label_name}" "${expected_color}" "${expected_desc}" + rc=$? + case ${rc} in + 0) return 0 ;; + *) return 1 ;; + esac + fi + return 0 +} + +# --------------------------------------------------------------------------- +# Mutation helpers (targeted POST/DELETE). +# --------------------------------------------------------------------------- + +# Add a single label via targeted POST (preserves existing labels, commas). +add_label() { + local issue_num="$1" + local label_name="$2" + gh api --method POST "repos/${REPO}/issues/${issue_num}/labels" \ + -f "labels[]=${label_name}" --silent >/dev/null 2>&1 +} + +# Add assignee via targeted POST (preserves existing assignees). +add_assignee() { + local issue_num="$1" + local login="$2" + gh api --method POST "repos/${REPO}/issues/${issue_num}/assignees" \ + -f "assignees[]=${login}" --silent >/dev/null 2>&1 +} + +# Remove assignee via targeted DELETE. +remove_assignee() { + local issue_num="$1" + local login="$2" + gh api --method DELETE "repos/${REPO}/issues/${issue_num}/assignees" \ + -f "assignees[]=${login}" --silent >/dev/null 2>&1 +} + +# Remove a single label via targeted DELETE. Uses jq @uri for URL encoding +# to safely handle label names with special characters. +remove_label() { + local issue_num="$1" + local label_name="$2" + local encoded + encoded="$(printf '%s' "${label_name}" | jq -sRr '@uri' | sed 's/%0A$//;s/%0a$//')" + gh api --method DELETE "repos/${REPO}/issues/${issue_num}/labels/${encoded}" \ + --silent >/dev/null 2>&1 +} + +# --------------------------------------------------------------------------- +# Verified rollback: removes only this run's assignee, re-reads exact +# assignee JSON, then decides whether to also remove the label. +# +# If a competing winner remains assigned after removing this run's assignee, +# the marker label is PRESERVED (the competing assignment owns it now). +# If no other assignee remains and this run definitively owns the marker, +# the label is also removed. Verified rollback must succeed; callers must +# NOT treat a failed rollback as success. +# --------------------------------------------------------------------------- + +rollback_this_run() { + local issue_num="$1" + local login="$2" + local label_name="$3" + local rolled_label="${4:-false}" # whether this run added the label + + # Remove this run's assignee (best-effort; DELETE may be a no-op if + # already gone, which is fine). + remove_assignee "${issue_num}" "${login}" || true + + # Re-read exact assignee JSON after removal. + local post_assignees_json + if ! post_assignees_json="$(get_issue_assignees_json 2>/dev/null)"; then + echo "‼ Rollback: failed to re-read assignees after removing @${login} on #${issue_num}" >&2 + # If we can't verify, leave the label for safety (diagnosable state). + return 1 + fi + + # Check if this login is still present (DELETE may have failed or was + # re-added by a post-handler race). + local still_assigned + still_assigned="$(echo "${post_assignees_json}" | jq -r --arg login "${login}" \ + '[.[].login] | index($login) != null')" + + if [[ "${still_assigned}" == "true" ]]; then + # Assignee DELETE did not take effect (or was re-added). Retain marker + # for next-run recovery and fail. + echo "‼ Rollback verification FAILED: @${login} still assigned to #${issue_num}" >&2 + return 1 + fi + + # Count remaining assignees. + local remaining_count + remaining_count="$(echo "${post_assignees_json}" | jq 'length')" + + # Decide whether to remove the label. + # If other assignees remain (competing winner), preserve the marker. + if [[ "${remaining_count}" -gt 0 ]]; then + # A competing winner is still assigned — preserve the marker. + return 0 + fi + + # No other assignee remains; this run owned the marker. Remove the label. + if [[ "${rolled_label}" == "true" ]]; then + remove_label "${issue_num}" "${label_name}" || true + local verify_labels + if ! verify_labels="$(get_issue_labels_json 2>/dev/null)"; then + echo "‼ Rollback: failed to verify label removal on #${issue_num}" >&2 + return 1 + fi + if echo "${verify_labels}" | jq -e --arg lbl "${label_name}" \ + '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then + echo "‼ Rollback verification FAILED: label '${label_name}' still on #${issue_num}" >&2 + return 1 + fi + fi + return 0 +} + +# Verified rollback that exits nonzero if rollback itself fails. +# Never treats a failed rollback as success. If rollback fails, the +# state is left diagnosable (assignee/label may remain for next run). +# Args: issue_num login label_name rolled_label feedback_msg exit_code +verified_rollback_and_fail() { + local issue_num="$1" + local login="$2" + local label_name="$3" + local rolled_label="$4" + local feedback_msg="$5" + local exit_code="${6:-1}" + + if ! rollback_this_run "${issue_num}" "${login}" "${label_name}" "${rolled_label}"; then + echo "‼ Rollback FAILED for @${login} on #${issue_num} — state left for diagnosis" >&2 + post_sticky_feedback "${feedback_msg}" || true + exit "${exit_code}" + fi +} + +# --------------------------------------------------------------------------- +# Deterministic winner election via authoritative paginated timeline. +# +# Uses EVENT POSITION (flattened timeline array index) for total ordering — +# NOT created_at timestamps (which collide at second resolution). +# +# Election algorithm: +# 1. For each currently assigned login, find its latest assignment +# transition (assigned/unassigned) by EVENT POSITION. +# 2. If ANY login's latest transition was made by a human (non-bot), +# the issue has human ownership — this run must rollback itself. +# 3. Among bot-established current assignments, the earliest current-stint +# assigned event (by position) is the deterministic winner. +# 4. At least one and at most one automated winner is guaranteed. +# +# Output: the winning login on stdout; empty if human ownership detected. +# Returns: 0 = winner determined; 1 = error/ambiguous. +# --------------------------------------------------------------------------- + +# Fetch and flatten the full paginated issue timeline. +# Output: single JSON array on stdout. +fetch_timeline_json() { + gh api "repos/${REPO}/issues/${ISSUE}/timeline?per_page=100" \ + --paginate 2>/dev/null \ + | jq -s 'if all(.[]; type == "array") then add else error("non-array page in timeline") end' +} + +# Deterministic election: returns the winning login or empty. +# Uses timeline event positions for total ordering. +# +# Args: assignees_json, timeline_json +# Output: winning login (empty if human ownership), or "AMBIGUOUS". +# Returns: 0 = success; 1 = error. +elect_winner() { + local assignees_json="$1" + local timeline_json="$2" + + local result + result="$(echo "${timeline_json}" | jq -r --argjson current_assignees "${assignees_json}" --arg bot "${BOT_LOGIN}" ' + if type != "array" then error("timeline is not an array") else . end + | to_entries as $all_entries + | ($all_entries | map(select(.value.event == "assigned" or .value.event == "unassigned"))) as $assign_entries + | ($current_assignees | map(.login)) as $logins + | [ + $logins[] + | . as $login + | ($assign_entries | map(select(.value.assignee.login == $login))) as $login_transitions + | ($login_transitions | last // null) as $last_entry + | if $last_entry != null then + { + login: $login, + pos: $last_entry.key, + actor: $last_entry.value.actor.login, + event: $last_entry.value.event + } + else + { + login: $login, + pos: -1, + actor: "unknown", + event: "unknown" + } + end + ] as $latest_per_login + | ($latest_per_login | map(select(.actor != $bot)) | length > 0) as $has_human + | if $has_human then + "" + else + ($latest_per_login + | map(select(.event == "assigned")) + | sort_by(.pos) + | .[0].login // "AMBIGUOUS") + end + ' 2>/dev/null)" || return 1 + + echo "${result}" +} + +# Run the winner election: fetch fresh assignees + timeline, elect, and +# determine if this run is the winner. Retries a bounded number of times +# so contemporaneous contenders can appear in the timeline. +# +# Sets global: ELECTION_WINNER (the winning login or empty). +# Returns: 0 = this run won or no winner needed; 1 = error/rollback needed. +# Sets global: SHOULD_ROLLBACK (true/false). +ELECTION_RETRIES="${ASSIGN_ELECTION_RETRIES:-5}" +ELECTION_DELAY="${ASSIGN_ELECTION_DELAY:-1}" + +run_election() { + SHOULD_ROLLBACK="false" + local attempt + for attempt in $(seq 1 $((ELECTION_RETRIES + 1))); do + local assignees_json timeline_json + if ! assignees_json="$(get_issue_assignees_json)"; then + echo "‼ Election: failed to read assignees for #${ISSUE}" >&2 + SHOULD_ROLLBACK="true" + return 1 + fi + if ! timeline_json="$(fetch_timeline_json)"; then + echo "‼ Election: failed to fetch timeline for #${ISSUE}" >&2 + SHOULD_ROLLBACK="true" + return 1 + fi + + local winner + if ! winner="$(elect_winner "${assignees_json}" "${timeline_json}")"; then + echo "‼ Election: error during election for #${ISSUE}" >&2 + SHOULD_ROLLBACK="true" + return 1 + fi + + if [[ -z "${winner}" ]]; then + echo "WARNING: Human ownership detected on #${ISSUE}; rolling back @${USER_LOGIN}" >&2 + SHOULD_ROLLBACK="true" + ELECTION_WINNER="" + return 0 + fi + + if [[ "${winner}" == "AMBIGUOUS" ]]; then + echo "‼ Election: ambiguous result for #${ISSUE}" >&2 + SHOULD_ROLLBACK="true" + return 1 + fi + + if [[ "${winner}" == "${USER_LOGIN}" ]]; then + ELECTION_WINNER="${winner}" + SHOULD_ROLLBACK="false" + return 0 + fi + + if [[ "${attempt}" -le "${ELECTION_RETRIES}" ]]; then + sleep "${ELECTION_DELAY}" + fi + done + + echo "WARNING: Election loser: @${USER_LOGIN} is not the winner (@${winner} won) on #${ISSUE}" >&2 + ELECTION_WINNER="${winner}" + SHOULD_ROLLBACK="true" + return 0 +} + +# --------------------------------------------------------------------------- +# Sticky feedback (fail-closed: lookup errors ≠ absence). +# --------------------------------------------------------------------------- post_sticky_feedback() { local message="$1" local body body="$(printf '%s\n%s\n' "${MARKER}" "${message}")" - local existing_id="" - existing_id="$( - gh api "repos/${REPO}/issues/${ISSUE}/comments" --paginate \ - --jq ".[] | select(.body != null and (.body | contains(\"${MARKER}\"))) | .id" 2>/dev/null \ - | head -n 1 || true - )" + local _comments_stderr_file + _comments_stderr_file="$(mktemp)" + local all_comments_raw + all_comments_raw="$(gh api "repos/${REPO}/issues/${ISSUE}/comments" --paginate 2>"${_comments_stderr_file}")" || { + echo "‼ Failed to fetch comments for feedback" >&2 + cat "${_comments_stderr_file}" >&2 2>/dev/null || true + rm -f "${_comments_stderr_file}" + return 1 + } + rm -f "${_comments_stderr_file}" + + local all_comment_ids + all_comment_ids="$(echo "${all_comments_raw}" | jq -s 'add | [(.[]? | select(.user.login == $bot and ((.body // "") | startswith($marker))) | .id)]' \ + --arg bot "${BOT_LOGIN}" --arg marker "${MARKER}")" || { + echo "‼ Failed to parse comments for feedback" >&2 + return 1 + } + + local existing_id + existing_id="$(echo "${all_comment_ids}" | jq -r 'if type == "array" then .[0] // empty else empty end')" if [[ -n "${existing_id}" ]]; then - if ! gh api --method PATCH "repos/${REPO}/issues/comments/${existing_id}" \ - -f body="${body}" >/dev/null 2>&1; then - echo " ⚠️ Failed to update sticky feedback comment" >&2 - fi + gh api --method PATCH "repos/${REPO}/issues/comments/${existing_id}" \ + -f body="${body}" --silent >/dev/null 2>&1 || return 1 else - if ! gh issue comment "${ISSUE}" --repo "${REPO}" --body "${body}" >/dev/null 2>&1; then - echo " ⚠️ Failed to post sticky feedback comment" >&2 - fi + gh api --method POST "repos/${REPO}/issues/${ISSUE}/comments" \ + -f body="${body}" --silent >/dev/null 2>&1 || return 1 fi } -echo "🔄 Processing /assign for issue #${ISSUE} by @${USER_LOGIN}" +abort_infra_error() { + local msg="$1" + echo "${msg}" >&2 + post_sticky_feedback "[ERROR] Could not process /assign for @${USER_LOGIN}: unable to verify state due to an API error. Please try again or contact a maintainer." || true + exit 1 +} + +# --------------------------------------------------------------------------- +# Main flow. +# --------------------------------------------------------------------------- -# --- Guard: already assigned --- -assignee_count="$( - gh issue view "${ISSUE}" --repo "${REPO}" --json assignees \ - --jq '.assignees | length' 2>/dev/null || echo "0" -)" +echo " Processing /assign for issue #${ISSUE} by @${USER_LOGIN}" -if [[ "${assignee_count}" -gt 0 ]]; then - current="$( - gh issue view "${ISSUE}" --repo "${REPO}" --json assignees \ - --jq '[.assignees[].login] | join(", ")' 2>/dev/null || echo "someone" - )" - echo "⚠️ Issue #${ISSUE} is already assigned to: ${current}" - post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: this issue is already assigned to **${current}**." +# Step -1: Closed-issue guard — closed issues cannot be assigned. +# Malformed/missing state is an infrastructure failure (exit 1). +pre_state="" +pre_state="$(get_issue_state)" || abort_infra_error "‼ Failed to read issue state for #${ISSUE}" +if ! is_valid_state "${pre_state}"; then + abort_infra_error "‼ Issue #${ISSUE} has malformed state '${pre_state}'" +fi +if [[ "${pre_state}" != "open" ]]; then + echo "WARNING: Issue #${ISSUE} is ${pre_state}, not open" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: this issue is **${pre_state}**, not open. Only open issues can be assigned." || true exit 0 fi -# --- Cap: max concurrent open assignments (spam / concurrency guard) --- -open_assigned_count="$( - gh search issues --repo "${REPO}" --assignee "${USER_LOGIN}" --state open \ - --json number --jq 'length' 2>/dev/null || echo "0" -)" +# Step 0: Read current assignees as JSON array (exact membership). +pre_assignees_json="" +pre_assignees_json="$(get_issue_assignees_json)" || abort_infra_error "‼ Failed to read assignees for #${ISSUE}" -if [[ "${open_assigned_count}" -ge "${MAX_ASSIGNMENTS}" ]]; then - echo "⚠️ @${USER_LOGIN} already has ${open_assigned_count} open assigned issues (max ${MAX_ASSIGNMENTS})" - post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: you already have **${open_assigned_count}** open assigned issues (maximum is **${MAX_ASSIGNMENTS}** per CONTRIBUTING.md)." +if echo "${pre_assignees_json}" | jq -e '. | length > 0' >/dev/null 2>&1; then + assignee_csv="$(echo "${pre_assignees_json}" | jq -r '[.[].login] | join(", ")')" + echo "WARNING: Issue #${ISSUE} is already assigned to: ${assignee_csv}" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: this issue is already assigned to **${assignee_csv}**." || true exit 0 fi -# --- Eligibility --- +# Step 0b: Check for pre-existing auto-assigned marker on an unassigned issue. +# This is inconsistent provenance — fail closed, do not assign. +pre_labels_json="" +pre_labels_json="$(get_issue_labels_json)" || abort_infra_error "‼ Failed to read labels for #${ISSUE}" + +if echo "${pre_labels_json}" | jq -e --arg lbl "${AUTO_ASSIGNED_LABEL}" '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then + echo "WARNING: Issue #${ISSUE} is unassigned but already carries '${AUTO_ASSIGNED_LABEL}' label — inconsistent provenance" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: this issue has an inconsistent state (auto-assigned label without an assignee). Please contact a maintainer." || true + exit 1 +fi + +# Step 1: Cap check (pre-mutation). +open_count="" +open_count="$(get_open_assigned_count "${USER_LOGIN}")" || abort_infra_error "‼ Failed to query open assigned count for @${USER_LOGIN}" + +if [[ "${open_count}" -ge "${MAX_ASSIGNMENTS}" ]]; then + echo "WARNING: @${USER_LOGIN} already has ${open_count} open assigned issues (max ${MAX_ASSIGNMENTS})" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: you already have **${open_count}** open assigned issues (maximum is **${MAX_ASSIGNMENTS}** per CONTRIBUTING.md). Please finish or unassign one first." || true + exit 0 +fi + +# Step 2: Eligibility. is_eligible=false eligibility_reason="" -merged_pr_count="$( - gh search prs --repo "${REPO}" --author "${USER_LOGIN}" --merged \ - --json number --jq 'length' 2>/dev/null || echo "0" -)" +merged_count="" +merged_count="$(get_merged_pr_count "${USER_LOGIN}")" || abort_infra_error "‼ Failed to query merged PRs for @${USER_LOGIN}" -if [[ "${merged_pr_count}" -gt 0 ]]; then +if [[ "${merged_count}" -gt 0 ]]; then is_eligible=true eligibility_reason="prior merged PR(s)" fi if [[ "${is_eligible}" != "true" ]]; then - prior_assigned_count="$( - gh search issues --repo "${REPO}" --assignee "${USER_LOGIN}" --state all \ - --json number --jq 'length' 2>/dev/null || echo "0" - )" - if [[ "${prior_assigned_count}" -gt 0 ]]; then - is_eligible=true - eligibility_reason="prior issue assignment" - fi + hist_rc=0 + has_historical_assignment "${USER_LOGIN}" || hist_rc=$? + case ${hist_rc} in + 0) + is_eligible=true + eligibility_reason="prior issue assignment" + ;; + 1) ;; # absent — not eligible via history + 2) + abort_infra_error "‼ Failed to query historical assignment for @${USER_LOGIN}" + ;; + esac fi -if [[ "${is_eligible}" != "true" && -f "${TRUSTED_FILE}" ]]; then - if grep -Fqx "${USER_LOGIN}" "${TRUSTED_FILE}"; then - is_eligible=true - eligibility_reason="trusted contributor list" - fi +if [[ "${is_eligible}" != "true" ]]; then + echo "WARNING: @${USER_LOGIN} is not eligible for self-assignment" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: eligibility requires at least one merged PR in this repository, or a prior issue assignment. Please open a PR first or ask a maintainer to assign you." || true + exit 0 fi -case "${AUTHOR_ASSOCIATION}" in - OWNER | MEMBER | COLLABORATOR) - is_eligible=true - eligibility_reason="repository ${AUTHOR_ASSOCIATION}" - ;; - *) - ;; -esac +echo "[OK] Eligible via: ${eligibility_reason}" -if [[ "${is_eligible}" != "true" ]]; then - echo "⚠️ @${USER_LOGIN} is not eligible for self-assignment" - post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: eligibility requires at least one merged PR in this repository, a prior issue assignment, or membership in \`.github/trusted-contributors.txt\`. Please open a PR first or ask a maintainer to assign you." +# Step 3: Validate/create the shared auto-assigned label (with exact definition). +ensure_label_exists "${AUTO_ASSIGNED_LABEL}" "${AUTO_ASSIGNED_COLOR}" "${AUTO_ASSIGNED_DESC}" \ + || abort_infra_error "‼ Failed to validate/create label '${AUTO_ASSIGNED_LABEL}'" + +# Step 4: Re-read assignees immediately before mutation (race protection). +pre_assignees_json="" +pre_assignees_json="$(get_issue_assignees_json)" || abort_infra_error "‼ Failed to re-read assignees for #${ISSUE}" + +if echo "${pre_assignees_json}" | jq -e '. | length > 0' >/dev/null 2>&1; then + assignee_csv="$(echo "${pre_assignees_json}" | jq -r '[.[].login] | join(", ")')" + echo "WARNING: Issue #${ISSUE} was assigned by a concurrent run: ${assignee_csv}" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: this issue was assigned to **${assignee_csv}** by a concurrent request." || true + exit 0 +fi + +# Step 4b: Re-validate issue state is still open immediately before mutation. +pre_mutation_state="" +pre_mutation_state="$(get_issue_state)" || abort_infra_error "‼ Failed to re-read issue state for #${ISSUE}" +if ! is_valid_state "${pre_mutation_state}"; then + abort_infra_error "‼ Issue #${ISSUE} has malformed state '${pre_mutation_state}'" +fi +if [[ "${pre_mutation_state}" != "open" ]]; then + echo "WARNING: Issue #${ISSUE} became ${pre_mutation_state} before mutation" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: this issue is **${pre_mutation_state}**, not open. Only open issues can be assigned." || true exit 0 fi -echo "✅ Eligible via: ${eligibility_reason}" +pre_open_count="" +pre_open_count="$(get_open_assigned_count "${USER_LOGIN}")" || abort_infra_error "‼ Failed to re-query open assigned count for @${USER_LOGIN}" + +if [[ "${pre_open_count}" -ge "${MAX_ASSIGNMENTS}" ]]; then + echo "WARNING: @${USER_LOGIN} reached cap (${pre_open_count}) before assignment" + post_sticky_feedback "[ERROR] Could not assign @${USER_LOGIN}: you now have **${pre_open_count}** open assigned issues (maximum is **${MAX_ASSIGNMENTS}**). Another assignment may have completed concurrently." || true + exit 0 +fi + +# Step 5: Add auto-assigned label (targeted POST). +label_added_by_this_run=false + +if add_label "${ISSUE}" "${AUTO_ASSIGNED_LABEL}"; then + label_added_by_this_run=true +else + # Label add failed — re-read to confirm current state. + verified_labels_json="" + if ! verified_labels_json="$(get_issue_labels_json)"; then + abort_infra_error "‼ Failed to verify labels after add failure for #${ISSUE}" + fi + if ! echo "${verified_labels_json}" | jq -e --arg lbl "${AUTO_ASSIGNED_LABEL}" '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then + abort_infra_error "‼ Label add failed and label is not present for #${ISSUE}" + fi +fi + +# Step 6: Add assignee (targeted POST). +if ! add_assignee "${ISSUE}" "${USER_LOGIN}"; then + echo "‼ Assignee POST failed for #${ISSUE}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: GitHub API error during assignment. Please try again or contact a maintainer." 1 + exit 1 +fi + +# Step 7: Deterministic winner election via authoritative paginated timeline. +# Uses EVENT POSITION for total ordering. If any human owns the issue, +# rollback. Among bot-established assignments, the earliest current-stint +# assigned event wins. The winner waits/retries so contemporaneous +# contenders can appear, then enforces exactly itself remains. +ELECTION_WINNER="" +SHOULD_ROLLBACK="false" -# --- Assign --- -if ! gh issue edit "${ISSUE}" --repo "${REPO}" --add-assignee "${USER_LOGIN}" >/dev/null 2>&1; then - echo "⚠️ gh issue edit --add-assignee failed for @${USER_LOGIN}" +if ! run_election; then + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: election verification failed. Please contact a maintainer." 1 + exit 1 fi -assigned_logins="$( - gh issue view "${ISSUE}" --repo "${REPO}" --json assignees \ - --jq '[.assignees[].login] | join(",")' 2>/dev/null || echo "" -)" +if [[ "${SHOULD_ROLLBACK}" == "true" ]]; then + if [[ -z "${ELECTION_WINNER}" ]]; then + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: a human has taken ownership of this issue." 1 + else + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 + fi + exit 1 +fi + +# Winner enforcement: wait/retry for losers to rollback, then verify +# exactly this run's login remains assigned. Re-runs the election when +# contention persists — only rolls back if this run is no longer the winner. +enforcement_ok=false +enforcement_retries=$((ELECTION_RETRIES * 3 + 5)) +for _e_attempt in $(seq 1 $((enforcement_retries + 1))); do + verified_assignees_json="" + if ! verified_assignees_json="$(get_issue_assignees_json)"; then + echo "‼ Failed to verify assignees for #${ISSUE}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: unable to verify assignment state. Please contact a maintainer." 1 + exit 1 + fi + + assignee_count="$(echo "${verified_assignees_json}" | jq 'length')" + has_commenter="$(echo "${verified_assignees_json}" | jq -r --arg login "${USER_LOGIN}" '[.[].login] | index($login) != null')" -IFS=',' read -ra ASSIGNED_ARRAY <<<"${assigned_logins}" -is_assigned=false -for login in "${ASSIGNED_ARRAY[@]}"; do - if [[ "${login}" == "${USER_LOGIN}" ]]; then - is_assigned=true + if [[ "${assignee_count}" == "1" ]] && [[ "${has_commenter}" == "true" ]]; then + enforcement_ok=true break fi + + # Contention persists — re-run election to check if this run is still the winner. + # If no longer the winner, roll back. If still the winner, keep waiting. + if [[ "${has_commenter}" != "true" ]]; then + echo "WARNING: @${USER_LOGIN} no longer assigned to #${ISSUE} during enforcement" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 + exit 1 + fi + + recheck_timeline="" + recheck_assignees="" + recheck_winner="" + if ! recheck_assignees="$(get_issue_assignees_json)" || \ + ! recheck_timeline="$(fetch_timeline_json)"; then + echo "‼ Enforcement re-election: API error for #${ISSUE}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: election verification failed. Please contact a maintainer." 1 + exit 1 + fi + + if ! recheck_winner="$(elect_winner "${recheck_assignees}" "${recheck_timeline}")"; then + echo "‼ Enforcement re-election: error for #${ISSUE}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: election verification failed. Please contact a maintainer." 1 + exit 1 + fi + + if [[ -z "${recheck_winner}" ]]; then + echo "WARNING: Human ownership detected during enforcement on #${ISSUE}; rolling back @${USER_LOGIN}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: a human has taken ownership of this issue." 1 + exit 1 + fi + + if [[ "${recheck_winner}" != "${USER_LOGIN}" ]]; then + echo "WARNING: @${USER_LOGIN} is no longer the winner (@${recheck_winner} won) on #${ISSUE}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 + exit 1 + fi + + if [[ "${_e_attempt}" -le "${enforcement_retries}" ]]; then + sleep "${ELECTION_DELAY}" + fi done -if [[ "${is_assigned}" != "true" ]]; then - echo "❌ Assignment did not take effect for @${USER_LOGIN} (likely lacks write access)" - post_sticky_feedback "❌ Could not assign @${USER_LOGIN}: GitHub only allows assigning users with write access to the repository. A maintainer may need to add you as a collaborator or assign the issue manually. Eligibility check passed (${eligibility_reason})." - exit 0 +if [[ "${enforcement_ok}" != "true" ]]; then + echo "WARNING: Post-election contention on #${ISSUE}: assignees = $(echo "${verified_assignees_json}" | jq -r '[.[].login] | join(", ")')" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: unable to enforce single-winner assignment. Please contact a maintainer." 1 + exit 1 +fi + +# Step 8: Verify label was added. +verified_labels_json="" +if ! verified_labels_json="$(get_issue_labels_json)"; then + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: unable to verify label state. Please contact a maintainer." 1 + abort_infra_error "‼ Failed to verify labels for #${ISSUE}" +fi + +if ! echo "${verified_labels_json}" | jq -e --arg lbl "${AUTO_ASSIGNED_LABEL}" '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then + echo "‼ Label verification failed for #${ISSUE}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: the label could not be verified. Please contact a maintainer." 1 + exit 1 +fi + +# Step 9: Post-mutation cap enforcement (fail-closed). +post_open_count="" +if ! post_open_count="$(get_open_assigned_count "${USER_LOGIN}")"; then + echo "‼ Post-mutation cap query failed for @${USER_LOGIN}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: unable to verify the assignment cap due to an API error. Please try again or contact a maintainer." 1 + exit 1 fi -gh label create "${AUTO_ASSIGNED_LABEL}" \ - --repo "${REPO}" \ - --color "0E8A16" \ - --description "Assigned via /assign automation" \ - >/dev/null 2>&1 || true +if [[ "${post_open_count}" -gt "${MAX_ASSIGNMENTS}" ]]; then + echo "WARNING: Post-mutation open count (${post_open_count}) exceeds cap; rolling back @${USER_LOGIN} on #${ISSUE}" >&2 + # Verified rollback must succeed before exiting. If it fails, exit nonzero. + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: assignment would exceed the **${MAX_ASSIGNMENTS}**-issue cap. Another assignment may have completed concurrently." 1 + exit 0 +fi -if ! gh issue edit "${ISSUE}" --repo "${REPO}" --add-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1; then - echo " ⚠️ Failed to add ${AUTO_ASSIGNED_LABEL} label" >&2 +# Step 10: Create durable history label directly (GitHub suppresses recursive +# issues:assigned workflows from GITHUB_TOKEN). +# Delegates to the shared record-assignment-history.sh script for consistent +# exact label definition validation and collision failure. +if ! bash "$(dirname "${BASH_SOURCE[0]}")/record-assignment-history.sh"; then + echo "‼ Failed to create history label for @${USER_LOGIN}" >&2 + verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ + "[ERROR] Could not assign @${USER_LOGIN}: failed to record assignment history. Please try again or contact a maintainer." 1 + exit 1 fi -echo "✅ Assigned @${USER_LOGIN} to issue #${ISSUE}" -post_sticky_feedback "✅ Assigned @${USER_LOGIN} to this issue via \`/assign\` (${eligibility_reason}). +echo "[OK] Assigned @${USER_LOGIN} to issue #${ISSUE}" +post_sticky_feedback "[OK] Assigned @${USER_LOGIN} to this issue via \`/assign\` (${eligibility_reason}). -Please open a linked PR within **2 weeks**, or the automation may unassign stale auto-assignments (maintainers are exempt)." +Please open a linked PR within **2 weeks**, or the automation may unassign stale auto-assignments (maintainer \`acoliver\` is exempt as an assignee)." exit 0 diff --git a/.github/scripts/record-assignment-history.sh b/.github/scripts/record-assignment-history.sh new file mode 100755 index 0000000000..8b3d58c6d3 --- /dev/null +++ b/.github/scripts/record-assignment-history.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Create or verify a per-user assignment history label. +# +# Used by both assign-issue.sh (direct durability) and the record-history +# workflow job (issues:assigned events). Uses the same short-prefix exact +# label definition validation (name, normalized color, description) and +# collision failure as assign-issue.sh. +set -euo pipefail + +HISTORY_PREFIX='asnhist--' +HISTORY_COLOR='0E8A16' +HISTORY_DESC='Issue assignment history index' + +: "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" +: "${ASSIGNEE_LOGIN:?Missing ASSIGNEE_LOGIN}" + +if [[ -n "${GH_TOKEN:-}" ]]; then + export GH_TOKEN +elif [[ -n "${GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${GITHUB_TOKEN}" +else + echo "‼ Missing \$GH_TOKEN / \$GITHUB_TOKEN" >&2 + exit 1 +fi + +REPO="${GITHUB_REPOSITORY}" +LOGIN="${ASSIGNEE_LOGIN}" +LABEL_NAME="${HISTORY_PREFIX}${LOGIN}" + +validate_history_label() { + local label_name="$1" + local _stderr_file raw + _stderr_file="$(mktemp)" + raw="$(gh api "repos/${REPO}/labels/${label_name}" --jq '.' 2>"${_stderr_file}")" || { + local _diag + _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" + rm -f "${_stderr_file}" + # Distinguish 404 (absence) from other API failures (error). + if echo "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then + return 1 + fi + # Non-404 API error — log sanitized diagnostics and signal error. + echo "‼ validate_history_label: API error for '${label_name}': $(echo "${_diag}" | head -1)" >&2 + return 2 + } + rm -f "${_stderr_file}" + local color desc + color="$(echo "${raw}" | jq -r '.color // ""')" || return 2 + desc="$(echo "${raw}" | jq -r '.description // ""')" || return 2 + if [[ "${color}" != "${HISTORY_COLOR}" ]] || [[ "${desc}" != "${HISTORY_DESC}" ]]; then + return 1 + fi + return 0 +} + +validate_history_label "${LABEL_NAME}" || rc=$? +case ${rc:-0} in + 0) + echo "Label ${LABEL_NAME} already exists with correct definition" + exit 0 + ;; + 1) ;; # absent — proceed to create + 2) + echo "‼ Failed to check label ${LABEL_NAME} (API error)" >&2 + exit 1 + ;; +esac + +if ! gh api --method POST "repos/${REPO}/labels" \ + -f name="${LABEL_NAME}" \ + -f color="${HISTORY_COLOR}" \ + -f description="${HISTORY_DESC}" \ + --silent >/dev/null 2>&1; then + # POST failed — re-check whether it was created concurrently. Capture the + # return code directly (not via if/else, which loses $? of the condition). + rc=0 + validate_history_label "${LABEL_NAME}" || rc=$? + case ${rc} in + 0) + echo "Label ${LABEL_NAME} already exists (created concurrently)" + exit 0 + ;; + 1) + echo "‼ Label '${LABEL_NAME}' exists with conflicting definition" >&2 + exit 1 + ;; + *) + echo "‼ Failed to create label ${LABEL_NAME}" >&2 + exit 1 + ;; + esac +fi + +echo "Created label ${LABEL_NAME}" diff --git a/.github/scripts/unassign-stale-issues.sh b/.github/scripts/unassign-stale-issues.sh index 37da5a99fc..478c109abe 100755 --- a/.github/scripts/unassign-stale-issues.sh +++ b/.github/scripts/unassign-stale-issues.sh @@ -1,16 +1,27 @@ #!/usr/bin/env bash -# Unassign stale auto-assigned issues with no linked PR activity for 2 weeks. -# Invoked by .github/workflows/assign-stale-cleanup.yml. -# Never unassigns acoliver. +# Unassign stale auto-assigned issues with no qualifying linked PR activity +# for 2 weeks. Invoked by .github/workflows/assign-stale-cleanup.yml. +# +# Uses targeted DELETE endpoints (never whole-array PATCH) to preserve +# concurrent human state and comma-bearing label names. Only the exact +# automation assignee and auto-assigned label are removed. +# +# acoliver is never unassigned. Timeline/API failures preserve state and +# cause a nonzero exit (never destructive cleanup). set -euo pipefail AUTO_ASSIGNED_LABEL='auto-assigned' +AUTO_ASSIGNED_COLOR='0E8A16' +AUTO_ASSIGNED_DESC='Assigned via /assign automation' STALE_DAYS=14 EXEMPT_LOGIN='acoliver' +BOT_LOGIN='github-actions[bot]' +ASSIGN_RETRY_DELAY="${ASSIGN_RETRY_DELAY:-5}" -# shellcheck disable=SC2154 : "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" +EXPECTED_REPO_URL="https://api.github.com/repos/${GITHUB_REPOSITORY}" + if [[ -n "${GH_TOKEN:-}" ]]; then export GH_TOKEN elif [[ -n "${GITHUB_TOKEN:-}" ]]; then @@ -30,15 +41,23 @@ retry_gh() { fi if [[ "${attempt}" -lt 4 ]]; then echo "Attempt ${attempt} failed, retrying: $*" >&2 - sleep 5 + sleep "${ASSIGN_RETRY_DELAY}" fi done echo "All retries exhausted for: $*" >&2 return 1 } -# ISO-8601 UTC timestamp STALE_DAYS ago (GNU date on ubuntu-latest; BSD fallback). threshold_iso() { + if [[ -n "${ASSIGN_NOW:-}" ]]; then + local now_epoch + now_epoch="$(iso_to_epoch "${ASSIGN_NOW}")" || return 1 + local threshold_epoch + threshold_epoch=$((now_epoch - STALE_DAYS * 86400)) + date -u -d "@${threshold_epoch}" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \ + date -u -r "${threshold_epoch}" +%Y-%m-%dT%H:%M:%SZ + return 0 + fi if date -u -d "${STALE_DAYS} days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null; then return 0 fi @@ -47,7 +66,6 @@ threshold_iso() { iso_to_epoch() { local iso="$1" - # Normalize fractional seconds / Z for date parsing. local normalized="${iso%Z}" normalized="${normalized%%.*}" if date -u -d "${normalized}Z" +%s 2>/dev/null; then @@ -56,161 +74,443 @@ iso_to_epoch() { date -u -j -f "%Y-%m-%dT%H:%M:%S" "${normalized}" +%s 2>/dev/null } -assignment_age_ok_to_unassign() { +# gh api --paginate emits one JSON array per page; jq -s merges them. +fetch_issue_timeline() { local issue_number="$1" - local assignee="$2" - local threshold_epoch="$3" + gh api "repos/${REPO}/issues/${issue_number}/timeline?per_page=100" \ + --paginate 2>/dev/null \ + | jq -s 'if all(.[]; type == "array") then add else error("non-array page in timeline") end' +} - # Prefer the most recent "assigned" timeline event for this assignee. - local assigned_at="" - assigned_at="$( - gh api "repos/${REPO}/issues/${issue_number}/timeline" --paginate \ - --jq "[.[] | select(.event == \"assigned\" and .assignee.login == \"${assignee}\") | .created_at] | last // empty" \ - 2>/dev/null || true - )" +# Read current assignees as a raw JSON array. +get_issue_assignees_json() { + local issue_number="$1" + gh api "repos/${REPO}/issues/${issue_number}" \ + --jq '.assignees // []' 2>/dev/null +} - if [[ -z "${assigned_at}" ]]; then - # Fall back to issue updatedAt when timeline is unavailable. - assigned_at="$( - gh issue view "${issue_number}" --repo "${REPO}" --json updatedAt \ - --jq '.updatedAt' 2>/dev/null || true - )" - fi - if [[ -z "${assigned_at}" ]]; then - echo " ⚠️ Could not determine assignment time for #${issue_number}; skipping" >&2 +# Provenance extraction from a pre-fetched timeline snapshot. +# Args: assignees_json, timeline_json. +# Output: "LOGIN TIMESTAMP" or empty. Returns nonzero on error/ambiguity. +find_provenance_from_timeline() { + local assignees_json="$1" + local timeline_json="$2" + + local result + result="$(echo "${timeline_json}" | jq -r \ + --argjson current_assignees "${assignees_json}" \ + --arg label "${AUTO_ASSIGNED_LABEL}" \ + --arg bot "${BOT_LOGIN}" ' + if type != "array" then error("timeline is not an array") else . end + | to_entries as $all_entries + | ($all_entries | map(select(.value.event == "assigned" or .value.event == "unassigned"))) as $assign_entries + | ($all_entries | map(select(.value.event == "labeled" or .value.event == "unlabeled"))) as $label_entries + | ($current_assignees | map(.login)) as $logins + | $logins[] + | . as $login + | ($assign_entries | map(select(.value.assignee.login == $login))) as $login_transitions + | ($login_transitions | last // null) as $last_entry + | ($login_transitions[-2] // null) as $boundary_entry + | if $last_entry != null and $last_entry.value.event == "assigned" and $last_entry.value.actor.login == $bot then + $last_entry.value.created_at as $assigned_at + | $last_entry.key as $assigned_pos + | ($boundary_entry.key // -1) as $boundary_pos + | ($label_entries | map(select( + .value.label.name == $label + and (.key > $boundary_pos) + and (.key <= $assigned_pos) + and .value.actor.login == $bot + and .value.event == "labeled" + )) | last // null) as $qualifying_entry + | if $qualifying_entry != null then + ($label_entries | map(select( + .value.label.name == $label + and (.key > $qualifying_entry.key) + and .value.event == "unlabeled" + )) | length > 0) as $has_invalidating_unlabel + | if $has_invalidating_unlabel then + empty + else + "\($login) \($assigned_at)" + end + else + empty + end + else + empty + end + ' 2>/dev/null)" || return 1 + + local line_count + line_count="$(echo "${result}" | grep -c . || true)" + if [[ "${line_count}" -gt 1 ]]; then + echo "Multiple automated provenance records found" >&2 return 1 fi - local assigned_epoch - assigned_epoch="$(iso_to_epoch "${assigned_at}" || true)" - if [[ -z "${assigned_epoch}" ]]; then - echo " ⚠️ Could not parse assignment time '${assigned_at}' for #${issue_number}; skipping" >&2 + echo "${result}" +} + +# Qualifying linked PR requires the same repository and assignee author, with +# linkage at or after assignment. Returns nonzero for malformed snapshots. +has_qualifying_linked_pr_from_timeline() { + local assignee="$1" + local assigned_at="$2" + local timeline_json="$3" + + local result + result="$(echo "${timeline_json}" | jq -r --arg assignee "${assignee}" --arg assigned_at "${assigned_at}" --arg repo_url "${EXPECTED_REPO_URL}" ' + if type != "array" then error("timeline is not an array") else . end + | map(select( + (.event == "cross-referenced") and + (.source.issue.pull_request != null) and + (.source.issue.user.login == $assignee) and + (.created_at >= $assigned_at) and + (.source.issue.repository_url == $repo_url) + )) + | length > 0 + ' 2>/dev/null)" || return 1 + + echo "${result}" +} + +# Discover candidates: open issues with auto-assigned label. +# GitHub /issues includes PRs; filter to issues only. +discover_candidates() { + gh api "repos/${REPO}/issues?state=open&labels=${AUTO_ASSIGNED_LABEL}&per_page=100" \ + --paginate 2>/dev/null \ + | jq -sr 'if all(.[]; type == "array") then add else error("non-array page") end + | .[]? | select(.pull_request == null) | "\(.number)\t\(.assignees)"' +} + +# Remove only the auto-assigned label via targeted DELETE. Uses jq @uri. +# On DELETE failure (including 404 from a race that already removed the +# label), re-reads the issue to verify the label is actually absent. Only an +# issue read confirming absence allows treating the failure as success. +remove_label_targeted() { + local issue_number="$1" + local label_name="$2" + local encoded + encoded="$(printf '%s' "${label_name}" | jq -sRr '@uri' | sed 's/%0A$//;s/%0a$//')" + if retry_gh gh api --method DELETE "repos/${REPO}/issues/${issue_number}/labels/${encoded}" \ + --silent >/dev/null 2>&1; then + return 0 + fi + # DELETE failed — verify whether the label is actually absent via issue read. + local verify_labels + if ! verify_labels="$(gh api "repos/${REPO}/issues/${issue_number}" --jq '.labels // []' 2>/dev/null)"; then + echo " Warning: Label-removal DELETE failed and verification read failed for #${issue_number}" >&2 + return 1 + fi + if echo "${verify_labels}" | jq -e --arg lbl "${label_name}" '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then + echo " Warning: Label-removal DELETE failed and label still present for #${issue_number}" >&2 return 1 fi + # Label is confirmed absent — desired state achieved despite DELETE failure. + return 0 +} - if [[ "${assigned_epoch}" -gt "${threshold_epoch}" ]]; then - echo " ⏳ Assignment for #${issue_number} is newer than ${STALE_DAYS} days; keeping" +# Remove only a specific assignee via targeted DELETE. +remove_assignee_targeted() { + local issue_number="$1" + local login="$2" + if ! retry_gh gh api --method DELETE "repos/${REPO}/issues/${issue_number}/assignees" \ + -f "assignees[]=${login}" --silent >/dev/null 2>&1; then + echo " Warning: Assignee-removal DELETE failed for #${issue_number}" >&2 return 1 fi return 0 } -has_linked_pr_activity() { +# Remove auto-assigned label only (no assignees to clean). +remove_label_only() { local issue_number="$1" - local assignee="$2" - - # Prefer search API (unbounded) over a limited pr list. - local pr_hits - pr_hits="$( - gh search prs \ - --repo "${REPO}" \ - --author "${assignee}" \ - --json number \ - --jq 'length' \ - "#${issue_number} in:title,body" \ - 2>/dev/null || echo "0" - )" - - [[ "${pr_hits}" =~ ^[0-9]+$ ]] && [[ "${pr_hits}" -gt 0 ]] + remove_label_targeted "${issue_number}" "${AUTO_ASSIGNED_LABEL}" +} + +# Validate the canonical auto-assigned label definition before any candidate +# discovery or mutation (fail-closed, matching assign-issue.sh). +# Returns: 0 = exists with correct definition; 1 = absent (clean no-op); +# 2 = conflicting definition; 3 = API error. +validate_marker_label() { + local _stderr_file raw + _stderr_file="$(mktemp)" + raw="$(gh api "repos/${REPO}/labels/${AUTO_ASSIGNED_LABEL}" --jq '.' 2>"${_stderr_file}")" || { + local _diag + _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" + rm -f "${_stderr_file}" + if echo "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then + return 1 + fi + return 3 + } + rm -f "${_stderr_file}" + local color desc + color="$(echo "${raw}" | jq -r '.color // ""')" || return 3 + desc="$(echo "${raw}" | jq -r '.description // ""')" || return 3 + if [[ "${color}" != "${AUTO_ASSIGNED_COLOR}" ]] || [[ "${desc}" != "${AUTO_ASSIGNED_DESC}" ]]; then + return 2 + fi + return 0 } process_issue() { local issue_number="$1" - local assignees_csv="$2" + local assignees_json="$2" local threshold_epoch="$3" - echo "🔄 Checking auto-assigned issue #${issue_number} (assignees: ${assignees_csv})" + local assignee_count + assignee_count="$(echo "${assignees_json}" | jq 'length' 2>/dev/null || echo '?')" - if [[ -z "${assignees_csv}" ]]; then + echo "🔄 Checking auto-assigned issue #${issue_number} (assignees: ${assignee_count})" + + if [[ "${assignee_count}" == "0" ]]; then echo " No assignees; removing stale ${AUTO_ASSIGNED_LABEL} label" - retry_gh gh issue edit "${issue_number}" --repo "${REPO}" \ - --remove-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1 || true + remove_label_only "${issue_number}" + return $? + fi + + local bot_assignment="" + local initial_timeline_json="" + # Fetch ONE initial timeline snapshot per issue and use it for BOTH + # initial provenance extraction and the initial linked-PR check. This + # avoids a duplicate timeline fetch (a separate fresh pre-delete snapshot + # is still mandatory before destructive DELETE for race safety). + initial_timeline_json="$(fetch_issue_timeline "${issue_number}")" || { + echo " Warning: Initial timeline read failed for #${issue_number}; preserving state" >&2 + return 1 + } + + bot_assignment="$(find_provenance_from_timeline "${assignees_json}" "${initial_timeline_json}")" || { + echo " Warning: Provenance check failed for #${issue_number} (ambiguous or error); preserving state" >&2 + return 1 + } + + if [[ -z "${bot_assignment}" ]]; then + echo " No automated assignment provenance for #${issue_number}; removing label only" + remove_label_only "${issue_number}" + return $? + fi + + local bot_login bot_assigned_at + bot_login="${bot_assignment%% *}" + bot_assigned_at="${bot_assignment#* }" + + echo " Bot-assigned @${bot_login} at ${bot_assigned_at}" + + if [[ "${bot_login}" == "${EXEMPT_LOGIN}" ]]; then + echo " Keeping @${EXEMPT_LOGIN} on #${issue_number} (exempt)" return 0 fi - IFS=',' read -ra ASSIGNEES <<<"${assignees_csv}" + local bot_assigned_epoch + bot_assigned_epoch="$(iso_to_epoch "${bot_assigned_at}" || true)" + if [[ -z "${bot_assigned_epoch}" ]]; then + echo " Warning: Could not parse assignment time '${bot_assigned_at}' for #${issue_number}; preserving state" >&2 + return 1 + fi - local keep=false - local assignee - for assignee in "${ASSIGNEES[@]}"; do - [[ -z "${assignee}" ]] && continue + if [[ "${bot_assigned_epoch}" -gt "${threshold_epoch}" ]]; then + echo " Assignment for #${issue_number} is newer than ${STALE_DAYS} days; keeping" + return 0 + fi - # Exempt acoliver only; other co-assignees can still be cleaned up. - if [[ "${assignee}" == "${EXEMPT_LOGIN}" ]]; then - echo " 🛡️ Keeping @${EXEMPT_LOGIN} on #${issue_number} (exempt)" - keep=true - continue - fi + # Use the same initial timeline snapshot for the initial linked-PR check + # (a separate fresh pre-delete snapshot is taken before DELETE). + local has_pr="false" + has_pr="$(has_qualifying_linked_pr_from_timeline "${bot_login}" "${bot_assigned_at}" "${initial_timeline_json}")" || { + echo " Warning: Linked PR query failed for #${issue_number}; preserving state" >&2 + return 1 + } - if ! assignment_age_ok_to_unassign "${issue_number}" "${assignee}" "${threshold_epoch}"; then - keep=true - continue - fi + if [[ "${has_pr}" == "true" ]]; then + echo " @${bot_login} has qualifying linked PR activity for #${issue_number}; keeping" + return 0 + fi - if has_linked_pr_activity "${issue_number}" "${assignee}"; then - echo " ✅ @${assignee} has linked PR activity for #${issue_number}; keeping" - keep=true - continue - fi + # Re-read current assignees and fetch ONE fresh timeline snapshot immediately + # before destructive DELETE. Both provenance revalidation and linked-PR + # revalidation run against this single snapshot, so a qualifying PR that + # appears between the initial check and the pre-delete revalidation is + # detected (race protection). + local pre_delete_assignees_json="" + pre_delete_assignees_json="$(get_issue_assignees_json "${issue_number}")" || { + echo " Warning: Pre-delete assignee read failed for #${issue_number}; preserving state" >&2 + return 1 + } - echo " 🧹 Unassigning stale @${assignee} from #${issue_number}" - if retry_gh gh issue edit "${issue_number}" --repo "${REPO}" \ - --remove-assignee "${assignee}" >/dev/null 2>&1; then - retry_gh gh issue comment "${issue_number}" --repo "${REPO}" --body "$(cat </dev/null 2>&1 || true - else - echo " ⚠️ Failed to unassign @${assignee} from #${issue_number}" >&2 - keep=true - fi - done + local pre_delete_timeline_json="" + pre_delete_timeline_json="$(fetch_issue_timeline "${issue_number}")" || { + echo " Warning: Pre-delete timeline read failed for #${issue_number}; preserving state" >&2 + return 1 + } - if [[ "${keep}" != "true" ]]; then - local remaining - remaining="$( - gh issue view "${issue_number}" --repo "${REPO}" --json assignees \ - --jq '.assignees | length' 2>/dev/null || echo "1" - )" - if [[ "${remaining}" -eq 0 ]]; then - retry_gh gh issue edit "${issue_number}" --repo "${REPO}" \ - --remove-label "${AUTO_ASSIGNED_LABEL}" >/dev/null 2>&1 || true - fi + local pre_delete_assignment="" + pre_delete_assignment="$(find_provenance_from_timeline "${pre_delete_assignees_json}" "${pre_delete_timeline_json}")" || { + echo " Warning: Pre-delete revalidation failed for #${issue_number}; preserving state" >&2 + return 1 + } + + if [[ -z "${pre_delete_assignment}" ]]; then + echo " Provenance changed before DELETE for #${issue_number}; human may have reassigned; removing label only" + remove_label_only "${issue_number}" + return $? + fi + + local revalidated_login revalidated_ts + revalidated_login="${pre_delete_assignment%% *}" + revalidated_ts="${pre_delete_assignment#* }" + if [[ "${revalidated_login}" != "${bot_login}" ]]; then + echo " Warning: Provenance login mismatch for #${issue_number}; preserving state" >&2 + return 0 + fi + if [[ "${revalidated_ts}" != "${bot_assigned_at}" ]]; then + echo " Warning: Provenance timestamp changed for #${issue_number}; preserving state" >&2 + return 0 + fi + + # Re-run the linked-PR predicate against the same fresh pre-delete snapshot. + # A qualifying PR that appeared between the initial check and this pre-delete + # revalidation must block deletion (race protection). + local pre_delete_has_pr="false" + pre_delete_has_pr="$(has_qualifying_linked_pr_from_timeline "${bot_login}" "${bot_assigned_at}" "${pre_delete_timeline_json}")" || { + echo " Warning: Pre-delete linked-PR revalidation failed for #${issue_number}; preserving state" >&2 + return 1 + } + + if [[ "${pre_delete_has_pr}" == "true" ]]; then + echo " @${bot_login} has qualifying linked PR activity (detected before DELETE) for #${issue_number}; keeping" + return 0 + fi + + # Verify bot_login is still assigned using exact jq index. + if ! echo "${pre_delete_assignees_json}" | jq -e --arg login "${bot_login}" '[.[].login] | index($login) != null' >/dev/null 2>&1; then + echo " @${bot_login} no longer assigned before DELETE for #${issue_number}; nothing to do" + return 0 + fi + + echo " Unassigning stale @${bot_login} from #${issue_number}" + + # Remove assignee first via targeted DELETE. + if ! remove_assignee_targeted "${issue_number}" "${bot_login}"; then + return 1 + fi + + # Immediately re-read and verify login is absent BEFORE label DELETE. + # If still present (successful no-op or post-handler race), retain marker. + local mid_delete_assignees_json="" + if ! mid_delete_assignees_json="$(get_issue_assignees_json "${issue_number}")"; then + echo " Warning: Post-assignee-DELETE verification read failed for #${issue_number}" >&2 + return 1 fi + + if echo "${mid_delete_assignees_json}" | jq -e --arg login "${bot_login}" '[.[].login] | index($login) != null' >/dev/null 2>&1; then + echo " Warning: @${bot_login} still assigned after DELETE (no-op or race); retaining marker for #${issue_number}" >&2 + return 1 + fi + + # Remove auto-assigned label via targeted DELETE. + if ! remove_label_targeted "${issue_number}" "${AUTO_ASSIGNED_LABEL}"; then + # Assignee was already removed but label DELETE failed. Report honestly + # that assignment was removed and leave the marker for a later retry. + echo " Assignment removed for #${issue_number}, but label remains for retry" >&2 + return 1 + fi + + # Re-read and verify final state using exact jq index. + local post_delete_assignees_json="" + post_delete_assignees_json="$(get_issue_assignees_json "${issue_number}")" || { + echo " Warning: Post-delete verification read failed for #${issue_number}" >&2 + return 1 + } + + if echo "${post_delete_assignees_json}" | jq -e --arg login "${bot_login}" '[.[].login] | index($login) != null' >/dev/null 2>&1; then + echo " Warning: @${bot_login} still assigned after DELETE for #${issue_number}" >&2 + return 1 + fi + + # Verify co-assignees were preserved using exact jq index. + local other_logins + other_logins="$(echo "${pre_delete_assignees_json}" | jq -r --arg bot "${bot_login}" '[.[].login] | map(select(. != $bot)) | .[]' 2>/dev/null || true)" + if [[ -n "${other_logins}" ]]; then + while IFS= read -r co_login; do + [[ -z "${co_login}" ]] && continue + if ! echo "${post_delete_assignees_json}" | jq -e --arg login "${co_login}" '[.[].login] | index($login) != null' >/dev/null 2>&1; then + echo " Warning: Co-assignee @${co_login} was unexpectedly removed from #${issue_number}" >&2 + return 1 + fi + done <<<"${other_logins}" + fi + + retry_gh gh api --method POST "repos/${REPO}/issues/${issue_number}/comments" \ + -f body="⏱️ Automatically unassigned @${bot_login} from this issue. + +This issue was auto-assigned via \`/assign\` more than **${STALE_DAYS} days** ago with no qualifying linked PR activity. Comment \`/assign\` again if you still plan to work on it (subject to eligibility and the 3-issue cap)." \ + --silent 2>/dev/null || true + + return 0 } -echo "📥 Scanning open issues with label:${AUTO_ASSIGNED_LABEL}" +echo " Scanning open issues with label:${AUTO_ASSIGNED_LABEL}" + +# Validate the canonical marker label definition before any discovery or +# mutation. A missing marker label means no automation-provenance issues can +# exist (discover_candidates would find none) — clean no-op. A conflicting +# definition or API error must fail closed with no mutation. +validate_marker_label || _marker_rc=$? +case ${_marker_rc:-0} in + 0) ;; # correct definition — proceed + 1) + echo "[OK] Marker label '${AUTO_ASSIGNED_LABEL}' is absent; nothing to clean" + exit 0 + ;; + 2) + echo "‼ Marker label '${AUTO_ASSIGNED_LABEL}' has a conflicting definition; aborting" >&2 + exit 1 + ;; + *) + echo "‼ Failed to validate marker label '${AUTO_ASSIGNED_LABEL}' (API error); aborting" >&2 + exit 1 + ;; +esac THRESHOLD_ISO="$(threshold_iso)" THRESHOLD_EPOCH="$(iso_to_epoch "${THRESHOLD_ISO}")" echo " Stale threshold: ${THRESHOLD_ISO} (epoch ${THRESHOLD_EPOCH})" -# List candidates: open + auto-assigned label. -mapfile -t CANDIDATES < <( - gh issue list --repo "${REPO}" --state open --label "${AUTO_ASSIGNED_LABEL}" \ - --limit 200 \ - --json number,assignees \ - --jq '.[] | "\(.number)\t\([.assignees[].login] | join(","))"' \ - 2>/dev/null || true -) +GLOBAL_FAIL=0 + +if ! CANDIDATES_RAW="$(discover_candidates)"; then + echo "‼️ Candidate discovery failed" >&2 + exit 1 +fi -if [[ "${#CANDIDATES[@]}" -eq 0 ]]; then +if [[ -z "${CANDIDATES_RAW}" || "${CANDIDATES_RAW}" == $'\n' ]]; then echo "✅ No auto-assigned open issues found" exit 0 fi +CANDIDATES=() +while IFS= read -r _line; do + [[ -n "${_line}" ]] && CANDIDATES+=("${_line}") +done <<<"${CANDIDATES_RAW}" + for row in "${CANDIDATES[@]}"; do [[ -z "${row}" ]] && continue issue_number="${row%%$'\t'*}" - assignees_csv="${row#*$'\t'}" - # Isolate per-issue failures so one bad issue does not abort the run. - if ! process_issue "${issue_number}" "${assignees_csv}" "${THRESHOLD_EPOCH}"; then - echo " ⚠️ Error processing #${issue_number}; continuing" >&2 + assignees_json="${row#*$'\t'}" + + if ! process_issue "${issue_number}" "${assignees_json}" "${THRESHOLD_EPOCH}"; then + echo " Warning: Error processing #${issue_number}; continuing" >&2 + GLOBAL_FAIL=1 fi done +if [[ "${GLOBAL_FAIL}" -ne 0 ]]; then + echo "⚠️ Cleanup completed with errors (some issues preserved)" >&2 + exit 1 +fi + echo "✅ Stale auto-assignment cleanup finished" exit 0 diff --git a/.github/workflows/assign-stale-cleanup.yml b/.github/workflows/assign-stale-cleanup.yml index 8358dadc57..a534831714 100644 --- a/.github/workflows/assign-stale-cleanup.yml +++ b/.github/workflows/assign-stale-cleanup.yml @@ -2,6 +2,9 @@ name: Assign Stale Cleanup # Unassigns issues that were auto-assigned via /assign but have had no linked # PR activity for two weeks. Never unassigns acoliver. +# +# Uses targeted DELETE endpoints (never whole-array PATCH) to preserve +# concurrent human state and comma-bearing label names. on: schedule: diff --git a/.github/workflows/assign.yml b/.github/workflows/assign.yml index 0cef85012f..5f14cb08d5 100644 --- a/.github/workflows/assign.yml +++ b/.github/workflows/assign.yml @@ -2,17 +2,27 @@ name: Assign Issue # Implements the CONTRIBUTING.md `/assign` self-assignment convention. # Comment body must be exactly `/assign` (optional trailing LF/CRLF/tab only). +# +# Also records issue assignment events in a durable label index so that +# /assign historical eligibility can use O(1) label lookups instead of +# scanning repository events. The assign job itself creates the history +# label directly (GitHub suppresses recursive issues:assigned workflows +# triggered by GITHUB_TOKEN, so the record-history job may not fire for +# automated assignments). on: issue_comment: types: - created + issues: + types: + - assigned permissions: contents: read issues: write - # Required for `gh search prs --merged` eligibility checks under an explicit - # permissions block (public repos are not exempt once permissions: is set). + # Required for merged-PR eligibility checks under an explicit permissions + # block (public repos are not exempt once permissions: is set). pull-requests: read defaults: @@ -30,17 +40,20 @@ jobs: # Exact `/assign` only (CONTRIBUTING.md). Support LF, CRLF, and trailing tab # via toJSON equality (not startsWith) so `/assign foo` never matches. # Ignore PR review comments and Bot accounts (spam hardening). + # + # No workflow concurrency: concurrent same-issue runs converge via + # postconditions enforced by assign-issue.sh (exactly-one-assignee + # verification, contention rollback, post-cap rollback). This ensures + # no valid comments are silently dropped. if: | + github.event_name == 'issue_comment' && github.event.issue.pull_request == null && + github.event.issue.state == 'open' && github.event.comment.user.type != 'Bot' && (github.event.comment.body == '/assign' || toJSON(github.event.comment.body) == '"/assign\n"' || toJSON(github.event.comment.body) == '"/assign\r\n"' || toJSON(github.event.comment.body) == '"/assign\t"') - # Per-issue cancel + per-actor serialize to blunt /assign floods. - concurrency: - group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ github.event.comment.user.login }} - cancel-in-progress: true steps: - name: Checkout repository uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 @@ -55,8 +68,39 @@ jobs: GITHUB_REPOSITORY: ${{ github.repository }} ISSUE_NUMBER: ${{ github.event.issue.number }} COMMENTER_LOGIN: ${{ github.event.comment.user.login }} - AUTHOR_ASSOCIATION: ${{ github.event.comment.author_association }} run: | set -euo pipefail chmod +x ./.github/scripts/assign-issue.sh ./.github/scripts/assign-issue.sh + + record-history: + name: Record assignment history + runs-on: ubuntu-latest + timeout-minutes: 5 + # Fires on issue assignment events for human/manual assignments. + # Automated /assign assignments also create the history label directly + # in assign-issue.sh because GitHub suppresses recursive + # issues:assigned workflows caused by GITHUB_TOKEN. + if: | + github.event_name == 'issues' && + github.event.action == 'assigned' + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # ratchet:actions/checkout@v5 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Record assignment-history label + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + ASSIGNEE_LOGIN: ${{ github.event.assignee.login }} + run: | + set -euo pipefail + chmod +x ./.github/scripts/record-assignment-history.sh + ./.github/scripts/record-assignment-history.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ca716bd3f..508edc456f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,12 +41,12 @@ for this purpose. To assign an issue to yourself, add a comment that contains **only** the text `/assign` (nothing else). A GitHub Action handles the request when: 1. The issue is not already assigned. -2. You are eligible: you have at least one **merged PR** in this repository, **or** you have previously been assigned an issue here, **or** you are listed in [`.github/trusted-contributors.txt`](.github/trusted-contributors.txt), **or** you are a repository owner/member/collaborator. -3. You currently have fewer than **3** open issues assigned to you (hard concurrency cap to limit spam / hoarding). +2. You are eligible: you have at least one **merged PR** in this repository, **or** you have a durable prior assignment (previously assigned to an issue here — current or past assignments in open or closed issues both qualify, recorded in a history index). +3. You currently have fewer than **3** open issues assigned to you (hard cap to limit spam / hoarding). -Bot accounts are ignored. On success the issue is labeled `auto-assigned`. If GitHub cannot assign you (for example, you do not have write access), the action posts feedback explaining the outcome so a maintainer can help. +Bot accounts are ignored. On success the issue is labeled `auto-assigned` and a feedback comment is posted. Assignment may fail for several reasons (the issue was assigned by a concurrent request, the eligibility check could not be verified, GitHub API errors, or the assignee lacks write access to the repository). The automation attempts to post feedback in failure cases. -Auto-assignments with **no linked PR activity for 2 weeks** may be unassigned automatically (maintainer `acoliver` is exempt as an assignee; other co-assignees can still be cleaned up). Comment `/assign` again if you still intend to work on the issue. +Auto-assignments older than **2 weeks** with no qualifying linked PR activity are unassigned automatically. Only the login assigned by the `/assign` automation is removed — manual co-assignees are preserved. The maintainer `acoliver` is exempt from cleanup. Comment `/assign` again if you still intend to work on the issue. ### Pull Request Guidelines diff --git a/scripts/tests/assign-helpers.js b/scripts/tests/assign-helpers.js new file mode 100644 index 0000000000..7f97fc8192 --- /dev/null +++ b/scripts/tests/assign-helpers.js @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Test helpers for the assignment automation behavioral tests. + * + * These helpers set up a stateful fake `gh` infrastructure adapter (a Python + * script that models GitHub REST API state transitions) and execute the REAL + * bash scripts against it. The fake gh is infrastructure — it models API + * state, not business logic. + */ + +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import * as path from 'path'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const FAKE_GH = path.join(import.meta.dirname, 'fake-gh.py'); + +/** + * Create a temporary directory representing a fake GitHub repo. + * Returns an object with methods to manage state and run scripts. + */ +export function createFakeRepo(initialState = {}) { + const dir = mkdtempSync(path.join(tmpdir(), 'assign-test-')); + const stateFile = path.join(dir, 'state.json'); + + // Create a bin dir with a `gh` wrapper that delegates to fake-gh.py + const binDir = path.join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const ghWrapper = path.join(binDir, 'gh'); + writeFileSync( + ghWrapper, + `#!/usr/bin/env bash\nexec python3 "${FAKE_GH}" "$@"\n`, + ); + execFileSync('chmod', ['+x', ghWrapper]); + + // Write trusted contributors file in the fake repo dir (unused after remediation + // but harmless to have present for compatibility) + const trustedFile = path.join(dir, 'trusted-contributors.txt'); + writeFileSync(trustedFile, 'acoliver\n'); + + const defaultState = { + now: '2025-07-23T00:00:00Z', + next_comment_id: 100, + issues: {}, + prs: {}, + comments: [], + labels: {}, + events: {}, + timeline: {}, + fail_config: {}, + ...initialState, + }; + + writeFileSync(stateFile, JSON.stringify(defaultState, null, 2)); + + const pathWithFakeGh = binDir + ':' + process.env.PATH; + + return { + dir, + stateFile, + trustedFile, + binDir, + + readState() { + return JSON.parse(readFileSync(stateFile, 'utf8')); + }, + + writeState(state) { + writeFileSync(stateFile, JSON.stringify(state, null, 2)); + }, + + updateState(updater) { + const state = this.readState(); + const updated = updater(state); + this.writeState(updated ?? state); + }, + + /** + * Run assign-issue.sh against the fake gh. + * Returns { stdout, stderr, status, state }. + */ + runAssign({ + issueNumber, + commenter, + authorAssociation = 'NONE', + extraEnv = {}, + }) { + const env = { + ...process.env, + GH_TOKEN: 'fake-token', + GITHUB_TOKEN: 'fake-token', + GITHUB_REPOSITORY: 'test/repo', + ISSUE_NUMBER: String(issueNumber), + COMMENTER_LOGIN: commenter, + AUTHOR_ASSOCIATION: authorAssociation, + GH_FAKE_STATE: stateFile, + ASSIGN_RETRY_DELAY: '0', + PATH: pathWithFakeGh, + ...extraEnv, + }; + try { + const stdout = execFileSync( + 'bash', + [path.join(ROOT, '.github/scripts/assign-issue.sh')], + { + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + return { stdout, stderr: '', status: 0, state: this.readState() }; + } catch (err) { + return { + stdout: err.stdout?.toString() ?? '', + stderr: err.stderr?.toString() ?? '', + status: err.status ?? 1, + state: this.readState(), + }; + } + }, + + /** + * Run unassign-stale-issues.sh against the fake gh. + */ + runCleanup({ extraEnv = {} } = {}) { + const env = { + ...process.env, + GH_TOKEN: 'fake-token', + GITHUB_TOKEN: 'fake-token', + GITHUB_REPOSITORY: 'test/repo', + GH_FAKE_STATE: stateFile, + ASSIGN_RETRY_DELAY: '0', + ASSIGN_NOW: '2025-07-23T00:00:00Z', + PATH: pathWithFakeGh, + ...extraEnv, + }; + try { + const stdout = execFileSync( + 'bash', + [path.join(ROOT, '.github/scripts/unassign-stale-issues.sh')], + { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + return { stdout, stderr: '', status: 0, state: this.readState() }; + } catch (err) { + return { + stdout: err.stdout?.toString() ?? '', + stderr: err.stderr?.toString() ?? '', + status: err.status ?? 1, + state: this.readState(), + }; + } + }, + }; +} + +/** + * Create a default state with the auto-assigned label defined. + */ +export function defaultState() { + return { + now: '2025-07-23T00:00:00Z', + next_comment_id: 100, + issues: {}, + prs: {}, + comments: [], + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0E8A16', + description: 'Assigned via /assign automation', + }, + }, + events: {}, + timeline: {}, + fail_config: {}, + }; +} + +/** + * Create an issue with the given properties. + */ +export function makeIssue({ + number, + assignees = [], + labels = [], + state = 'open', + title = `Issue ${number}`, + body = '', + createdAt = '2025-06-01T00:00:00Z', +}) { + return { + number, + title, + body, + state, + created_at: createdAt, + updated_at: createdAt, + _assignees: assignees, + _label_names: labels, + user: { login: 'reporter', type: 'User' }, + pull_request: undefined, + }; +} + +/** + * Create a PR in the fake state. + */ +export function makePR({ + number, + author, + merged = false, + title = '', + body = '', + mergedAt = null, + repoUrl = null, +}) { + const actualMergedAt = mergedAt ?? (merged ? '2025-06-15T00:00:00Z' : null); + const repositoryUrl = repoUrl ?? 'https://api.github.com/repos/test/repo'; + return { + number, + title, + body, + state: merged ? 'closed' : 'open', + merged_at: actualMergedAt, + created_at: '2025-06-01T00:00:00Z', + updated_at: actualMergedAt ?? '2025-06-01T00:00:00Z', + user: { login: author, type: 'User' }, + _assignees: [], + _label_names: [], + pull_request: { url: '' }, + repository_url: repositoryUrl, + }; +} + +/** + * Create an "assigned" timeline event. + */ +export function makeAssignedEvent({ + number: _number, + assignee, + actor = 'github-actions[bot]', + createdAt = '2025-07-01T00:00:00Z', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'assigned', + actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, + assignee: { login: assignee }, + created_at: createdAt, + }; +} + +/** + * Create a "cross-referenced" timeline event (a linked PR). + * repositoryUrl defaults to same-repo; use null to test cross-repo scenario + * (the script must reject cross-repo PRs). + */ +export function makeCrossRefEvent({ + number: _number, + prNumber, + prAuthor, + createdAt = '2025-07-05T00:00:00Z', + repositoryUrl = 'https://api.github.com/repos/test/repo', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'cross-referenced', + actor: { login: prAuthor, type: 'User' }, + source: { + issue: { + number: prNumber, + title: `PR #${prNumber}`, + pull_request: { url: '' }, + user: { login: prAuthor, type: 'User' }, + repository_url: repositoryUrl, + }, + }, + created_at: createdAt, + }; +} + +/** + * Create a "labeled" timeline event. + */ +export function makeLabeledEvent({ + number: _number, + label, + actor = 'github-actions[bot]', + createdAt = '2025-07-01T00:00:00Z', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'labeled', + actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, + label: { name: label }, + created_at: createdAt, + }; +} + +/** + * Create an "unlabeled" timeline event. + */ +export function makeUnlabeledEvent({ + number: _number, + label, + actor = 'github-actions[bot]', + createdAt = '2025-07-02T00:00:00Z', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'unlabeled', + actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, + label: { name: label }, + created_at: createdAt, + }; +} + +/** + * Create an "unassigned" timeline event. + */ +export function makeUnassignedEvent({ + number: _number, + assignee, + actor = 'github-actions[bot]', + createdAt = '2025-07-02T00:00:00Z', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'unassigned', + actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, + assignee: { login: assignee }, + created_at: createdAt, + }; +} + +/** + * Create a "closed" timeline event (indicating a linked PR was merged/closed). + */ +export function makeClosedEvent({ + number: _number, + createdAt = '2025-07-10T00:00:00Z', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'closed', + actor: { login: 'someone', type: 'User' }, + created_at: createdAt, + }; +} + +/** + * ISO timestamp N days before a reference date (default: now=2025-07-23). + */ +export function daysAgo(days, refDate = '2025-07-23T00:00:00Z') { + const d = new Date(refDate); + d.setDate(d.getDate() - days); + return d.toISOString().replace(/\.\d+Z$/, 'Z'); +} + +/** + * Generate N filler timeline events (to push the event of interest to a later + * page in paginated output). + */ +export function makeFillerEvents( + count, + { + event = 'labeled', + actor = 'someone', + createdAt = '2025-07-01T00:00:00Z', + label = 'bug', + } = {}, +) { + const events = []; + for (let i = 0; i < count; i++) { + events.push({ + id: 900000 + i, + event, + actor: { login: actor, type: 'User' }, + label: { name: `${label}-${i}` }, + created_at: createdAt, + }); + } + return events; +} + +/** + * Build a structured fail_config that targets a specific method + endpoint + * on its nth occurrence (1-indexed). + * + * failOnNth({ method: 'POST', endpoint: 'repos/test/repo/issues/42/labels', on_nth: 1, type: 'error' }) + * failOnNth({ method: 'GET', endpoint: 'repos/test/repo/labels/foo', type: 'not_found', http_status: 404 }) + */ +export function failOnNth({ + method, + endpoint, + on_nth = 1, + type = 'error', + http_status, +}) { + const req = { method, endpoint, on_nth, type }; + if (http_status !== undefined) { + req.http_status = http_status; + } + return { + requests: [req], + }; +} diff --git a/scripts/tests/assign-remediation.test.js b/scripts/tests/assign-remediation.test.js new file mode 100644 index 0000000000..6380d9488b --- /dev/null +++ b/scripts/tests/assign-remediation.test.js @@ -0,0 +1,678 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 remediation findings. + * + * Each test targets a specific blocking finding from code review: + * F1: paginated JSON aggregation + * F2: --input - for array mutations (fake-gh rejects string-typed arrays) + * F3: fail-closed label reads/removals + * F4: sticky feedback lookup failure vs absence + * F5: cap re-read before assignment (race) + * F6: assignment verification nonzero failure + * F7: jq output schema validation + * F8: discover_candidates error propagation + * + * These execute the REAL bash scripts against the fake gh infrastructure. + */ + +import { describe, expect, it } from 'vitest'; +import { writeFileSync } from 'fs'; +import { join } from 'path'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeCrossRefEvent, + makeLabeledEvent, + makeFillerEvents, + daysAgo, +} from './assign-helpers.js'; + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +// --------------------------------------------------------------------------- +// F1: Paginated JSON aggregation +// --------------------------------------------------------------------------- + +describe('F1: paginated JSON aggregation', () => { + it('historical assignment via backfill file is detected (assign-issue.sh)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + }), + ); + // Simulate a large backfill file where 'bob' is on a "later page" + // (the grep is O(1) per-line but file can be large) + const logins = []; + for (let i = 0; i < 500; i++) { + logins.push(`filler-user-${i}`); + } + logins.push('bob'); + const historyFile = join(repo.dir, 'assignment-history.txt'); + writeFileSync( + historyFile, + logins.join(String.fromCharCode(10)) + String.fromCharCode(10), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { + ASSIGNMENT_HISTORY_FILE: join(repo.dir, 'assignment-history.txt'), + }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('bob'); + }); + + it('cleanup retains assignment when cross-ref is on a later page', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + page_size: 2, + issues: { + 42: makeIssue({ + number: 42, + assignees: ['active-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'active-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ...makeFillerEvents(2), + makeCrossRefEvent({ + number: 42, + prNumber: 200, + prAuthor: 'active-user', + createdAt: daysAgo(15), + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('active-user'); + }); + + it('cleanup unassigns stale user when bot-assign is on a later page', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + page_size: 2, + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + ...makeFillerEvents(2), + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); +}); + +// --------------------------------------------------------------------------- +// F2: --input - for array mutations (fake-gh rejects string-typed arrays) +// --------------------------------------------------------------------------- + +describe('F2: array mutations via --input -', () => { + it('assign-issue.sh sends valid JSON arrays (not strings)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + labels: ['bug'], + }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + expect(result.state.issues['42']._label_names).toContain('bug'); + }); + + it('cleanup uses targeted DELETE for unassign (not whole-array PATCH)', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned', 'bug'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + expect(result.state.issues['42']._label_names).toContain('bug'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); +}); + +// --------------------------------------------------------------------------- +// F3: fail-closed label reads and removals +// --------------------------------------------------------------------------- + +describe('F3: fail-closed label reads/removals', () => { + it('cleanup fails nonzero and preserves state when label read fails', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned', 'bug', 'important'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + fail_config: { + 'repos/test/repo/issues/42': 'error', + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('stale-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + expect(result.state.issues['42']._label_names).toContain('bug'); + expect(result.state.issues['42']._label_names).toContain('important'); + }); + + it('cleanup fails nonzero and preserves state when label-removal DELETE fails', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + labels: ['auto-assigned', 'important'], + }), + }, + timeline: { + 42: [ + makeAssignedEvent({ + number: 42, + assignee: 'someone', + actor: 'human-admin', + createdAt: assignedAt, + }), + ], + }, + fail_config: { + 'repos/test/repo/issues/42/labels/auto-assigned': 'error', + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + expect(result.state.issues['42']._label_names).toContain('important'); + }); +}); + +// --------------------------------------------------------------------------- +// F4: sticky feedback lookup failure vs absence +// --------------------------------------------------------------------------- + +describe('F4: sticky feedback lookup failure vs absence', () => { + it('comment lookup failure does not create a duplicate comment and exits nonzero', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + comments: [ + { + id: 777, + issue_number: 42, + body: '\nPrior feedback', + user: { login: 'github-actions[bot]', type: 'Bot' }, + created_at: '2025-07-20T00:00:00Z', + updated_at: '2025-07-20T00:00:00Z', + }, + ], + fail_config: { + 'repos/test/repo/issues/42/comments': 'error', + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Nonzero: infrastructure failure during feedback lookup must not be + // masked as a normal exit. + expect(result.status).not.toBe(0); + // No duplicate: the pre-existing marker comment is unchanged and no + // new comment was posted (POST would also fail since the endpoint + // is configured to error). + const markerComments = result.state.comments.filter( + (c) => + c.issue_number === 42 && + c.user.login === 'github-actions[bot]' && + c.body.startsWith(''), + ); + expect(markerComments.length).toBe(1); + expect(markerComments[0].body).toBe( + '\nPrior feedback', + ); + }); +}); + +// --------------------------------------------------------------------------- +// F4b: stderr separation in post_sticky_feedback and label validation +// --------------------------------------------------------------------------- + +describe('F4b: stderr separation (JSON stdout not corrupted by stderr)', () => { + it('stderr warnings during comment fetch do not corrupt feedback lookup', () => { + // gh writes a deprecation warning to stderr while still returning valid + // JSON on stdout. The script must parse stdout-only and post feedback. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + stderr_warnings: [ + { + method: 'GET', + endpoint: 'repos/test/repo/issues/42/comments', + message: 'WARNING: API deprecation notice', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Should succeed — stderr warnings must not corrupt the JSON stdout + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + // Feedback was posted despite stderr warning + const feedbackComments = result.state.comments.filter( + (c) => + c.issue_number === 42 && + c.user.login === 'github-actions[bot]' && + c.body.startsWith(''), + ); + expect(feedbackComments.length).toBeGreaterThanOrEqual(1); + }); + + it('stderr warnings during merged-PR search do not corrupt count', () => { + // gh writes a warning to stderr during the merged PR search, but the + // JSON response on stdout is valid. The script must parse stdout only. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + stderr_warnings: [ + { + method: 'GET', + endpoint: 'search/issues', + message: 'WARNING: search rate limit approaching', + }, + ], + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'alice', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // alice has no merged PR in fake state, so should be refused for eligibility + // But the key is that the script doesn't crash due to stderr in stdout + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + }); + + it('stderr warnings during history-label GET do not corrupt absence detection', () => { + // gh writes a warning to stderr while the label GET returns 404. + // The 404 (absence) must still be detected from stderr, not from stdout. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + stderr_warnings: [ + { + method: 'GET', + endpoint: 'repos/test/repo/labels/asnhist--alice', + message: 'WARNING: retrying request', + }, + ], + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'alice', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // Should succeed — alice has a merged PR so is eligible + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + }); +}); + +// --------------------------------------------------------------------------- +// F5: cap re-read before assignment (race condition) +// --------------------------------------------------------------------------- + +describe('F5: cap re-read before assignment', () => { + it('refuses assignment when issue gets assigned by concurrent run before POST', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + side_effects: [ + { + method: 'GET', + endpoint: 'repos/test/repo/issues/42', + on_nth: 2, + action: 'add_assignee', + issue: 42, + assignee: 'someone-else', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.state.issues['42']._assignees).not.toContain('alice'); + expect(result.state.issues['42']._assignees).toContain('someone-else'); + }); +}); + +// --------------------------------------------------------------------------- +// F6: assignment verification nonzero failure +// --------------------------------------------------------------------------- + +describe('F6: assignment verification failure', () => { + it('fails nonzero when post-assignment verification returns malformed JSON', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + fail_config: { + 'repos/test/repo/issues/42': 'malformed', + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + }); +}); + +// --------------------------------------------------------------------------- +// F7: jq output schema validation +// --------------------------------------------------------------------------- + +describe('F7: jq output schema validation', () => { + it('assign fails closed when issue detail returns malformed JSON', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + fail_config: { + 'repos/test/repo/issues/42': 'malformed', + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + }); + + it('cleanup fails closed when issue detail returns malformed JSON', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + fail_config: { + 'repos/test/repo/issues/42': 'malformed', + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('stale-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('cleanup fails closed when timeline returns malformed JSON', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + ], + }, + fail_config: { + 'repos/test/repo/issues/42/timeline': 'malformed', + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); +}); + +// --------------------------------------------------------------------------- +// F8: discover_candidates error propagation +// --------------------------------------------------------------------------- + +describe('F8: discover_candidates error propagation', () => { + it('cleanup fails nonzero when candidate discovery API fails', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + ], + }, + fail_config: { + 'repos/test/repo/issues': 'error', + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); +}); diff --git a/scripts/tests/assign-remediation2.test.js b/scripts/tests/assign-remediation2.test.js new file mode 100644 index 0000000000..76347e44ee --- /dev/null +++ b/scripts/tests/assign-remediation2.test.js @@ -0,0 +1,931 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 deep-review remediation findings. + * + * These tests target the specific architectural requirements: + * A: No repository-wide events scan; backfill + history label + * B: Targeted REST mutations (POST/DELETE, not whole-array PATCH) + * C: Corrected cleanup provenance (human reassignment not mistaken for bot) + * D: Cross-reference same-repo qualification + * E: PR filtering from all issue paths + * F: Per-issue concurrency and post-cap rollback + * + * These execute the REAL bash scripts against the fake gh infrastructure. + */ + +import { describe, expect, it } from 'vitest'; +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import * as nodePath from 'path'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + makeUnassignedEvent, + makeCrossRefEvent, + daysAgo, + failOnNth, +} from './assign-helpers.js'; + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +function writeBackfill(repo, logins) { + const filePath = nodePath.join(repo.dir, 'assignment-history.txt'); + writeFileSync(filePath, logins.join('\n') + '\n'); + return filePath; +} + +// =========================================================================== +// A: No repository-wide events scan; backfill + history label +// =========================================================================== + +describe('A: Durable indexed history (no events scan)', () => { + it('current NON-PR issue assignment qualifies (cheap search first)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 50: makeIssue({ number: 50, assignees: ['bob'] }), + }, + }), + ); + // No backfill, no history label — but bob is currently assigned to #50 + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('bob'); + }); + + it('static backfill exact-line lookup qualifies', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + }), + ); + const historyFile = writeBackfill(repo, ['alice', 'bob', 'charlie']); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: historyFile }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('bob'); + }); + + it('backfill does not match substrings (exact-line only)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + }), + ); + // 'bob' should NOT match 'bobby' + const historyFile = writeBackfill(repo, ['bobby', 'alice']); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: historyFile }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('bob'); + }); + + it('per-login history label O(1) GET qualifies', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0E8A16', + description: 'Assigned via /assign automation', + }, + 'asnhist--bob': { + name: 'asnhist--bob', + color: '0E8A16', + description: 'Issue assignment history index', + }, + }, + }), + ); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('bob'); + }); + + it('current PR assignment does NOT qualify (is:issue filter)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { + 99: makePR({ number: 99, author: 'newbie', merged: false }), + }, + }), + ); + // newbie is assigned to PR #99 but not to any issue + repo.updateState((s) => { + s.prs['99']._assignees = ['newbie']; + }); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'newbie', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // Should be refused — PR assignment doesn't qualify + expect(result.state.issues['42']._assignees).not.toContain('newbie'); + }); + + it('merged PR still qualifies (independent of history)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { + 100: makePR({ number: 100, author: 'contributor', merged: true }), + }, + }), + ); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'contributor', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('contributor'); + }); +}); + +// =========================================================================== +// B: Targeted REST mutations (POST/DELETE, not whole-array PATCH) +// =========================================================================== + +describe('B: Targeted REST mutations', () => { + it('preserves comma-bearing labels during assignment', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + labels: ['priority: high, urgent'], + }), + }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + const issue = result.state.issues['42']; + expect(issue._assignees).toContain('alice'); + expect(issue._label_names).toContain('auto-assigned'); + // Comma-bearing label preserved exactly + expect(issue._label_names).toContain('priority: high, urgent'); + }); + + it('preserves comma-bearing labels during cleanup', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned', 'priority: high, urgent'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + const issue = result.state.issues['42']; + expect(issue._assignees).not.toContain('stale-user'); + expect(issue._label_names).not.toContain('auto-assigned'); + // Comma-bearing label preserved exactly + expect(issue._label_names).toContain('priority: high, urgent'); + }); + + it('preserves human co-assignee added between read and mutation', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + side_effects: [ + { + method: 'DELETE', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 42, + assignee: 'human-contributor', + }, + ], + }), + ); + + const result = repo.runCleanup(); + + // stale-user should be removed, human-contributor should survive + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + expect(result.state.issues['42']._assignees).toContain('human-contributor'); + }); + + it('preserves human label added between read and mutation', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + side_effects: [ + { + method: 'DELETE', + endpoint: 'repos/test/repo/issues/42/labels/auto-assigned', + on_nth: 1, + action: 'add_label', + issue: 42, + label: 'human-added-label', + }, + ], + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + // Human-added label must survive + expect(result.state.issues['42']._label_names).toContain( + 'human-added-label', + ); + }); + + it('silently drops unassignable user and rolls back label', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + unassignable_logins: ['alice'], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // alice was silently dropped (no collaborator access) + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // Label should have been rolled back + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('rolls back label when assignee POST fails (partial state)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // Label was added then rolled back + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('post-mutation cap rollback removes only this run assignee', () => { + // Alice starts at the cap. The assignee POST also injects an unrelated + // concurrent assignee on issue 1; after Alice is added to #42, her own + // open count exceeds the cap and only this run's assignment is rolled back. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + 2: makeIssue({ number: 2, assignees: ['alice'] }), + 3: makeIssue({ number: 3, assignees: ['alice'] }), + }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 1, + assignee: 'concurrent-user', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // alice has 3 already + 1 from our POST = 4 > 3 + // Should roll back alice from #42 + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // auto-assigned label should also be rolled back + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('jq @uri encodes special-character label names for DELETE', () => { + // The cleanup script uses `jq -sRr '@uri'` to URL-encode label names. + // This verifies that special characters (comma, space, slash) are properly + // encoded so the DELETE endpoint receives the correct path. The raw+slurp + // mode produces a single string (not an array), which is the correct + // behavior for @uri. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned', 'priority: high/urgent'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // auto-assigned was removed + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + // Special-character label survives (was not accidentally removed by + // malformed URL encoding) + expect(result.state.issues['42']._label_names).toContain( + 'priority: high/urgent', + ); + }); +}); + +// =========================================================================== +// C: Corrected cleanup provenance +// =========================================================================== + +describe('C: Cleanup provenance (human reassignment not bot)', () => { + it('bot assigned, human unassigned, human reassigned — never remove', () => { + const botAssignedAt = daysAgo(25); + const humanUnassignedAt = daysAgo(20); + const humanReassignedAt = daysAgo(15); + + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['someuser'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: botAssignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'someuser', + actor: 'github-actions[bot]', + createdAt: botAssignedAt, + }), + makeUnassignedEvent({ + number: 42, + assignee: 'someuser', + actor: 'human-admin', + createdAt: humanUnassignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'someuser', + actor: 'human-admin', + createdAt: humanReassignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // The latest transition for someuser is assigned by human, NOT bot + // So this is NOT an automated assignment — must NOT remove + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('someuser'); + }); + + it('bot assigned only (latest transition is bot) — eligible for removal', () => { + const botAssignedAt = daysAgo(25); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: botAssignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: botAssignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); + + it('bot assigned, bot unassigned, bot reassigned — latest is bot assign, eligible', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + makeUnassignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(15), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(15), + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // Latest transition is bot-assigned → eligible for removal + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); +}); + +// =========================================================================== +// D: Cross-reference same-repo qualification +// =========================================================================== + +describe('D: Cross-reference same-repo qualification', () => { + it('same-repo PR by assignee after assignment — retains', () => { + const assignedAt = daysAgo(25); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['active-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'active-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeCrossRefEvent({ + number: 42, + prNumber: 200, + prAuthor: 'active-user', + createdAt: daysAgo(20), + repositoryUrl: 'https://api.github.com/repos/test/repo', + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('active-user'); + }); + + it('cross-repo PR by assignee — does NOT qualify, unassigns', () => { + const assignedAt = daysAgo(25); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeCrossRefEvent({ + number: 42, + prNumber: 200, + prAuthor: 'stale-user', + createdAt: daysAgo(20), + repositoryUrl: 'https://api.github.com/repos/other/fork', + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // Cross-repo PR does NOT qualify — should unassign + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); + + it('PR author does not match assignee — does NOT qualify', () => { + const assignedAt = daysAgo(25); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeCrossRefEvent({ + number: 42, + prNumber: 200, + prAuthor: 'someone-else', + createdAt: daysAgo(20), + repositoryUrl: 'https://api.github.com/repos/test/repo', + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // PR author != assignee → does NOT qualify → unassigns + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); + + it('PR linked before assignment — does NOT qualify', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + // PR linked BEFORE assignment + makeCrossRefEvent({ + number: 42, + prNumber: 200, + prAuthor: 'stale-user', + createdAt: daysAgo(25), + repositoryUrl: 'https://api.github.com/repos/test/repo', + }), + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // PR was linked before the assignment → does NOT qualify → unassigns + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); +}); + +// =========================================================================== +// E: PR filtering from all issue paths +// =========================================================================== + +describe('E: PR filtering', () => { + it('PR assignment does NOT qualify for /assign eligibility', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { + 99: makePR({ number: 99, author: 'newbie', merged: false }), + }, + }), + ); + // Assign newbie to a PR (not an issue) + repo.updateState((s) => { + s.prs['99']._assignees = ['newbie']; + }); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'newbie', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + expect(result.state.issues['42']._assignees).not.toContain('newbie'); + }); + + it('PR with auto-assigned label never enters cleanup', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: {}, + prs: { + 99: makePR({ number: 99, author: 'someone', merged: false }), + }, + }), + ); + // Put auto-assigned label on a PR + repo.updateState((s) => { + s.prs['99']._label_names = ['auto-assigned']; + s.prs['99']._assignees = ['someone']; + }); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // PR should not be processed by cleanup (discover_candidates filters PRs) + expect(result.state.prs['99']._assignees).toContain('someone'); + expect(result.state.prs['99']._label_names).toContain('auto-assigned'); + }); +}); + +// =========================================================================== +// F: Per-issue concurrency and post-cap rollback +// =========================================================================== + +describe('F: Per-issue concurrency and post-cap rollback', () => { + it('assign job has NO concurrency block (postconditions handle races)', async () => { + const yaml = (await import('js-yaml')).default; + const fs = await import('fs'); + const source = fs.readFileSync( + nodePath.join( + import.meta.dirname, + '../..', + '.github/workflows/assign.yml', + ), + 'utf8', + ); + const workflow = yaml.load(source); + expect(workflow.jobs, 'workflow should have jobs').toBeDefined(); + expect(workflow.jobs.assign, 'assign job should exist').toBeDefined(); + expect(workflow.jobs.assign.concurrency).toBeUndefined(); + }); + + it('race: concurrent assignment pushes over cap, rollback occurs', () => { + // alice has 2 open issues. Our run will assign her to #42 (making 3). + // But a side_effect on the POST will also assign her to #50 (making 4). + // After our mutation, the post-mutation cap check should roll back. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + 2: makeIssue({ number: 2, assignees: ['alice'] }), + 50: makeIssue({ number: 50, assignees: [] }), + }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 50, + assignee: 'alice', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Post-mutation: alice has 4 open issues (1, 2, 42, 50) > 3 + // Should roll back alice from #42 + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // #50 should still have alice (that was the concurrent assignment) + expect(result.state.issues['50']._assignees).toContain('alice'); + }); +}); + +// =========================================================================== +// Backfill file structure tests +// =========================================================================== + +describe('Backfill file semantics', () => { + it('.github/assignment-history.txt exists and is sorted unique', () => { + const filePath = nodePath.join( + import.meta.dirname, + '../..', + '.github/assignment-history.txt', + ); + expect(existsSync(filePath)).toBe(true); + const content = readFileSync(filePath, 'utf8'); + const lines = content.trim().split('\n'); + expect(lines.length).toBeGreaterThan(0); + // All lines non-empty + expect(lines.every((l) => l.trim().length > 0)).toBe(true); + // No bots + expect(lines.every((l) => !l.endsWith('[bot]'))).toBe(true); + // Sorted + const sorted = [...lines].sort(); + expect(lines).toEqual(sorted); + // Unique + expect(new Set(lines).size).toBe(lines.length); + }); +}); diff --git a/scripts/tests/assign-remediation3.test.js b/scripts/tests/assign-remediation3.test.js new file mode 100644 index 0000000000..278ea679ff --- /dev/null +++ b/scripts/tests/assign-remediation3.test.js @@ -0,0 +1,849 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 deep-review remediation (Round 3). + * + * Addresses findings from the fresh review: + * 1: Durable history for /assign itself (direct label creation) + * 2: Short history label prefix asnhist-- (supports 39-char usernames) + * 3: Pre-existing auto-assigned marker detection + * 4: Final cap read must fail closed + * 5: No concurrency coalescing (assign job has no concurrency block) + * 6: History lookup tri-state (present/absent/error) + * 7: Cleanup provenance (label-event correlation) + * 8: Cleanup recovery and exact membership + * 9: Fake fidelity (tested indirectly via behavioral assertions) + * 10: URL encoding (tested indirectly) + * 11: Docs accuracy + * + * These execute the REAL bash scripts against the fake gh infrastructure. + */ + +import { describe, expect, it } from 'vitest'; +import yaml from 'js-yaml'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + daysAgo, + failOnNth, +} from './assign-helpers.js'; +import { readRootFile } from './ocr-review-workflow-helpers.js'; +const HISTORY_PREFIX = 'asnhist--'; +const HISTORY_DESC = 'Issue assignment history index'; +const HISTORY_COLOR = '0E8A16'; +const AUTO_LABEL_DESC = 'Assigned via /assign automation'; +const AUTO_LABEL_COLOR = '0E8A16'; + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +function makeLabeledEvent({ + number: _n, + label = 'auto-assigned', + actor = 'github-actions[bot]', + createdAt = '2025-07-01T00:00:00Z', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'labeled', + actor: { + login: actor, + type: actor.endsWith('[bot]') ? 'Bot' : 'User', + }, + label: { name: label }, + created_at: createdAt, + }; +} + +function makeUnlabeledEvent({ + number: _n, + label = 'auto-assigned', + actor = 'github-actions[bot]', + createdAt = '2025-07-02T00:00:00Z', +}) { + return { + id: Math.floor(Math.random() * 100000), + event: 'unlabeled', + actor: { + login: actor, + type: actor.endsWith('[bot]') ? 'Bot' : 'User', + }, + label: { name: label }, + created_at: createdAt, + }; +} + +// =========================================================================== +// 1: Durable history for /assign itself +// =========================================================================== + +describe('1: Durable history for /assign itself', () => { + it('successful /assign creates per-user history label directly', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + // Primary assignee must be set + expect(result.state.issues['42']._assignees).toContain('alice'); + // The per-user history label must exist after successful assignment + const historyLabelName = `${HISTORY_PREFIX}alice`; + expect(result.state.labels[historyLabelName]).toBeDefined(); + expect(result.state.labels[historyLabelName].color).toBe(HISTORY_COLOR); + expect(result.state.labels[historyLabelName].description).toBe( + HISTORY_DESC, + ); + }); + + it('successful /assign leaves durable history even without another workflow event', () => { + // Simulate: only issue_comment /assign triggers the workflow. GitHub + // suppresses recursive issues:assigned events from GITHUB_TOKEN, so + // the record-history job may never fire. The assign job itself must + // create the durable history label. + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'bob', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'bob' }); + + expect(result.status).toBe(0); + // History label must exist regardless of record-history job + const historyLabelName = `${HISTORY_PREFIX}bob`; + expect(result.state.labels[historyLabelName]).toBeDefined(); + }); + + it('history label creation failure rolls back assignment', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/labels', + on_nth: 1, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + // alice should be rolled back from #42 + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // auto-assigned label should be rolled back + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); +}); + +// =========================================================================== +// 2: Short label prefix asnhist-- + exact definition validation +// =========================================================================== + +describe('2: Short history label prefix and definition validation', () => { + it('uses asnhist-- prefix (9 chars + 39-char username = 48 <= 50)', () => { + const longUsername = 'a'.repeat(39); + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { + 100: makePR({ number: 100, author: longUsername, merged: true }), + }, + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: longUsername, + }); + + expect(result.status).toBe(0); + const labelName = `${HISTORY_PREFIX}${longUsername}`; + expect(labelName.length).toBeLessThanOrEqual(50); + expect(result.state.labels[labelName]).toBeDefined(); + }); + + it('existing history label with correct definition qualifies for eligibility', () => { + const historyLabel = `${HISTORY_PREFIX}bob`; + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: AUTO_LABEL_COLOR, + description: AUTO_LABEL_DESC, + }, + [historyLabel]: { + name: historyLabel, + color: HISTORY_COLOR, + description: HISTORY_DESC, + }, + }, + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('bob'); + }); + + it('existing label with WRONG description does NOT qualify (collision)', () => { + // A human-created label with the same name but wrong description must + // NOT grant eligibility — this is a collision. + const historyLabel = `${HISTORY_PREFIX}bob`; + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: AUTO_LABEL_COLOR, + description: AUTO_LABEL_DESC, + }, + [historyLabel]: { + name: historyLabel, + color: 'FF0000', // wrong color + description: 'Some human label', // wrong description + }, + }, + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // Collision means not-eligible — expected user-facing refusal (exit 0) + expect(result.status).toBe(0); + // Must NOT assign — collision with human label + expect(result.state.issues['42']._assignees).not.toContain('bob'); + }); + + it('validates/reconciles shared auto-assigned label definition', () => { + // The script must not silently repurpose a human label named auto-assigned + // with conflicting definition + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: 'FF0000', // conflicting color + description: 'Human label', // conflicting description + }, + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Must fail closed — conflicting definition + expect(result.status).not.toBe(0); + // Should NOT have silently repurposed + expect(result.state.labels['auto-assigned'].color).toBe('FF0000'); + }); +}); + +// =========================================================================== +// 3: Pre-existing auto-assigned marker +// =========================================================================== + +describe('3: Pre-existing auto-assigned marker', () => { + it('unassigned issue already carrying auto-assigned label fails closed', () => { + // Issue has auto-assigned label but no assignee — inconsistent provenance + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + labels: ['auto-assigned'], + }), + }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Must fail closed — do not assign, never claim ownership + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + }); + + it('exact rollback regression: label added then assignee fails', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // The auto-assigned label that was added by this run must be removed + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); +}); + +// =========================================================================== +// 4: Final cap read must fail closed +// =========================================================================== + +describe('4: Final cap read fail-closed', () => { + it('post-mutation count GET failure rolls back assignment (fail closed)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + // Fail the REST issues endpoint on the LAST call (post-mutation count) + fail_config: failOnNth({ + method: 'GET', + endpoint: 'repos/test/repo/issues', + on_nth: 3, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Must fail closed — post-mutation count failure + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('nth-occurrence test: final count GET fails on 3rd occurrence', () => { + // Track the op log to verify which GET failed + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + fail_config: failOnNth({ + method: 'GET', + endpoint: 'repos/test/repo/issues', + on_nth: 3, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + // The 3rd REST issues GET should be the failure + const restGets = (result.state._op_log || []).filter( + (op) => op.endpoint === 'repos/test/repo/issues' && op.method === 'GET', + ); + expect(restGets.length).toBeGreaterThanOrEqual(3); + // The 3rd one should be failed + expect(restGets[2].status).toBe('failed'); + }); +}); + +// =========================================================================== +// 5: No concurrency coalescing +// =========================================================================== + +describe('5: No concurrency coalescing', () => { + it('assign job has NO concurrency block in assign.yml', () => { + const source = readRootFile('.github/workflows/assign.yml'); + const workflow = yaml.load(source); + // The assign job must NOT have a concurrency key at all + expect(workflow.jobs.assign.concurrency).toBeUndefined(); + }); + + it('assign.yml has no mention of concurrency coalescing', () => { + const source = readRootFile('.github/workflows/assign.yml'); + // Should not mention coalescing in comments + expect(source).not.toMatch(/coalesc/i); + }); + + it('postcondition: after assignee POST, assignee array contains exactly commenter', () => { + // Normal successful assignment: exactly one assignee = commenter + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toEqual(['alice']); + }); + + it('contention: side-effect adds second assignee during POST → rollback commenter only', () => { + // A concurrent run adds a second assignee right after our POST. + // Postcondition: assignees should NOT contain exactly our commenter alone. + // Script must detect contention and rollback only this run's commenter. + // Per contention-marker design: when a competing winner remains, + // the auto-assigned label is PRESERVED (not removed), since the + // remaining assignee may legitimately own it. + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 42, + assignee: 'concurrent-user', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // alice must be rolled back (contention detected) + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // The concurrent assignee remains (not our responsibility to remove) + expect(result.state.issues['42']._assignees).toContain('concurrent-user'); + // Marker is PRESERVED when a competing winner remains (not our label to remove) + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('three interleaved contenders: only one survives, others rollback', () => { + // Simulate 3 users trying to assign the same issue concurrently. + // Since there's no workflow concurrency, they all run. Postcondition + // enforcement means only one can win (exactly one assignee). + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + 101: makePR({ number: 101, author: 'bob', merged: true }), + 102: makePR({ number: 102, author: 'charlie', merged: true }), + }, + }), + ); + + // Run 1: alice assigns first (succeeds) + const r1 = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + expect(r1.status).toBe(0); + + // Run 2: bob tries — issue is now assigned to alice, should refuse + const r2 = repo.runAssign({ issueNumber: 42, commenter: 'bob' }); + expect(r2.state.issues['42']._assignees).toContain('alice'); + expect(r2.state.issues['42']._assignees).not.toContain('bob'); + + // Run 3: charlie tries — same + const r3 = repo.runAssign({ issueNumber: 42, commenter: 'charlie' }); + expect(r3.state.issues['42']._assignees).toContain('alice'); + expect(r3.state.issues['42']._assignees).not.toContain('charlie'); + }); +}); + +// =========================================================================== +// 6: History lookup tri-state +// =========================================================================== + +describe('6: History lookup tri-state', () => { + it('current-history GET failure aborts as infrastructure error', () => { + // The current issue search must distinguish present/absent/error. + // A failed query must abort infrastructure, not be treated as ineligibility. + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + fail_config: failOnNth({ + method: 'GET', + endpoint: 'search/issues', + on_nth: 1, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'newuser', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // Must fail closed (nonzero) — infrastructure error + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('newuser'); + }); + + it('history-label GET 500 error aborts as infrastructure error', () => { + // The history label lookup must distinguish 404 (absence) from other errors. + // A 500 must NOT be treated as "label not found" (absence). + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + // Fail the labels endpoint (not search, not issue) + fail_config: { + requests: [ + { + method: 'GET', + endpoint: 'repos/test/repo/labels/asnhist--newuser', + on_nth: 1, + type: 'error', + http_status: 500, + }, + ], + }, + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'newuser', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // Must fail closed — infrastructure error, NOT "not eligible" + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('newuser'); + }); +}); + +// =========================================================================== +// 7: Cleanup provenance (label-event correlation) +// =========================================================================== + +describe('7: Cleanup provenance with label-event correlation', () => { + it('bot assigned WITH labeled event before bot-assigned → eligible for removal', () => { + const assignedAt = daysAgo(25); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); + + it('bot assigned WITHOUT labeled provenance → fail closed, remove nobody', () => { + // Another bot assigned but there's no auto-assigned labeled event before it. + // This is inconsistent provenance — fail closed. + const assignedAt = daysAgo(25); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + // Bot assigned but NO labeled event preceding it + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // Must NOT remove — no label-event provenance + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); + + it('labeled then unlabeled then assigned → no valid provenance', () => { + // Label was added then removed (by bot), then bot assigned without re-adding + // the label event. The unlabeled event invalidates the label provenance. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(30), + }), + makeUnlabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(28), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // No valid label-event provenance (was unlabeled before assignment) + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); + + it('multiple current bot assignees → fail closed, remove nobody', () => { + // Two users both have bot-assigned provenance. Multiple records = ambiguous, + // fail closed. + const assignedAt = daysAgo(25); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user', 'other-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'other-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // Multiple bot assignees → ambiguous, fail closed, remove nobody + expect(result.state.issues['42']._assignees).toContain('stale-user'); + expect(result.state.issues['42']._assignees).toContain('other-user'); + }); +}); + +// =========================================================================== +// 8: Cleanup recovery and exact membership +// =========================================================================== + +describe('8: Cleanup recovery and exact membership', () => { + it('assignee DELETE succeeds but label DELETE fails → report removed, leave marker', () => { + // The assignee is successfully removed, but label DELETE then fails + // on every retry. The script must report nonzero (operational failure) + // but the assignee was already removed, and label should remain for retry. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + fail_config: { + 'repos/test/repo/issues/42/labels/auto-assigned': 'error', + }, + }), + ); + + const result = repo.runCleanup(); + + // Must be nonzero (operational failure) but assignee was removed + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + // Label should remain for retry + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('next run with no assignee removes stale label', () => { + // After the previous scenario (assignee removed, label remains), + // the next cleanup run should remove the label. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + labels: ['auto-assigned'], + }), + }, + timeline: {}, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('bob/bobby exact membership: only stale-user removed, not bobby', () => { + // Two similar names — exact jq index must be used, not grep substring. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['bob', 'bobby'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'bob', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // Only bob should be removed (he has bot provenance) + expect(result.state.issues['42']._assignees).not.toContain('bob'); + // bobby must be preserved (exact match, not substring) + expect(result.state.issues['42']._assignees).toContain('bobby'); + }); +}); + +// =========================================================================== +// 11: Docs accuracy +// =========================================================================== + +describe('11: Docs accuracy', () => { + it('CONTRIBUTING.md does not mention concurrency coalescing', () => { + const docs = readRootFile('CONTRIBUTING.md'); + expect(docs).not.toMatch(/coalesc/i); + }); + + it('CONTRIBUTING.md defines qualifying linked PR accurately', () => { + const docs = readRootFile('CONTRIBUTING.md'); + expect(docs).toContain('linked PR'); + }); + + it('CONTRIBUTING.md mentions durable prior assignment', () => { + const docs = readRootFile('CONTRIBUTING.md'); + // Should reference prior issue assignment eligibility + expect(docs).toMatch(/prior.*assignment/i); + }); + + it('CONTRIBUTING.md mentions hard cap postcondition', () => { + const docs = readRootFile('CONTRIBUTING.md'); + expect(docs).toContain('3'); + }); +}); diff --git a/scripts/tests/assign-remediation4.test.js b/scripts/tests/assign-remediation4.test.js new file mode 100644 index 0000000000..dadeaba2c7 --- /dev/null +++ b/scripts/tests/assign-remediation4.test.js @@ -0,0 +1,752 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 Round 4 remediation findings. + * + * Each test targets a specific blocking finding from the latest review: + * F1: Contention marker preservation (competing winner retains label) + * F2: Cleanup provenance by current stint (boundary + label correlation) + * F3: No || true on rollback; verified rollback before exit 0 + * F4: REST paginated issue count for cap (not Search API) + * F5: Extracted record-history script with consistent label validation + * F6: Cleanup sequence: verify assignee absent BEFORE label DELETE + * + * These execute the REAL bash scripts against the fake gh infrastructure. + */ + +import { describe, expect, it } from 'vitest'; +import { writeFileSync, rmSync } from 'fs'; +import * as nodePath from 'path'; +import { execFileSync } from 'child_process'; +import { mkdtempSync, mkdirSync, readFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + makeUnassignedEvent, + makeUnlabeledEvent, + daysAgo, + failOnNth, +} from './assign-helpers.js'; + +const ROOT = nodePath.resolve(import.meta.dirname, '../..'); + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +// =========================================================================== +// F1: Contention marker preservation +// =========================================================================== + +describe('F1: Contention marker preservation', () => { + it('rollback preserves marker when competing winner remains (interleaving)', () => { + // Genuine interleaving via fake pre-handler side effect: a concurrent + // POST adds a second assignee DURING our POST. + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 42, + assignee: 'winner-user', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // alice rolled back + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // winner-user remains + expect(result.state.issues['42']._assignees).toContain('winner-user'); + // Marker PRESERVED — competing winner owns it + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('rollback removes marker when NO competing winner remains (this run owns it)', () => { + // No concurrent side effect — this run is the sole assigner. + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Normal successful assignment — no rollback + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('rollback removes marker when assignee DELETE fails but no winner (re-add via post-handler)', () => { + // Simulate: DELETE appears to succeed but a post-handler re-adds the + // assignee. The rollback verification must detect this and fail. + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + // Post-effect: after the assignee DELETE (during rollback), re-add alice + side_effects: [ + { + method: 'DELETE', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + timing: 'post', + action: 'readd_assignee', + issue: 42, + assignee: 'alice', + }, + ], + // Fail the post-mutation cap query to trigger rollback + fail_config: failOnNth({ + method: 'GET', + endpoint: 'repos/test/repo/issues', + on_nth: 3, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Must be nonzero — rollback failed (alice re-added by post-handler) + expect(result.status).not.toBe(0); + // alice still assigned (DELETE was undone) + expect(result.state.issues['42']._assignees).toContain('alice'); + // Marker retained (diagnosable state) + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); +}); + +// =========================================================================== +// F2: Cleanup provenance by current stint +// =========================================================================== + +describe('F2: Cleanup provenance by current stint', () => { + it('old label + later bot reassignment without new label does NOT qualify', () => { + // Timeline: bot labels, bot assigns, bot unassigns, bot unlabels, + // then bot reassigns WITHOUT re-labeling. Old label is stale. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(30), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(30), + }), + makeUnassignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + makeUnlabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + // Re-assigned by bot WITHOUT new label + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // Must NOT remove — no qualifying labeled event in the current stint + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); + + it('bot reassignment WITH new label in same stint qualifies', () => { + // Timeline: bot labels, bot assigns, bot unassigns, bot unlabels, + // then bot re-labels AND re-assigns. This is a valid current stint. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(30), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(30), + }), + makeUnassignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + // New stint: label + assign + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(22), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // Should remove — valid current stint with label + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); + + it('later human unlabel invalidates /assign provenance', () => { + // Bot assigned with label, but a human later removed the label + // (manual intervention). This invalidates automation provenance. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(25), + }), + // Human removes the label after assignment + makeUnlabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'human-maintainer', + createdAt: daysAgo(22), + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + // Must NOT remove — human intervention invalidated provenance + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); +}); + +// =========================================================================== +// F3: No || true on rollback; verified rollback before exit 0 +// =========================================================================== + +describe('F3: Verified rollback (no || true)', () => { + it('cap >3 rollback failure exits nonzero with marker preserved', () => { + // alice has 2 open issues initially (under cap). Our POST succeeds + // (making 3). A concurrent side effect assigns alice to #50 (making 4). + // Post-mutation count detects over-cap. Rollback DELETE is attempted + // but a post-handler re-adds alice, so rollback verification fails → nonzero. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + 2: makeIssue({ number: 2, assignees: ['alice'] }), + 50: makeIssue({ number: 50, assignees: [] }), + }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + // Concurrent: assign alice to #50 when our POST fires + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 50, + assignee: 'alice', + }, + // Post-effect: re-add alice after rollback DELETE + { + method: 'DELETE', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + timing: 'post', + action: 'readd_assignee', + issue: 42, + assignee: 'alice', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Post-mutation: alice has 4 issues (1, 2, 42, 50). Rollback attempted + // but fails (alice re-added to #42). Must NOT exit 0. + expect(result.status).not.toBe(0); + // alice still assigned to #42 (rollback failed) + expect(result.state.issues['42']._assignees).toContain('alice'); + // Marker retained for diagnosis + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('post-count query failure triggers verified rollback (no swallowing)', () => { + // The post-mutation REST count GET fails. Rollback must be attempted + // and verified. If rollback succeeds, exit nonzero (infra error path). + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + // Fail the 3rd REST issues GET (post-mutation count) + fail_config: failOnNth({ + method: 'GET', + endpoint: 'repos/test/repo/issues', + on_nth: 3, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Must be nonzero — infra error during cap verification + expect(result.status).not.toBe(0); + // Rollback should succeed (alice removed) + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // Label also removed (rollback verified) + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); +}); + +// =========================================================================== +// F4: REST paginated issue count for cap (not Search API) +// =========================================================================== + +describe('F4: REST-based cap count', () => { + it('cap check uses REST /repos/{repo}/issues, not search/issues', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + 2: makeIssue({ number: 2, assignees: ['alice'] }), + 3: makeIssue({ number: 3, assignees: ['alice'] }), + }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // alice at cap → refused + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // Verify REST issues was called (not search/issues for cap) + const restGets = (result.state._op_log || []).filter( + (op) => op.method === 'GET' && op.endpoint === 'repos/test/repo/issues', + ); + expect(restGets.length).toBeGreaterThan(0); + }); + + it('REST count excludes PRs (only counts non-PR issues)', () => { + // alice is assigned to 2 issues and 1 PR. Cap count must be 2, not 3. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + 2: makeIssue({ number: 2, assignees: ['alice'] }), + }, + prs: { + 99: makePR({ number: 99, author: 'someone', merged: false }), + }, + }), + ); + // Assign alice to PR #99 as well + repo.updateState((s) => { + s.prs['99']._assignees = ['alice']; + }); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'alice', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // alice has 2 issues (not 3) → under cap → should assign + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + }); + + it('merged PR search incomplete_results=true causes infra error', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + search_incomplete: true, + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'alice', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // incomplete_results=true is infra error → fail closed + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + }); + + it('REST count paginates across multiple pages (>100 issues)', () => { + // Create >100 issues assigned to alice to force REST pagination + const issues = {}; + issues[42] = makeIssue({ number: 42, assignees: [] }); + for (let i = 1000; i < 1120; i++) { + issues[String(i)] = makeIssue({ + number: i, + assignees: ['alice'], + }); + } + const repo = createFakeRepo( + defaultStateWith({ + issues, + page_size: 100, + prs: {}, + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'alice', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + // alice has 120 issues > cap → refused + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + }); +}); + +// =========================================================================== +// F5: Extracted record-history script +// =========================================================================== + +/** + * Helper to run the record-assignment-history.sh script directly against + * a fake gh, for behavioral testing of the extracted script. + */ +function runRecordHistory({ state, assigneeLogin, extraEnv = {} }) { + const dir = mkdtempSync(nodePath.join(tmpdir(), 'record-hist-')); + const stateFile = nodePath.join(dir, 'state.json'); + const binDir = nodePath.join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const ghWrapper = nodePath.join(binDir, 'gh'); + const fakeGh = nodePath.join(ROOT, 'scripts/tests/fake-gh.py'); + writeFileSync( + ghWrapper, + `#!/usr/bin/env bash\nexec python3 "${fakeGh}" "$@"\n`, + ); + execFileSync('chmod', ['+x', ghWrapper]); + + const defaultState = { + now: '2025-07-23T00:00:00Z', + issues: {}, + prs: {}, + comments: [], + labels: {}, + events: {}, + timeline: {}, + fail_config: {}, + ...state, + }; + writeFileSync(stateFile, JSON.stringify(defaultState, null, 2)); + + const env = { + ...process.env, + GH_TOKEN: 'fake', + GITHUB_TOKEN: 'fake', + GITHUB_REPOSITORY: 'test/repo', + ASSIGNEE_LOGIN: assigneeLogin, + GH_FAKE_STATE: stateFile, + PATH: binDir + ':' + process.env.PATH, + ...extraEnv, + }; + + try { + const stdout = execFileSync( + 'bash', + [nodePath.join(ROOT, '.github/scripts/record-assignment-history.sh')], + { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); + return { stdout, stderr: '', status: 0, state: finalState }; + } catch (err) { + // Read final state for assertions, but do NOT mask the original process + // error if state read/parse fails — return a structured fallback state. + let finalState; + try { + finalState = JSON.parse(readFileSync(stateFile, 'utf8')); + } catch { + finalState = { + _stateReadError: true, + labels: {}, + issues: {}, + }; + } + return { + stdout: err.stdout?.toString() ?? '', + stderr: err.stderr?.toString() ?? '', + status: err.status ?? 1, + state: finalState, + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('F5: Extracted record-history script', () => { + const HISTORY_COLOR = '0E8A16'; + const HISTORY_DESC = 'Issue assignment history index'; + + it('creates label when absent', () => { + const result = runRecordHistory({ + state: { labels: {} }, + assigneeLogin: 'newuser', + }); + + expect(result.status).toBe(0); + expect(result.state.labels['asnhist--newuser']).toBeDefined(); + expect(result.state.labels['asnhist--newuser'].color).toBe(HISTORY_COLOR); + expect(result.state.labels['asnhist--newuser'].description).toBe( + HISTORY_DESC, + ); + }); + + it('idempotent when label already exists with correct definition', () => { + const result = runRecordHistory({ + state: { + labels: { + 'asnhist--bob': { + name: 'asnhist--bob', + color: HISTORY_COLOR, + description: HISTORY_DESC, + }, + }, + }, + assigneeLogin: 'bob', + }); + + expect(result.status).toBe(0); + // No duplicate creation, definition preserved + expect(result.state.labels['asnhist--bob'].color).toBe(HISTORY_COLOR); + }); + + it('fails on collision (wrong color/description)', () => { + const result = runRecordHistory({ + state: { + labels: { + 'asnhist--charlie': { + name: 'asnhist--charlie', + color: 'FF0000', // wrong + description: 'Human label', // wrong + }, + }, + }, + assigneeLogin: 'charlie', + }); + + // Must fail — collision + expect(result.status).not.toBe(0); + // Original definition preserved + expect(result.state.labels['asnhist--charlie'].color).toBe('FF0000'); + }); + + it('failed POST with label still absent exits nonzero', () => { + const result = runRecordHistory({ + state: { + labels: {}, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/labels', + on_nth: 1, + type: 'error', + }), + }, + assigneeLogin: 'racer', + }); + + // POST fails, re-check finds label absent → should fail + expect(result.status).not.toBe(0); + }); +}); + +// =========================================================================== +// F6: Cleanup sequence — verify assignee absent BEFORE label DELETE +// =========================================================================== + +describe('F6: Cleanup sequence — verify before label DELETE', () => { + it('assignee DELETE succeeds (no-op/race re-add) → retain marker, fail nonzero', () => { + // The assignee DELETE appears to succeed, but a post-handler re-adds + // the assignee immediately. The mid-DELETE verification must catch this + // and NOT proceed to label DELETE. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + // Post-effect: after the assignee DELETE, re-add stale-user + side_effects: [ + { + method: 'DELETE', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + timing: 'post', + action: 'readd_assignee', + issue: 42, + assignee: 'stale-user', + }, + ], + }), + ); + + const result = repo.runCleanup(); + + // Must be nonzero — assignee still present after DELETE + expect(result.status).not.toBe(0); + // stale-user still assigned + expect(result.state.issues['42']._assignees).toContain('stale-user'); + // Marker retained (label NOT deleted) + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('assignee removed but label DELETE fails → nonzero with marker', () => { + // Existing behavior: assignee DELETE succeeds, but label DELETE fails. + // Must report nonzero and leave the marker for next-run recovery. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + fail_config: { + 'repos/test/repo/issues/42/labels/auto-assigned': 'error', + }, + }), + ); + + const result = repo.runCleanup(); + + // Must be nonzero + expect(result.status).not.toBe(0); + // stale-user was removed (DELETE succeeded) + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + // Label remains for retry + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); +}); diff --git a/scripts/tests/assign-remediation5.test.js b/scripts/tests/assign-remediation5.test.js new file mode 100644 index 0000000000..66e4680e7f --- /dev/null +++ b/scripts/tests/assign-remediation5.test.js @@ -0,0 +1,833 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 Round 5 remediation findings. + * + * Each test targets a specific fresh review finding: + * G1: state=all eligibility query (closed issue counts as prior assignment) + * G2: record-assignment-history.sh validate_history_label return-code capture + * G3: unassign-stale-issues.sh validates auto-assigned label definition upfront + * G4: fake-gh 404 on DELETE of non-attached label; cleanup race resilience + * + * These execute the REAL bash scripts against the fake gh infrastructure. + */ + +import { describe, expect, it } from 'vitest'; +import { writeFileSync, rmSync } from 'fs'; +import { mkdtempSync, mkdirSync, readFileSync } from 'fs'; +import { execFileSync } from 'child_process'; +import { tmpdir } from 'os'; +import * as nodePath from 'path'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + daysAgo, + failOnNth, +} from './assign-helpers.js'; + +const ROOT = nodePath.resolve(import.meta.dirname, '../..'); + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +// =========================================================================== +// G1: state=all eligibility query +// =========================================================================== + +describe('G1: state=all eligibility query', () => { + it('closed issue assignment qualifies (state=all, not default open)', () => { + // The user's ONLY current non-PR assignment is a CLOSED issue. No static + // backfill line and no history label exist. The current-assignment + // eligibility query must use state=all so the closed issue is visible. + // (Omitted state defaults to open on GitHub and would miss it.) + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 99: makeIssue({ + number: 99, + assignees: ['prioruser'], + state: 'closed', + }), + }, + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'prioruser', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('prioruser'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('fake-gh: omitted state on /issues defaults to open (not all)', () => { + // Directly verify fake-gh fidelity: omitting state returns only open issues. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 1: makeIssue({ number: 1, assignees: ['u'], state: 'open' }), + 2: makeIssue({ + number: 2, + assignees: ['u'], + state: 'closed', + }), + }, + }), + ); + + // Query WITHOUT state param (should default to open → only #1) + const out = execFileSync( + 'bash', + [ + '-c', + `PATH="${repo.binDir}:$PATH" GH_FAKE_STATE="${repo.stateFile}" ` + + `gh api 'repos/test/repo/issues?assignee=u&per_page=100'`, + ], + { encoding: 'utf8' }, + ); + const nums = JSON.parse(out).map((i) => i.number); + expect(nums).toContain(1); + expect(nums).not.toContain(2); + }); + + it('fake-gh: explicit state=all returns both open and closed', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 1: makeIssue({ number: 1, assignees: ['u'], state: 'open' }), + 2: makeIssue({ + number: 2, + assignees: ['u'], + state: 'closed', + }), + }, + }), + ); + + const out = execFileSync( + 'bash', + [ + '-c', + `PATH="${repo.binDir}:$PATH" GH_FAKE_STATE="${repo.stateFile}" ` + + `gh api 'repos/test/repo/issues?assignee=u&state=all&per_page=100'`, + ], + { encoding: 'utf8' }, + ); + const nums = JSON.parse(out).map((i) => i.number); + expect(nums).toContain(1); + expect(nums).toContain(2); + }); +}); + +// =========================================================================== +// G1b: fake-gh search query and events fidelity +// =========================================================================== + +describe('G1b: fake-gh search and events fidelity', () => { + it('search query splits on whitespace (not literal +)', () => { + // The gh CLI query uses '+' as a space separator, which unquote_plus + // converts to spaces. After that, split must be on whitespace only. + // A literal '+' in a search value would be percent-encoded as %2B. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + // Query with '+' separators (URL-encoded spaces): repo+author+type+is + const out = execFileSync( + 'bash', + [ + '-c', + `PATH="${repo.binDir}:$PATH" GH_FAKE_STATE="${repo.stateFile}" ` + + `gh api 'search/issues?q=repo:test/repo+author:alice+type:pr+is:merged&per_page=1'`, + ], + { encoding: 'utf8' }, + ); + const parsed = JSON.parse(out); + expect(parsed.total_count).toBe(1); + expect(parsed.items[0].number).toBe(100); + }); + + it('repository /issues/events includes both issue and PR events', () => { + // GitHub's repository-wide /issues/events endpoint includes events from + // both issues and PRs. The fake must match this fidelity. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: ['alice'] }), + }, + prs: { + 100: makePR({ number: 100, author: 'bob', merged: true }), + }, + events: { + 42: [ + { + id: 1, + event: 'assigned', + actor: { login: 'github-actions[bot]', type: 'Bot' }, + assignee: { login: 'alice' }, + created_at: '2025-07-01T00:00:00Z', + }, + ], + 100: [ + { + id: 2, + event: 'assigned', + actor: { login: 'github-actions[bot]', type: 'Bot' }, + assignee: { login: 'bob' }, + created_at: '2025-07-02T00:00:00Z', + }, + ], + }, + }), + ); + + const out = execFileSync( + 'bash', + [ + '-c', + `PATH="${repo.binDir}:$PATH" GH_FAKE_STATE="${repo.stateFile}" ` + + `gh api 'repos/test/repo/issues/events'`, + ], + { encoding: 'utf8' }, + ); + const events = JSON.parse(out); + // Both issue #42 and PR #100 events must be present + expect(events.length).toBeGreaterThanOrEqual(2); + const issueNums = events.map((e) => e.issue?.number); + expect(issueNums).toContain(42); + expect(issueNums).toContain(100); + }); +}); + +// =========================================================================== +// G2: record-assignment-history.sh validate_history_label return codes +// =========================================================================== + +/** + * Helper to run the record-assignment-history.sh script directly against + * a fake gh. + */ +function runRecordHistory({ state, assigneeLogin, extraEnv = {} }) { + const dir = mkdtempSync(nodePath.join(tmpdir(), 'record-hist-g2-')); + const stateFile = nodePath.join(dir, 'state.json'); + const binDir = nodePath.join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const ghWrapper = nodePath.join(binDir, 'gh'); + const fakeGh = nodePath.join(ROOT, 'scripts/tests/fake-gh.py'); + writeFileSync( + ghWrapper, + `#!/usr/bin/env bash\nexec python3 "${fakeGh}" "$@"\n`, + ); + execFileSync('chmod', ['+x', ghWrapper]); + + const defaultState = { + now: '2025-07-23T00:00:00Z', + issues: {}, + prs: {}, + comments: [], + labels: {}, + events: {}, + timeline: {}, + fail_config: {}, + ...state, + }; + writeFileSync(stateFile, JSON.stringify(defaultState, null, 2)); + + const env = { + ...process.env, + GH_TOKEN: 'fake', + GITHUB_TOKEN: 'fake', + GITHUB_REPOSITORY: 'test/repo', + ASSIGNEE_LOGIN: assigneeLogin, + GH_FAKE_STATE: stateFile, + PATH: binDir + ':' + process.env.PATH, + ...extraEnv, + }; + + try { + const stdout = execFileSync( + 'bash', + [nodePath.join(ROOT, '.github/scripts/record-assignment-history.sh')], + { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); + return { stdout, stderr: '', status: 0, state: finalState }; + } catch (err) { + const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); + return { + stdout: err.stdout?.toString() ?? '', + stderr: err.stderr?.toString() ?? '', + status: err.status ?? 1, + state: finalState, + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('G2: record-history return-code capture', () => { + const HISTORY_COLOR = '0E8A16'; + const HISTORY_DESC = 'Issue assignment history index'; + + it('initial GET API error exits nonzero and performs no POST', () => { + // The initial validate_history_label GET fails (500). The script must + // capture the return code, exit nonzero, and NOT POST a label. + const result = runRecordHistory({ + state: { + labels: {}, + fail_config: failOnNth({ + method: 'GET', + endpoint: 'repos/test/repo/labels/asnhist--newuser', + on_nth: 1, + type: 'error', + http_status: 500, + }), + }, + assigneeLogin: 'newuser', + }); + + expect(result.status).not.toBe(0); + // No label was created (no POST happened) + expect(result.state.labels['asnhist--newuser']).toBeUndefined(); + // Verify via op_log that no POST /labels occurred + const postLabels = (result.state._op_log || []).filter( + (op) => op.method === 'POST' && op.endpoint === 'repos/test/repo/labels', + ); + expect(postLabels.length).toBe(0); + }); + + it('concurrent exact label creation after failed POST succeeds', () => { + // POST /labels fails (race), but recheck finds the label was created + // concurrently with the exact correct definition → success. + const result = runRecordHistory({ + state: { + // Pre-place the label so the recheck finds it (simulates concurrent creation) + labels: { + 'asnhist--racer': { + name: 'asnhist--racer', + color: HISTORY_COLOR, + description: HISTORY_DESC, + }, + }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/labels', + on_nth: 1, + type: 'error', + }), + }, + assigneeLogin: 'racer', + }); + + expect(result.status).toBe(0); + expect(result.state.labels['asnhist--racer']).toBeDefined(); + expect(result.state.labels['asnhist--racer'].color).toBe(HISTORY_COLOR); + expect(result.state.labels['asnhist--racer'].description).toBe( + HISTORY_DESC, + ); + }); + + it('conflicting label after failed POST exits nonzero', () => { + // POST /labels fails, recheck finds a label with WRONG definition + // (conflicting) → must fail nonzero. + const result = runRecordHistory({ + state: { + labels: { + 'asnhist--conflict': { + name: 'asnhist--conflict', + color: 'FF0000', + description: 'Human label', + }, + }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/labels', + on_nth: 1, + type: 'error', + }), + }, + assigneeLogin: 'conflict', + }); + + expect(result.status).not.toBe(0); + // Original conflicting definition preserved + expect(result.state.labels['asnhist--conflict'].color).toBe('FF0000'); + }); + + it('absent label after failed POST exits nonzero (not treated as success)', () => { + // POST /labels fails, recheck finds label absent → must fail nonzero, + // NOT silently succeed. + const result = runRecordHistory({ + state: { + labels: {}, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/labels', + on_nth: 1, + type: 'error', + }), + }, + assigneeLogin: 'absentcase', + }); + + expect(result.status).not.toBe(0); + expect(result.state.labels['asnhist--absentcase']).toBeUndefined(); + }); + + it('recheck GET 500 after failed POST is treated as API error (not absence)', () => { + // POST fails, then the recheck GET returns 500. Must NOT be treated as + // absence (which would be a soft path); it must be a hard error. + const result = runRecordHistory({ + state: { + labels: {}, + fail_config: { + requests: [ + { + method: 'POST', + endpoint: 'repos/test/repo/labels', + on_nth: 1, + type: 'error', + }, + { + method: 'GET', + endpoint: 'repos/test/repo/labels/asnhist--apierror2', + on_nth: 2, + type: 'error', + http_status: 500, + }, + ], + }, + }, + assigneeLogin: 'apierror2', + }); + + expect(result.status).not.toBe(0); + expect(result.state.labels['asnhist--apierror2']).toBeUndefined(); + }); +}); + +// =========================================================================== +// G2b: record-history stderr separation (validate_history_label) +// =========================================================================== + +describe('G2b: validate_history_label stderr separation', () => { + const HISTORY_COLOR = '0E8A16'; + const HISTORY_DESC = 'Issue assignment history index'; + + it('stderr warning during initial GET does not corrupt 404 absence detection', () => { + // gh writes a warning to stderr while the label GET returns 404 (absent). + // The script must detect 404 from stderr and proceed to create the label. + const result = runRecordHistory({ + state: { + labels: {}, + stderr_warnings: [ + { + method: 'GET', + endpoint: 'repos/test/repo/labels/asnhist--warnuser', + message: 'WARNING: API rate limit approaching', + }, + ], + }, + assigneeLogin: 'warnuser', + }); + + // Should succeed — the 404 is detected from stderr, label is created + expect(result.status).toBe(0); + expect(result.state.labels['asnhist--warnuser']).toBeDefined(); + expect(result.state.labels['asnhist--warnuser'].color).toBe(HISTORY_COLOR); + expect(result.state.labels['asnhist--warnuser'].description).toBe( + HISTORY_DESC, + ); + }); + + it('stderr warning during recheck after failed POST does not corrupt validation', () => { + // POST fails, recheck GET writes a warning to stderr but returns valid + // JSON for a label with correct definition. The recheck must succeed. + const result = runRecordHistory({ + state: { + labels: { + 'asnhist--recheckwarn': { + name: 'asnhist--recheckwarn', + color: HISTORY_COLOR, + description: HISTORY_DESC, + }, + }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/labels', + on_nth: 1, + type: 'error', + }), + stderr_warnings: [ + { + method: 'GET', + endpoint: 'repos/test/repo/labels/asnhist--recheckwarn', + message: 'WARNING: slow response', + }, + ], + }, + assigneeLogin: 'recheckwarn', + }); + + // POST failed, recheck found label with correct definition → success + expect(result.status).toBe(0); + expect(result.state.labels['asnhist--recheckwarn']).toBeDefined(); + }); +}); + +// =========================================================================== +// G3: unassign-stale-issues.sh validates auto-assigned label definition upfront +// =========================================================================== + +describe('G3: Cleanup validates auto-assigned label definition upfront', () => { + it('missing auto-assigned label is a clean no-op', () => { + // The auto-assigned label does not exist in the repo at all. + // discover_candidates would find no issues anyway, but the label definition + // validation must be a clean no-op (exit 0, no mutation). + const repo = createFakeRepo( + defaultStateWith({ + labels: {}, + issues: {}, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + }); + + it('conflicting auto-assigned definition fails with no mutation', () => { + // The auto-assigned label exists but with a WRONG definition (color/desc). + // Even with candidate issues present, cleanup must fail closed (nonzero) + // and perform NO mutation — matching assign-issue.sh's fail-closed policy. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: 'FF0000', // wrong color + description: 'Human label', // wrong description + }, + }, + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + // No mutation — state preserved + expect(result.state.issues['42']._assignees).toContain('stale-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('conflicting definition on unassigned issue fails with no mutation', () => { + // Conflicting auto-assigned label definition, issue has NO assignees + // (just the stale label). Must still fail closed (no label-only removal). + const repo = createFakeRepo( + defaultStateWith({ + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0000FF', // wrong color + description: 'Assigned via /assign automation', // correct desc + }, + }, + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + labels: ['auto-assigned'], + }), + }, + timeline: {}, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + // No mutation — stale label must NOT be removed under conflicting definition + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('conflicting definition on human-assigned issue fails with no mutation', () => { + // Conflicting auto-assigned label definition, issue has a HUMAN assignee. + // Must fail closed (no label-only removal, no unassign). + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0E8A16', // correct color + description: 'Wrong description', // wrong description + }, + }, + issues: { + 42: makeIssue({ + number: 42, + assignees: ['human-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeAssignedEvent({ + number: 42, + assignee: 'human-user', + actor: 'human-admin', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('human-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('marker-label GET failure exits nonzero with no mutation', () => { + // The GET to validate the auto-assigned label definition fails (500). + // Must fail closed (nonzero), no mutation. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + fail_config: failOnNth({ + method: 'GET', + endpoint: 'repos/test/repo/labels/auto-assigned', + on_nth: 1, + type: 'error', + http_status: 500, + }), + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('stale-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); +}); + +// =========================================================================== +// G4: fake-gh 404 on DELETE of non-attached label; cleanup race resilience +// =========================================================================== + +describe('G4: fake-gh 404 label DELETE + cleanup race resilience', () => { + it('fake-gh: DELETE of non-attached issue label returns 404', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['u'], + labels: ['bug'], + }), + }, + }), + ); + + let exitCode = 0; + let stderr = ''; + try { + execFileSync( + 'bash', + [ + '-c', + `PATH="${repo.binDir}:$PATH" GH_FAKE_STATE="${repo.stateFile}" ` + + `gh api --method DELETE 'repos/test/repo/issues/42/labels/nonexistent' --silent`, + ], + { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }, + ); + } catch (err) { + exitCode = err.status ?? 1; + stderr = err.stderr?.toString() ?? ''; + } + + expect(exitCode).not.toBe(0); + expect(stderr).toMatch(/404/); + }); + + it('cleanup race: label removed after read but before DELETE succeeds cleanly', () => { + // A race: cleanup reads the issue (label present), decides to DELETE the + // label, but the label is removed by a concurrent actor AFTER the read + // and BEFORE the DELETE. The DELETE returns 404. Cleanup must treat this + // as the desired state (label is gone) and succeed cleanly with no + // unrelated mutation. + // + // The side_effect fires on the pre-delete assignee GET (the verifying + // issue read that confirms the label is still present), removing the label + // so the subsequent DELETE gets a 404. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + // Race: on the 2nd GET of the issue (the mid-delete verification read), + // remove the auto-assigned label so the subsequent label DELETE gets 404. + side_effects: [ + { + method: 'GET', + endpoint: 'repos/test/repo/issues/42', + on_nth: 2, + action: 'remove_label', + issue: 42, + label: 'auto-assigned', + }, + ], + }), + ); + + const result = repo.runCleanup(); + + // Must succeed cleanly — the 404 on label DELETE is the desired state + expect(result.status).toBe(0); + // stale-user was unassigned (assignee DELETE happened before the race) + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + // Label is absent (removed by race) + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('cleanup: targeted marker deletion persistent failure hits exact endpoint', () => { + // Verify that failure injection targets the exact DELETE label endpoint. + // Uses flat fail_config to fail ALL DELETE attempts to the label endpoint + // (retry_gh retries 4 times; each must fail for a persistent server error). + // Other endpoints (GET issue, DELETE assignee) must succeed. + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + // Flat fail_config: fails ALL DELETE to the label endpoint + fail_config: { + 'repos/test/repo/issues/42/labels/auto-assigned': 'error', + }, + }), + ); + + const result = repo.runCleanup(); + + // Assignee was removed (DELETE assignee succeeded) + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + // Label DELETE failed → nonzero, label remains for retry + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); +}); diff --git a/scripts/tests/assign-remediation6.test.js b/scripts/tests/assign-remediation6.test.js new file mode 100644 index 0000000000..2387d53297 --- /dev/null +++ b/scripts/tests/assign-remediation6.test.js @@ -0,0 +1,255 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 Round 6 remediation findings. + * + * H1: Closed assignment target — no assignee, marker, history, or success + * feedback on a closed issue. State validated at initial guard and + * again immediately before mutation. Closed = expected refusal (exit 0); + * malformed/missing state = infrastructure failure (exit 1). + * H2: Cleanup linked-PR race — one fresh pre-delete timeline snapshot + * validates BOTH provenance/marker correlation AND qualifying + * same-repo PR linkage immediately before DELETE. + * + * These execute the REAL bash scripts against the fake gh infrastructure. + */ + +import { describe, expect, it } from 'vitest'; +import yaml from 'js-yaml'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + daysAgo, +} from './assign-helpers.js'; +import { normalize, readRootFile } from './ocr-review-workflow-helpers.js'; + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +// =========================================================================== +// H1: Closed assignment target +// =========================================================================== + +describe('H1: Closed assignment target', () => { + it('closed issue receives no assignee, label, history, or success feedback', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + state: 'closed', + }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + expect(result.state.labels['asnhist--alice']).toBeUndefined(); + const comments = result.state.comments.filter((c) => c.issue_number === 42); + expect(comments.length).toBeGreaterThanOrEqual(1); + expect(comments.some((c) => /closed|not open/i.test(c.body))).toBe(true); + expect(comments.some((c) => /\[OK\] Assigned/.test(c.body))).toBe(false); + }); + + it('issue closed between initial guard and mutation (pre-mutation guard)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + side_effects: [ + { + method: 'GET', + endpoint: 'repos/test/repo/issues/42', + on_nth: 5, + action: 'set_state', + issue: 42, + state: 'closed', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + expect(result.state.labels['asnhist--alice']).toBeUndefined(); + }); + + it('malformed issue state is infrastructure failure (exit 1)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + state: 'locked', + }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('open issue still gets assigned normally (happy path)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + expect(result.state.labels['asnhist--alice']).toBeDefined(); + }); + + it('assign.yml workflow gate includes issue.state == open', () => { + const source = readRootFile('.github/workflows/assign.yml'); + const workflow = yaml.load(source); + const condition = normalize(workflow.jobs?.assign?.if); + expect(condition).toMatch(/github\.event\.issue\.state\s*==\s*'open'/); + }); +}); + +// =========================================================================== +// H2: Cleanup linked-PR race +// =========================================================================== + +describe('H2: Cleanup linked-PR race', () => { + it('qualifying PR appears before pre-delete revalidation → retain assignment', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + side_effects: [ + { + method: 'GET', + endpoint: 'repos/test/repo/issues/42/timeline', + // The initial timeline snapshot serves both provenance and the + // initial linked-PR check (one call instead of two). The fresh + // pre-delete revalidation snapshot is the 2nd timeline GET — a + // qualifying PR appearing there must block deletion. + on_nth: 2, + action: 'add_cross_ref', + issue: 42, + pr_number: 200, + pr_author: 'stale-user', + repo_url: 'https://api.github.com/repos/test/repo', + created_at: daysAgo(15), + }, + ], + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('stale-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('no qualifying PR appears → normal unassign (no false retain)', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); +}); diff --git a/scripts/tests/assign-remediation7.test.js b/scripts/tests/assign-remediation7.test.js new file mode 100644 index 0000000000..5ad1648773 --- /dev/null +++ b/scripts/tests/assign-remediation7.test.js @@ -0,0 +1,652 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 Round 7 remediation findings. + * + * H3: unassign-stale-issues.sh EXPECTED_REPO_URL moved below the + * explicit GITHUB_REPOSITORY guard — missing env must produce + * "Missing GITHUB_REPOSITORY", not unbound-variable noise. + * H4: find_provenance_from_timeline uses EVENT POSITION (array index) + * for total ordering, not created_at timestamps. Same-second events + * must be ordered by their position in the flattened timeline array. + * H5: Concurrent same-issue /assign convergence — deterministic winner + * election via timeline event positions. Two (and three) REAL + * assign-issue.sh processes against shared fake state with process-safe + * locking must converge to exactly one deterministic bot winner. + */ + +import { describe, expect, it, afterEach } from 'vitest'; +import { execFileSync, spawn } from 'child_process'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + readFileSync, + rmSync, +} from 'fs'; +import { tmpdir } from 'os'; +import * as nodePath from 'path'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + makeUnlabeledEvent, + makeUnassignedEvent, + daysAgo, +} from './assign-helpers.js'; + +const ROOT = nodePath.resolve(import.meta.dirname, '../..'); +const FAKE_GH = nodePath.join(ROOT, 'scripts/tests/fake-gh.py'); +const ASSIGN_SCRIPT = nodePath.join(ROOT, '.github/scripts/assign-issue.sh'); +const CLEANUP_SCRIPT = nodePath.join( + ROOT, + '.github/scripts/unassign-stale-issues.sh', +); + +// Track active child processes and temp dirs for robust teardown. +const activeChildren = []; +const tempDirs = []; + +afterEach(() => { + // Kill any lingering child processes if tests fail or time out. + for (const child of activeChildren) { + if (!child.killed) { + try { + child.kill('SIGKILL'); + } catch { + // already exited + } + } + } + activeChildren.length = 0; + + // Clean up temp dirs and lock files synchronously. + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + // Also remove any stray lock file created by fake-gh. + rmSync(dir + '.lock', { force: true }); + } + tempDirs.length = 0; +}); + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +// =========================================================================== +// H3: unassign-stale-issues.sh EXPECTED_REPO_URL guard order +// =========================================================================== + +describe('H3: unassign-stale-issues.sh guard order', () => { + it('empty-string GITHUB_REPOSITORY exits nonzero with clean message (not unbound-variable)', () => { + // GITHUB_REPOSITORY is set to an empty string. The guard must treat + // this as missing and fail with the clean message, not bash unbound- + // variable noise. + let exitCode = 0; + let stderr = ''; + try { + execFileSync('bash', [CLEANUP_SCRIPT], { + encoding: 'utf8', + env: { + ...process.env, + GITHUB_REPOSITORY: '', // explicitly unset + GH_TOKEN: 'fake', + GITHUB_TOKEN: 'fake', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + exitCode = err.status ?? 1; + stderr = err.stderr?.toString() ?? ''; + } + + expect(exitCode).not.toBe(0); + // Must contain the clean message, NOT bash unbound-variable noise + expect(stderr).toContain('Missing GITHUB_REPOSITORY'); + expect(stderr).not.toMatch( + /unbound variable|GITHUB_REPOSITORY.*parameter/i, + ); + }); + + it('missing GITHUB_REPOSITORY via env deletion also exits cleanly', () => { + let exitCode = 0; + let stderr = ''; + const env = { ...process.env }; + delete env.GITHUB_REPOSITORY; + delete env.GITHUB_TOKEN; + delete env.GH_TOKEN; + try { + execFileSync('bash', [CLEANUP_SCRIPT], { + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + } catch (err) { + exitCode = err.status ?? 1; + stderr = err.stderr?.toString() ?? ''; + } + + expect(exitCode).not.toBe(0); + expect(stderr).toContain('Missing GITHUB_REPOSITORY'); + }); +}); + +// =========================================================================== +// H4: find_provenance_from_timeline uses EVENT POSITION (not created_at) +// =========================================================================== + +describe('H4: Same-second event ordering by position', () => { + const ts = daysAgo(20); // all events share this timestamp + + it('bot label → bot assign → human unlabel → human relabel (never remove)', () => { + // All events same second. Timeline order: + // 1. bot labeled auto-assigned + // 2. bot assigned stale-user + // 3. human unlabeled auto-assigned + // 4. human relabeled auto-assigned + // Human unlabel invalidates provenance (even at equal timestamp). + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeUnlabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'human-maintainer', + createdAt: ts, + }), + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'human-maintainer', + createdAt: ts, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // Human unlabel after bot label invalidates provenance — never remove + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); + + it('prior bot assign → unassign → new bot label → bot assign (new stint qualifies)', () => { + // All events same second. Timeline order by position: + // 1. bot assigned stale-user + // 2. bot unassigned stale-user + // 3. bot labeled auto-assigned + // 4. bot assigned stale-user (new stint) + // The new stint qualifies because the latest transition (by position) is + // a bot assigned event, with a bot labeled event after the boundary + // (the unassign) and at/before the new assign. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeUnassignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: ts, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // New stint qualifies — should unassign + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); + + it('same-second old label → unassign → new bot assign without new label (does NOT qualify)', () => { + // All events same second. Timeline order by position: + // 1. bot labeled auto-assigned (old label) + // 2. bot unassigned stale-user (boundary) + // 3. bot assigned stale-user (no new label after boundary) + // Does NOT qualify because the qualifying labeled event must be AFTER + // the boundary (unassign) and at/before the assigned event. The only + // labeled event is BEFORE the boundary. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeUnassignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: ts, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // Does NOT qualify — old label before boundary, no new label in current stint + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); + + it('human unlabel BEFORE bot label does NOT invalidate (position matters)', () => { + // All events same second. Timeline order by position: + // 1. human unlabeled auto-assigned (stale unlabel from a prior stint) + // 2. bot labeled auto-assigned + // 3. bot assigned stale-user + // The unlabel is BEFORE the qualifying labeled event (by position), so + // it does NOT invalidate. This is the key position-based ordering test. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeUnlabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'human-maintainer', + createdAt: ts, + }), + makeLabeledEvent({ + number: 42, + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: ts, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: ts, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // Bot label after the unlabel (by position) is valid — qualifies for removal + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); +}); + +// =========================================================================== +// H5: Concurrent same-issue /assign convergence (deterministic winner) +// =========================================================================== + +/** + * Helper: set up a fake repo for concurrency tests with file-lock-aware + * fake-gh and return the paths needed for spawning real bash processes. + */ +function setupConcurrencyRepo(initialState) { + const dir = mkdtempSync(nodePath.join(tmpdir(), 'assign-concur-')); + tempDirs.push(dir); + const stateFile = nodePath.join(dir, 'state.json'); + const binDir = nodePath.join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const ghWrapper = nodePath.join(binDir, 'gh'); + writeFileSync( + ghWrapper, + `#!/usr/bin/env bash\nexec python3 "${FAKE_GH}" "$@"\n`, + ); + execFileSync('chmod', ['+x', ghWrapper]); + + const baseState = { + now: '2025-07-23T00:00:00Z', + next_comment_id: 100, + next_event_id: 10000, + issues: {}, + prs: {}, + comments: [], + labels: {}, + events: {}, + timeline: {}, + fail_config: {}, + ...initialState, + }; + writeFileSync(stateFile, JSON.stringify(baseState, null, 2)); + + const pathWithFakeGh = binDir + ':' + process.env.PATH; + + return { dir, stateFile, binDir, pathWithFakeGh }; +} + +/** + * Spawn a real assign-issue.sh process asynchronously. + * Returns a promise that resolves to { stdout, stderr, status }. + * + * Both processes are forced past the initial empty-assignee guard by having + * the issue start unassigned. They both proceed to mutate (POST label, + * POST assignee). The winner election must converge deterministically. + */ +function spawnAssignProcess({ + dir: _dir, + stateFile, + pathWithFakeGh, + issueNumber, + commenter, +}) { + return new Promise((resolve) => { + const env = { + ...process.env, + GH_TOKEN: 'fake-token', + GITHUB_TOKEN: 'fake-token', + GITHUB_REPOSITORY: 'test/repo', + ISSUE_NUMBER: String(issueNumber), + COMMENTER_LOGIN: commenter, + GH_FAKE_STATE: stateFile, + ASSIGN_RETRY_DELAY: '0', + ASSIGN_ELECTION_RETRIES: '3', + ASSIGN_ELECTION_DELAY: '0', + PATH: pathWithFakeGh, + }; + + const child = spawn('bash', [ASSIGN_SCRIPT], { + encoding: 'utf8', + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + activeChildren.push(child); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (d) => { + stdout += d; + }); + child.stderr.on('data', (d) => { + stderr += d; + }); + + child.on('error', (err) => { + // Spawn failed (e.g. bash not found) — resolve with error info. + resolve({ stdout, stderr: stderr + String(err), status: 1 }); + }); + + child.on('close', (code) => { + resolve({ stdout, stderr, status: code ?? 1 }); + }); + }); +} + +/** + * Spawn multiple assign-issue.sh processes concurrently, wait for all, + * and return the final state + per-process results. + */ +async function runConcurrentAssign({ + stateFile, + pathWithFakeGh, + issueNumber, + commenters, +}) { + const promises = commenters.map((commenter) => + spawnAssignProcess({ + stateFile, + pathWithFakeGh, + issueNumber, + commenter, + }), + ); + + const results = await Promise.all(promises); + const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); + + return { results, finalState }; +} + +describe('H5: Concurrent same-issue /assign convergence', () => { + it('two real processes converge to exactly one deterministic winner', async () => { + // Both alice and bob have merged PRs (eligible). Issue #42 starts unassigned. + // Two REAL bash processes run concurrently. The file-lock-aware fake-gh + // serializes mutations. Deterministic winner election via timeline event + // position must converge: exactly ONE assignee, ONE marker, durable history + // for the winner only, no unrelated state loss. + const { stateFile, pathWithFakeGh } = setupConcurrencyRepo({ + issues: { + 42: makeIssue({ number: 42, assignees: [], labels: ['bug'] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + 101: makePR({ number: 101, author: 'bob', merged: true }), + }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0E8A16', + description: 'Assigned via /assign automation', + }, + }, + }); + + const { results, finalState } = await runConcurrentAssign({ + stateFile, + pathWithFakeGh, + issueNumber: 42, + commenters: ['alice', 'bob'], + }); + + const issue = finalState.issues['42']; + + // Exactly ONE assignee + expect(issue._assignees).toHaveLength(1); + + // The winner is the one whose assigned event is earliest in timeline position + const assignedEvents = (finalState.timeline['42'] || []).filter( + (e) => e.event === 'assigned' && e.actor.login === 'github-actions[bot]', + ); + expect(assignedEvents.length).toBeGreaterThanOrEqual(1); + + // Determine the winner by earliest assigned event position + const winnerLogin = issue._assignees[0]; + expect(['alice', 'bob']).toContain(winnerLogin); + + // Exactly ONE auto-assigned marker + const autoLabels = issue._label_names.filter((l) => l === 'auto-assigned'); + expect(autoLabels).toHaveLength(1); + + // Durable history label for winner only + const winnerHistory = `asnhist--${winnerLogin}`; + const loserLogin = winnerLogin === 'alice' ? 'bob' : 'alice'; + expect(finalState.labels[winnerHistory]).toBeDefined(); + expect(finalState.labels[`asnhist--${loserLogin}`]).toBeUndefined(); + + // Unrelated labels preserved + expect(issue._label_names).toContain('bug'); + + // At least one process must succeed (exit 0), the loser exits nonzero + const exitCodes = results.map((r) => r.status); + expect(exitCodes).toContain(0); + }, 60000); + + it('three real processes converge to exactly one deterministic winner', async () => { + const { stateFile, pathWithFakeGh } = setupConcurrencyRepo({ + issues: { + 42: makeIssue({ number: 42, assignees: [], labels: ['bug'] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + 101: makePR({ number: 101, author: 'bob', merged: true }), + 102: makePR({ number: 102, author: 'charlie', merged: true }), + }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0E8A16', + description: 'Assigned via /assign automation', + }, + }, + }); + + const { results, finalState } = await runConcurrentAssign({ + stateFile, + pathWithFakeGh, + issueNumber: 42, + commenters: ['alice', 'bob', 'charlie'], + }); + + const issue = finalState.issues['42']; + + // Exactly ONE assignee despite 3 contenders + expect(issue._assignees).toHaveLength(1); + + const winnerLogin = issue._assignees[0]; + expect(['alice', 'bob', 'charlie']).toContain(winnerLogin); + + // Exactly ONE marker + const autoLabels = issue._label_names.filter((l) => l === 'auto-assigned'); + expect(autoLabels).toHaveLength(1); + + // Durable history for winner only (the winner's history label must exist; + // losers may or may not have one depending on timing, but winner MUST) + expect(finalState.labels[`asnhist--${winnerLogin}`]).toBeDefined(); + for (const loser of ['alice', 'bob', 'charlie']) { + if (loser !== winnerLogin) { + expect(finalState.labels[`asnhist--${loser}`]).toBeUndefined(); + } + } + + // At least one process succeeds + const exitCodes = results.map((r) => r.status); + expect(exitCodes).toContain(0); + }, 60000); + + it('concurrent bot assign with pre-existing human assignment: bot rolls back', async () => { + // Issue is ALREADY assigned to a human. Two bot processes attempt to assign. + // Both must see the human assignment and rollback. No winner. + const { stateFile, pathWithFakeGh } = setupConcurrencyRepo({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['human-user'], + labels: ['bug'], + }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + 101: makePR({ number: 101, author: 'bob', merged: true }), + }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0E8A16', + description: 'Assigned via /assign automation', + }, + }, + }); + + const { finalState } = await runConcurrentAssign({ + stateFile, + pathWithFakeGh, + issueNumber: 42, + commenters: ['alice', 'bob'], + }); + + const issue = finalState.issues['42']; + + // Human assignment preserved; neither bot user assigned + expect(issue._assignees).toContain('human-user'); + expect(issue._assignees).not.toContain('alice'); + expect(issue._assignees).not.toContain('bob'); + // No auto-assigned marker + expect(issue._label_names).not.toContain('auto-assigned'); + }, 60000); + + it('single process still succeeds normally (no regression)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + expect(result.state.labels['asnhist--alice']).toBeDefined(); + }); +}); diff --git a/scripts/tests/assign-workflow-behaviors.test.js b/scripts/tests/assign-workflow-behaviors.test.js new file mode 100644 index 0000000000..df2cc74983 --- /dev/null +++ b/scripts/tests/assign-workflow-behaviors.test.js @@ -0,0 +1,743 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for the /assign GitHub Action automation. + * + * Per RULES.md: these tests execute the REAL bash scripts against a stateful + * fake `gh` infrastructure adapter. They assert final issue state, comments, + * exit status, and preservation/destructive behavior — not call counts or + * mocked invocations. + * + * The fake gh (scripts/tests/fake-gh.py) models GitHub REST API state + * transitions. It is infrastructure, not a business-logic mirror. + */ + +import { describe, expect, it } from 'vitest'; +import yaml from 'js-yaml'; +import { writeFileSync } from 'fs'; +import * as nodePath from 'path'; +import { normalize, readRootFile } from './ocr-review-workflow-helpers.js'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + makeCrossRefEvent, + daysAgo, +} from './assign-helpers.js'; + +// --------------------------------------------------------------------------- +// Workflow gate / config tests +// --------------------------------------------------------------------------- + +describe('assign.yml workflow configuration', () => { + const source = readRootFile('.github/workflows/assign.yml'); + const workflow = yaml.load(source); + const job = workflow.jobs?.assign; + + it('triggers on issue_comment created and issues assigned', () => { + expect(workflow.on?.issue_comment?.types).toEqual(['created']); + expect(workflow.on?.issues?.types).toEqual(['assigned']); + expect(workflow.on?.pull_request).toBeUndefined(); + }); + + it('uses least-privilege permissions including pull-requests read', () => { + expect(workflow.permissions).toEqual({ + contents: 'read', + issues: 'write', + 'pull-requests': 'read', + }); + }); + + it('gates on exact /assign for issues (not PRs) and rejects bots', () => { + expect(job, 'assign job should exist').toBeTruthy(); + const condition = normalize(job.if); + expect(condition).toContain('github.event.issue.pull_request == null'); + expect(condition).toContain("github.event.comment.user.type != 'Bot'"); + expect(condition).toContain("github.event.comment.body == '/assign'"); + expect(condition).toContain( + `toJSON(github.event.comment.body) == '"/assign\\n"'`, + ); + // Must not use startsWith + expect(source).not.toMatch( + /startsWith\(toJSON\(github\.event\.comment\.body\)/, + ); + }); + + it('assign job has NO concurrency block (postconditions handle races)', () => { + expect(job.concurrency).toBeUndefined(); + }); + + it('passes env vars and runs the script', () => { + const runStep = job.steps?.find( + (s) => s.name === 'Run assign-issue script', + ); + expect(runStep?.run).toContain('./.github/scripts/assign-issue.sh'); + expect(runStep?.env?.ISSUE_NUMBER).toBe('${{ github.event.issue.number }}'); + expect(runStep?.env?.COMMENTER_LOGIN).toBe( + '${{ github.event.comment.user.login }}', + ); + expect(runStep?.env?.GH_TOKEN).toBe('${{ github.token }}'); + }); +}); + +describe('assign.yml record-history job', () => { + const workflow = yaml.load(readRootFile('.github/workflows/assign.yml')); + const job = workflow.jobs?.['record-history']; + + it('has a record-history job that fires on issues:assigned events', () => { + expect(job, 'record-history job should exist').toBeTruthy(); + expect(normalize(job.if)).toContain("github.event_name == 'issues'"); + expect(normalize(job.if)).toContain("github.event.action == 'assigned'"); + }); + + it('uses least-privilege permissions', () => { + expect(job.permissions).toEqual({ + contents: 'read', + issues: 'write', + }); + }); + + it('validates login from event payload and delegates to record-history script', () => { + const runStep = job.steps?.find( + (s) => s.name === 'Record assignment-history label', + ); + expect(runStep?.env?.ASSIGNEE_LOGIN).toBe( + '${{ github.event.assignee.login }}', + ); + const runText = normalize(runStep?.run); + // Must invoke the extracted record-history script + expect(runText).toContain('record-assignment-history.sh'); + // Must NOT contain inline label creation logic (delegated to script) + expect(runStep?.run).not.toMatch(/\/issues\/\d+\/labels/); + }); +}); + +describe('assign-stale-cleanup.yml workflow configuration', () => { + const source = readRootFile('.github/workflows/assign-stale-cleanup.yml'); + const workflow = yaml.load(source); + const job = workflow.jobs?.cleanup; + + it('runs on a daily schedule and workflow_dispatch', () => { + expect(workflow.on?.schedule?.[0]?.cron).toBe('0 7 * * *'); + expect(workflow.on?.workflow_dispatch).toBeDefined(); + }); + + it('guards scheduled runs to the canonical upstream repository', () => { + expect(normalize(job?.if)).toContain( + "github.repository == 'vybestack/llxprt-code'", + ); + expect(normalize(job?.if)).not.toContain('llpxrt-code'); + }); +}); + +// --------------------------------------------------------------------------- +// assign-issue.sh behavioral tests +// --------------------------------------------------------------------------- + +describe('assign-issue.sh behavioral', () => { + it('assigns a user with a merged PR', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ + number: 100, + author: 'alice', + merged: true, + mergedAt: '2025-06-15T00:00:00Z', + }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + const issue = result.state.issues['42']; + expect(issue._assignees).toContain('alice'); + expect(issue._label_names).toContain('auto-assigned'); + // Should have posted a success feedback comment by the bot + const feedbackComments = result.state.comments.filter( + (c) => + c.issue_number === 42 && + c.user.login === 'github-actions[bot]' && + c.body.includes(''), + ); + expect(feedbackComments.length).toBeGreaterThanOrEqual(1); + expect(feedbackComments[0].body).toContain('Assigned'); + expect(feedbackComments[0].user.login).toBe('github-actions[bot]'); + }); + + it('assigns a user in the assignment-history backfill file', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + }), + ); + const historyPath = nodePath.join(repo.dir, 'assignment-history.txt'); + writeFileSync(historyPath, ['alice', 'bob', 'charlie'].join('\n') + '\n'); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: historyPath }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('bob'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('assigns a user with a history label (per-login O(1) lookup)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + labels: { + 'auto-assigned': { + name: 'auto-assigned', + color: '0E8A16', + description: 'Assigned via /assign automation', + }, + 'asnhist--bob': { + name: 'asnhist--bob', + color: '0E8A16', + description: 'Issue assignment history index', + }, + }, + }), + ); + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'bob', + extraEnv: { ASSIGNMENT_HISTORY_FILE: '/nonexistent' }, + }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('bob'); + }); + + it('refuses to assign when issue is already assigned', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: ['someone-else'] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Exit 0 (expected refusal, posts feedback) + expect(result.status).toBe(0); + // alice NOT assigned + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // Original assignee preserved + expect(result.state.issues['42']._assignees).toContain('someone-else'); + // Feedback posted + const comments = result.state.comments.filter((c) => c.issue_number === 42); + expect(comments.some((c) => c.body.includes('already assigned'))).toBe( + true, + ); + }); + + it('refuses to assign when user is at the 3-issue cap', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + 2: makeIssue({ number: 2, assignees: ['alice'] }), + 3: makeIssue({ number: 3, assignees: ['alice'] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + const comments = result.state.comments.filter((c) => c.issue_number === 42); + expect(comments.some((c) => c.body.includes('open assigned issues'))).toBe( + true, + ); + expect(comments.some((c) => c.body.includes('maximum is'))).toBe(true); + }); + + it('refuses ineligible user with no merged PR and no prior assignment', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'newbie' }); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('newbie'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + const comments = result.state.comments.filter((c) => c.issue_number === 42); + expect(comments.some((c) => c.body.includes('eligibility requires'))).toBe( + true, + ); + }); + + it('fails closed (nonzero, no mutation) when guard API fails', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + // Fail the issue view (already-assigned guard) + fail_config: { 'repos/test/repo/issues/42': 'error' }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Must be nonzero — infrastructure failure + expect(result.status).not.toBe(0); + // No mutation + expect(result.state.issues['42']._assignees).toEqual([]); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('atomic assignee+label transition preserves existing labels', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: [], + labels: ['bug', 'help wanted'], + }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + const issue = result.state.issues['42']; + expect(issue._assignees).toContain('alice'); + // Original labels preserved + new auto-assigned added + expect(issue._label_names).toContain('bug'); + expect(issue._label_names).toContain('help wanted'); + expect(issue._label_names).toContain('auto-assigned'); + }); + + it('sticky feedback lookup only selects bot-authored marker comments', () => { + // A user-authored comment containing the marker should not be hijacked + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: ['someone-else'] }), + }, + comments: [ + { + id: 999, + issue_number: 42, + body: '\nUser-injected marker', + user: { login: 'malicious-user', type: 'User' }, + created_at: '2025-07-20T00:00:00Z', + updated_at: '2025-07-20T00:00:00Z', + }, + ], + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + // The user-authored comment must NOT be updated + const userComment = result.state.comments.find((c) => c.id === 999); + expect(userComment.body).toBe( + '\nUser-injected marker', + ); + // A NEW bot comment should be posted (not updating the user's) + const botComments = result.state.comments.filter( + (c) => c.issue_number === 42 && c.user.login === 'github-actions[bot]', + ); + expect(botComments.length).toBeGreaterThanOrEqual(1); + }); +}); + +// --------------------------------------------------------------------------- +// unassign-stale-issues.sh behavioral tests +// --------------------------------------------------------------------------- + +describe('unassign-stale-issues.sh behavioral', () => { + it('preserves manual co-assignees when removing stale bot-assignee', () => { + const assignedAt = daysAgo(20); // 20 days ago, > 14 day threshold + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user', 'manual-contributor'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + const issue = result.state.issues['42']; + // stale-user removed, manual-contributor preserved + expect(issue._assignees).not.toContain('stale-user'); + expect(issue._assignees).toContain('manual-contributor'); + }); + + it('never unassigns acoliver', () => { + const assignedAt = daysAgo(30); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['acoliver'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'acoliver', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('acoliver'); + }); + + it('removes stale bot-assignee with no qualifying linked PR', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('retains recent assignment (younger than 14 days)', () => { + const assignedAt = daysAgo(5); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['recent-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'recent-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('recent-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('retains assignment when a qualifying linked PR exists after assignment', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['active-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'active-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + // Cross-reference: a PR by active-user linked 5 days after assignment + makeCrossRefEvent({ + number: 42, + prNumber: 200, + prAuthor: 'active-user', + createdAt: daysAgo(15), + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).toContain('active-user'); + expect(result.state.issues['42']._label_names).toContain('auto-assigned'); + }); + + it('preserves state and reports nonzero when timeline query fails', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + number: 42, + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + fail_config: { + 'repos/test/repo/issues/42/timeline': 'error', + }, + }), + ); + + const result = repo.runCleanup(); + + // Must report nonzero (unknown state, preserve) + expect(result.status).not.toBe(0); + // State preserved — no destructive cleanup + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); + + it('removes no human assignee when there is no bot-assigned provenance', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['manually-assigned-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + // Assigned by a human, NOT github-actions[bot] + makeAssignedEvent({ + number: 42, + assignee: 'manually-assigned-user', + actor: 'some-admin', + createdAt: assignedAt, + }), + ], + }, + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).toBe(0); + // The manually-assigned user must NOT be unassigned + expect(result.state.issues['42']._assignees).toContain( + 'manually-assigned-user', + ); + }); + + it('paginates candidate discovery beyond one page', () => { + // Create >30 issues (default page size) to force pagination + const issues = {}; + const timeline = {}; + for (let i = 1; i <= 35; i++) { + issues[String(i)] = makeIssue({ + number: i, + assignees: [`user-${i}`], + labels: ['auto-assigned'], + }); + timeline[String(i)] = [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + makeAssignedEvent({ + number: i, + assignee: `user-${i}`, + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + ]; + } + + const repo = createFakeRepo(defaultStateWith({ issues, timeline })); + + const result = repo.runCleanup(); + + // All 35 should be discovered and processed (pagination works) + expect(result.status).toBe(0); + // Verify issues from the second page (31-35) were processed + for (let i = 31; i <= 35; i++) { + expect(result.state.issues[String(i)]._assignees).not.toContain( + `user-${i}`, + ); + } + }, 30000); +}); + +// --------------------------------------------------------------------------- +// CONTRIBUTING.md docs test +// --------------------------------------------------------------------------- + +describe('CONTRIBUTING.md self-assign docs', () => { + it('documents /assign eligibility, cap, and stale cleanup without OWNER/MEMBER', () => { + const docs = readRootFile('CONTRIBUTING.md'); + expect(docs).toContain('/assign'); + expect(docs).toContain('merged PR'); + expect(docs).toContain('3'); + expect(docs).toContain('auto-assigned'); + expect(docs).toContain('2 weeks'); + // Must NOT mention trusted-contributors or OWNER/MEMBER/COLLABORATOR + // as eligibility paths + expect(docs).not.toContain('trusted-contributors'); + // Check the self-assign section specifically doesn't mention + // owner/member/collaborator as eligibility + const selfAssignSection = docs.match( + /### Self Assigning Issues[\s\S]*?(?=\n### )/, + ); + expect( + selfAssignSection, + 'Self Assigning Issues section should exist', + ).toBeTruthy(); + const sectionText = normalize(selfAssignSection[0]); + expect(sectionText).not.toMatch(/owner|member|collaborator/i); + // Must not claim write access is the only cause of assignment failure + expect(sectionText).not.toContain('write access is the only'); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} diff --git a/scripts/tests/assign-workflow.test.js b/scripts/tests/assign-workflow.test.js index 184bd64b5a..4cba9f9a26 100644 --- a/scripts/tests/assign-workflow.test.js +++ b/scripts/tests/assign-workflow.test.js @@ -4,6 +4,14 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * Structural/config tests for the /assign automation. + * + * Behavioral tests (executing the real scripts against a fake gh) live in + * assign-workflow-behaviors.test.js. This file validates workflow YAML + * structure, script presence, and documentation consistency. + */ + import { describe, expect, it } from 'vitest'; import yaml from 'js-yaml'; import * as fs from 'fs'; @@ -25,8 +33,9 @@ describe('.github/workflows/assign.yml', () => { const { source, workflow } = loadWorkflow('.github/workflows/assign.yml'); const job = workflow.jobs?.assign; - it('triggers on issue_comment created only', () => { + it('triggers on issue_comment created and issues assigned', () => { expect(workflow.on?.issue_comment?.types).toEqual(['created']); + expect(workflow.on?.issues?.types).toEqual(['assigned']); expect(workflow.on?.pull_request).toBeUndefined(); }); @@ -50,20 +59,16 @@ describe('.github/workflows/assign.yml', () => { expect(condition).toContain( `toJSON(github.event.comment.body) == '"/assign\\r\\n"'`, ); - // Must not use startsWith, which would accept `/assign foo`. expect(source).not.toMatch( /startsWith\(toJSON\(github\.event\.comment\.body\)/, ); }); - it('scopes concurrency by workflow, issue, and actor', () => { - expect(job.concurrency?.['cancel-in-progress']).toBe(true); - expect(normalize(job.concurrency?.group)).toBe( - '${{ github.workflow }}-${{ github.event.issue.number }}-${{ github.event.comment.user.login }}', - ); + it('assign job has NO concurrency block (postconditions handle races)', () => { + expect(job.concurrency).toBeUndefined(); }); - it('checks out with the ratchet-pinned checkout action and runs assign-issue.sh', () => { + it('passes env vars and runs the script', () => { const checkout = job.steps?.find((s) => s.name === 'Checkout repository'); expect(checkout?.uses).toBe( 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8', @@ -99,7 +104,6 @@ describe('.github/workflows/assign-stale-cleanup.yml', () => { expect(normalize(job?.if)).toContain( "github.repository == 'vybestack/llxprt-code'", ); - // Do not copy the historical typo from llxprt-scheduled-pr-triage.yml. expect(normalize(job?.if)).not.toContain('llpxrt-code'); }); @@ -119,7 +123,7 @@ describe('.github/workflows/assign-stale-cleanup.yml', () => { }); describe('.github/scripts assign automation', () => { - it('assign-issue.sh enforces eligibility, cap, label, and sticky feedback', () => { + it('assign-issue.sh uses gh api exclusively with fail-closed guards', () => { const script = fs.readFileSync( path.join(ROOT, '.github/scripts/assign-issue.sh'), 'utf8', @@ -127,14 +131,12 @@ describe('.github/scripts assign automation', () => { expect(script).toContain("MARKER=''"); expect(script).toContain("AUTO_ASSIGNED_LABEL='auto-assigned'"); expect(script).toContain('MAX_ASSIGNMENTS=3'); - expect(script).toContain('trusted-contributors.txt'); - expect(script).toContain('gh search prs'); - expect(script).toContain('--merged'); - expect(script).toContain('--add-assignee'); - expect(script).toContain('post_sticky_feedback'); + expect(script).toContain('gh api'); + expect(script).toContain('merged'); + expect(script).toContain('github-actions[bot]'); }); - it('unassign-stale-issues.sh uses a 14-day window and exempts acoliver', () => { + it('unassign-stale-issues.sh contains required structural markers (constants, provenance, exemption)', () => { const script = fs.readFileSync( path.join(ROOT, '.github/scripts/unassign-stale-issues.sh'), 'utf8', @@ -143,7 +145,8 @@ describe('.github/scripts assign automation', () => { expect(script).toContain("EXEMPT_LOGIN='acoliver'"); expect(script).toContain("AUTO_ASSIGNED_LABEL='auto-assigned'"); expect(script).toContain('retry_gh'); - expect(script).toContain('--remove-assignee'); + expect(script).toContain('github-actions[bot]'); + expect(script).toContain('timeline'); }); }); @@ -152,7 +155,6 @@ describe('CONTRIBUTING.md self-assign docs', () => { const docs = readRootFile('CONTRIBUTING.md'); expect(docs).toContain('/assign'); expect(docs).toContain('merged PR'); - expect(docs).toContain('trusted-contributors.txt'); expect(docs).toContain('3'); expect(docs).toContain('auto-assigned'); expect(docs).toContain('2 weeks'); diff --git a/scripts/tests/fake-gh.py b/scripts/tests/fake-gh.py new file mode 100644 index 0000000000..3135717345 --- /dev/null +++ b/scripts/tests/fake-gh.py @@ -0,0 +1,1207 @@ +#!/usr/bin/env python3 +""" +Fake gh CLI for assignment automation behavioral tests. + +Models GitHub REST API state transitions backed by a JSON state file. +Supports: gh api [--method METHOD] [--input -] [-f key=value]... + [-F key=value]... [--paginate] [--jq EXPR] + +Key behavioral modeling: + - --paginate: outputs SEPARATE JSON documents per page (concatenated), + exactly like real `gh api --paginate`. This is NOT one merged array. + - -f/--field values are always STRINGS. -F/--raw-field values are JSON-typed. + key[]=value syntax creates array entries (matching gh CLI behavior). + - PATCH/PUT with string-typed assignees/labels is rejected (validation error), + matching real GitHub behavior for malformed array fields. + - State-level "page_size" overrides per_page for test-controlled pagination. + - side_effects: mutate state on the Nth matching request (method+endpoint), + enabling race-condition tests. + - fail_config supports both flat {endpoint: type} and structured + {requests: [{method, endpoint, on_nth, type}]} for targeted injection. + - POST/DELETE targeted labels and assignees, silent dropping of configured + unassignable logins, assignment/unassignment timeline events with request + actor, label names with commas, and repository_url on PR cross refs. + - /issues listing includes PRs (like real GitHub); scripts must filter with + is:issue or pull_request checks. + +This is test infrastructure (not production code). It models only GitHub API +state transitions — it does NOT duplicate business logic from the scripts +under test. +""" + +import json +import os +import re +import subprocess +import sys +import urllib.parse + +try: + import fcntl +except ImportError: + fcntl = None + +StateFile = os.environ.get("GH_FAKE_STATE", "") +LockFile = StateFile + ".lock" if StateFile else "" + + +def die(msg, code=1): + sys.stderr.write(msg + "\n") + sys.exit(code) + + +def load_state(): + if not StateFile: + die("GH_FAKE_STATE not set") + if not os.path.exists(StateFile): + die(f"State file not found: {StateFile}") + with open(StateFile) as f: + return json.load(f) + + +def save_state(state): + tmp = StateFile + ".tmp" + with open(tmp, "w") as f: + json.dump(state, f, indent=2) + os.replace(tmp, StateFile) + + +def log_operation(state, method, endpoint, status): + """Append to the operation log for strict state assertions.""" + state.setdefault("_op_log", []).append({ + "method": method, + "endpoint": endpoint, + "status": status, + }) + + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +def parse_api_args(argv): + """Parse `gh api` arguments into a structured dict. + + Key distinction (matching real gh CLI): + -f / --raw-field → string value (sent as-is) + -F / --field → typed value (JSON magic conversion) + Both support key[]=value syntax for array fields. + """ + method = "GET" + path = None + input_file = None + string_fields = [] + raw_fields = [] + paginate = False + jq_filter = None + silent = False + + i = 0 + while i < len(argv): + arg = argv[i] + if arg in ("--method", "-X"): + method = argv[i + 1].upper() + i += 2 + elif arg == "--input": + input_file = argv[i + 1] + i += 2 + elif arg in ("-f", "--raw-field"): + kv = argv[i + 1] + if "=" in kv: + k, v = kv.split("=", 1) + string_fields.append((k, v)) + i += 2 + elif arg in ("-F", "--field"): + kv = argv[i + 1] + if "=" in kv: + k, raw_v = kv.split("=", 1) + parsed = _coerce_field_value(raw_v) + raw_fields.append((k, parsed)) + i += 2 + elif arg == "--paginate": + paginate = True + i += 1 + elif arg == "--jq": + jq_filter = argv[i + 1] + i += 2 + elif arg == "-q": + jq_filter = argv[i + 1] + i += 2 + elif arg == "--silent": + silent = True + i += 1 + elif arg in ("-H", "--header"): + i += 2 + elif arg in ("-s", "--include", "-i"): + i += 1 + elif arg == "-h": + die("usage: gh api [flags]", 0) + elif not arg.startswith("-") and path is None: + path = arg + i += 1 + else: + i += 1 + + body = None + if input_file: + if input_file == "-": + raw = sys.stdin.read() + else: + with open(input_file) as f: + raw = f.read() + if raw.strip(): + body = json.loads(raw) + elif string_fields or raw_fields: + body = build_body_from_fields(string_fields, raw_fields) + + return { + "method": method, + "path": path, + "body": body, + "paginate": paginate, + "jq_filter": jq_filter, + "silent": silent, + } + + +def build_body_from_fields(string_fields, raw_fields): + """Build a dict body from -f (raw/string) and -F (typed) fields. + + Handles key[]=value array syntax: multiple key[] entries accumulate. + For non-array keys, later values override earlier ones. + """ + body = {} + + for key, value in string_fields: + _add_field(body, key, value) + + for key, value in raw_fields: + _add_field(body, key, value) + + return body + + +def _coerce_field_value(raw_v): + """Mimic gh -F/--field magic type conversion. + + - literal true/false/null → JSON bool/null + - integer numbers → int + - starts with @ → read file content (not needed for tests) + - everything else → string + """ + if raw_v == "true": + return True + if raw_v == "false": + return False + if raw_v == "null": + return None + try: + return int(raw_v) + except ValueError: + pass + return raw_v + + +def _add_field(body, key, value): + if key.endswith("[]"): + real_key = key[:-2] + body.setdefault(real_key, []).append(value) + else: + body[key] = value + + +# --------------------------------------------------------------------------- +# Path / query parsing +# --------------------------------------------------------------------------- + +def split_path(raw_path): + if "?" in raw_path: + base, query = raw_path.split("?", 1) + else: + base, query = raw_path, "" + params = {} + if query: + for pair in query.split("&"): + if "=" in pair: + k, v = pair.split("=", 1) + params[urllib.parse.unquote_plus(k)] = urllib.parse.unquote_plus(v) + else: + params[urllib.parse.unquote_plus(pair)] = "" + return base, params + + +# --------------------------------------------------------------------------- +# Fail-config matching +# --------------------------------------------------------------------------- + +def check_fail(state, method, base_path): + """Check if a request should fail. + + Supports two formats: + 1. Flat: {"endpoint_pattern": "error"|"malformed"} — applies to ALL methods + 2. Structured: {"requests": [{"method": "POST", "endpoint": "...", + "on_nth": 1, "type": "error"|"malformed"}]} + + For structured format, nth occurrence is tracked per (method, endpoint). + """ + fc = state.get("fail_config", {}) + + # Check structured requests format first + requests = fc.get("requests", []) + for req in requests: + req_method = req.get("method", "GET").upper() + if req_method != method: + continue + endpoint = req.get("endpoint", "") + if endpoint != base_path: + regex = re.escape(endpoint).replace(r"\*", ".*") + if not re.fullmatch(regex, base_path): + continue + on_nth = req.get("on_nth", 1) + count_key = f"_fail_count_{method}_{endpoint}" + state[count_key] = state.get(count_key, 0) + 1 + if state[count_key] == on_nth: + save_state(state) + fail_type = req.get("type", "error") + http_status = req.get("http_status") + return True, {"type": fail_type, "http_status": http_status} + save_state(state) + + # Check flat format (applies to all methods, all occurrences) + for pattern, fail_type in fc.items(): + if pattern == "requests": + continue + if isinstance(fail_type, dict): + continue + if pattern == base_path: + return True, {"type": fail_type, "http_status": None} + regex = re.escape(pattern).replace(r"\*", ".*") + if re.fullmatch(regex, base_path): + return True, {"type": fail_type, "http_status": None} + + return False, None + + +# --------------------------------------------------------------------------- +# Side effects (for race-condition tests) +# --------------------------------------------------------------------------- + +def apply_side_effects(state, method, base_path): + """Mutate state on the Nth matching request, then persist. + + Supports both pre-handler effects (applied BEFORE the request is + processed, for race simulation) and post-handler effects (applied + AFTER the request is processed, for post-handler race simulation). + + Side effects are applied exactly once per invocation. Counting is + per (method, endpoint). + """ + changed = False + for se in state.get("side_effects", []): + if se.get("method", "GET").upper() != method.upper(): + continue + endpoint = se.get("endpoint", "") + if endpoint != base_path: + regex = re.escape(endpoint).replace(r"\*", ".*") + if not re.fullmatch(regex, base_path): + continue + count_key = f"_se_count_{method}_{endpoint}" + state[count_key] = state.get(count_key, 0) + 1 + changed = True + if state[count_key] != se.get("on_nth", 1): + continue + timing = se.get("timing", "pre") + if timing == "post": + continue + _apply_side_effect_action(state, se) + if changed: + save_state(state) + + +def apply_post_side_effects(state, method, base_path): + """Apply post-handler side effects (after the request was processed).""" + changed = False + for se in state.get("side_effects", []): + if se.get("method", "GET").upper() != method.upper(): + continue + endpoint = se.get("endpoint", "") + if endpoint != base_path: + regex = re.escape(endpoint).replace(r"\*", ".*") + if not re.fullmatch(regex, base_path): + continue + count_key = f"_se_count_{method}_{endpoint}" + current = state.get(count_key, 0) + if current != se.get("on_nth", 1): + continue + timing = se.get("timing", "pre") + if timing != "post": + continue + _apply_side_effect_action(state, se) + changed = True + if changed: + save_state(state) + + +def _apply_side_effect_action(state, se): + action = se.get("action") + issue = state.get("issues", {}).get(str(se.get("issue", ""))) + if action == "add_assignee" and issue is not None: + a = se["assignee"] + if a not in issue.get("_assignees", []): + issue.setdefault("_assignees", []).append(a) + _add_timeline_event(state, se.get("issue"), { + "event": "assigned", + "actor": {"login": se.get("actor", "concurrent-user"), "type": "User"}, + "assignee": {"login": a}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + elif action == "readd_assignee" and issue is not None: + a = se["assignee"] + issue["_assignees"] = issue.get("_assignees", []) + if a not in issue["_assignees"]: + issue["_assignees"].append(a) + elif action == "remove_label" and issue is not None: + lbl = se["label"] + issue["_label_names"] = [ + l for l in issue.get("_label_names", []) if l != lbl + ] + elif action == "add_assignee_to_search" and issue is not None: + a = se["assignee"] + if a not in issue.get("_assignees", []): + issue.setdefault("_assignees", []).append(a) + elif action == "add_label" and issue is not None: + lbl = se["label"] + if lbl not in issue.get("_label_names", []): + issue.setdefault("_label_names", []).append(lbl) + elif action == "set_state" and issue is not None: + issue["state"] = se.get("state", "open") + elif action == "add_cross_ref": + issue_num = se.get("issue") + if issue_num is not None: + _add_timeline_event(state, issue_num, { + "event": "cross-referenced", + "actor": {"login": se.get("pr_author", "unknown"), "type": "User"}, + "source": { + "issue": { + "number": se.get("pr_number", 0), + "title": f"PR #{se.get('pr_number', 0)}", + "pull_request": {"url": ""}, + "user": { + "login": se.get("pr_author", "unknown"), + "type": "User", + }, + "repository_url": se.get( + "repo_url", + "https://api.github.com/repos/test/repo", + ), + } + }, + "created_at": se.get( + "created_at", state.get("now", "2025-07-23T00:00:00Z") + ), + }) + + +# --------------------------------------------------------------------------- +# Timeline event helper +# --------------------------------------------------------------------------- + +def _next_event_id(state): + eid = state.get("next_event_id", 10000) + state["next_event_id"] = eid + 1 + return eid + + +def _add_timeline_event(state, issue_num, event): + """Add a timeline/event entry for an issue.""" + event = dict(event) + event["id"] = _next_event_id(state) + key = str(issue_num) + state.setdefault("timeline", {}).setdefault(key, []).append(event) + state.setdefault("events", {}).setdefault(key, []).append(event) + + +# --------------------------------------------------------------------------- +# Response shaping +# --------------------------------------------------------------------------- + +def issue_to_api(issue, state): + labels = [] + for name in issue.get("_label_names", []): + lbl = state.get("labels", {}).get(name) + if lbl: + labels.append(dict(lbl)) + else: + labels.append({"name": name}) + return { + "number": issue["number"], + "title": issue.get("title", ""), + "body": issue.get("body", ""), + "state": issue.get("state", "open"), + "assignees": [{"login": l} for l in issue.get("_assignees", [])], + "labels": labels, + "created_at": issue.get("created_at", ""), + "updated_at": issue.get("updated_at", ""), + "user": issue.get("user", {"login": "reporter", "type": "User"}), + "pull_request": issue.get("pull_request"), + } + + +def pr_to_issue_api(pr, state): + """Convert a PR to the issue-API shape (GitHub /issues includes PRs).""" + labels = [] + for name in pr.get("_label_names", pr.get("labels", [])): + if isinstance(name, str): + lbl = state.get("labels", {}).get(name) + if lbl: + labels.append(dict(lbl)) + else: + labels.append({"name": name}) + elif isinstance(name, dict): + labels.append(dict(name)) + return { + "number": pr["number"], + "title": pr.get("title", ""), + "body": pr.get("body", ""), + "state": pr.get("state", "open"), + "assignees": [{"login": l} for l in pr.get("_assignees", pr.get("assignees", []))], + "labels": labels, + "created_at": pr.get("created_at", ""), + "updated_at": pr.get("updated_at", ""), + "user": pr.get("user", {"login": "unknown", "type": "User"}), + "pull_request": pr.get("pull_request", {"url": ""}), + "merged_at": pr.get("merged_at"), + } + + +def comment_to_api(comment): + return { + "id": comment["id"], + "body": comment.get("body", ""), + "user": comment.get("user", {"login": "unknown", "type": "User"}), + "created_at": comment.get("created_at", ""), + "updated_at": comment.get("updated_at", ""), + "issue_url": f"https://api.github.com/repos/test/repo/issues/{comment.get('issue_number', 0)}", + } + + +def event_to_api(event): + out = { + "id": event.get("id", 0), + "event": event.get("event", ""), + "created_at": event.get("created_at", ""), + "actor": event.get("actor", {"login": "unknown", "type": "User"}), + } + if "assignee" in event: + out["assignee"] = event["assignee"] + if "commit_id" in event: + out["commit_id"] = event["commit_id"] + if "commit_url" in event: + out["commit_url"] = event["commit_url"] + if "label" in event: + out["label"] = event["label"] + if "issue" in event: + out["issue"] = event["issue"] + return out + + +def timeline_event_to_api(event): + out = event_to_api(event) + if "source" in event: + out["source"] = event["source"] + return out + + +# --------------------------------------------------------------------------- +# Pagination +# --------------------------------------------------------------------------- + +DEFAULT_PER_PAGE = 30 + + +def get_per_page(params, state): + state_ps = state.get("page_size") + if state_ps: + return max(1, int(state_ps)) + pp = params.get("per_page") + if pp: + try: + return max(1, int(pp)) + except ValueError: + pass + return DEFAULT_PER_PAGE + + +def split_pages(items, params, do_paginate, state): + pp = get_per_page(params, state) + page = 1 + if "page" in params: + try: + page = max(1, int(params["page"])) + except ValueError: + pass + if do_paginate: + pages = [] + for i in range(0, len(items), pp): + pages.append(items[i : i + pp]) + if not pages: + pages.append([]) + return pages + start = (page - 1) * pp + return [items[start : start + pp]] + + +# --------------------------------------------------------------------------- +# Endpoint handlers +# --------------------------------------------------------------------------- + +REPO_PREFIX = r"repos/[^/]+/[^/]+" +REPO_URL = "https://api.github.com/repos/test/repo" + + +def handle_get(state, base_path, params, do_paginate): + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)$", base_path) + if m: + num = int(m.group(1)) + issue = state.get("issues", {}).get(str(num)) + if issue is not None: + return issue_to_api(issue, state) + pr = state.get("prs", {}).get(str(num)) + if pr is not None: + return pr_to_issue_api(pr, state) + die(f"HTTP 404: Issue #{num} not found", 1) + + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/comments$", base_path) + if m: + num = int(m.group(1)) + return [ + comment_to_api(c) + for c in state.get("comments", []) + if c.get("issue_number") == num + ] + + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/timeline$", base_path) + if m: + num = int(m.group(1)) + events = state.get("timeline", {}).get(str(num), []) + return [timeline_event_to_api(e) for e in events] + + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/events$", base_path) + if m: + num = int(m.group(1)) + events = state.get("events", {}).get(str(num), []) + return [event_to_api(e) for e in events] + + m = re.match(rf"^{REPO_PREFIX}/issues/events$", base_path) + if m: + # GitHub's repository-wide /issues/events endpoint includes events + # for BOTH issues and PRs (PRs are issues in the API). + all_events = [] + for num_str, issue in state.get("issues", {}).items(): + issue_events = state.get("events", {}).get(num_str, []) + issue_api = issue_to_api(issue, state) + for e in issue_events: + enriched = dict(e) + enriched["issue"] = issue_api + all_events.append(enriched) + for num_str, pr in state.get("prs", {}).items(): + pr_events = state.get("events", {}).get(num_str, []) + pr_api = pr_to_issue_api(pr, state) + for e in pr_events: + enriched = dict(e) + enriched["issue"] = pr_api + all_events.append(enriched) + return [event_to_api(e) for e in all_events] + + m = re.match(rf"^{REPO_PREFIX}/issues$", base_path) + if m: + results = [] + # GitHub defaults omitted state to "open" for /issues listing. + state_filter = params.get("state", "open") + assignee_filter = params.get("assignee") + label_filter = params.get("labels") + for _num_str, issue in state.get("issues", {}).items(): + if state_filter == "open" and issue.get("state", "open") != "open": + continue + if state_filter == "closed" and issue.get("state") != "closed": + continue + if state_filter == "all": + pass + if assignee_filter and assignee_filter not in issue.get("_assignees", []): + continue + if label_filter: + wanted = set(label_filter.split(",")) + have = set(issue.get("_label_names", [])) + if not wanted.intersection(have): + continue + results.append(issue_to_api(issue, state)) + # GitHub /issues includes PRs too (unless filtered by query) + for _num_str, pr in state.get("prs", {}).items(): + if state_filter == "open" and pr.get("state", "open") != "open": + continue + if state_filter == "closed" and pr.get("state") != "closed": + continue + if state_filter == "all": + pass + if assignee_filter: + pr_assignees = pr.get("_assignees", pr.get("assignees", [])) + if assignee_filter not in pr_assignees: + continue + if label_filter: + wanted = set(label_filter.split(",")) + have = set(pr.get("_label_names", [])) + if not wanted.intersection(have): + continue + results.append(pr_to_issue_api(pr, state)) + return results + + m = re.match(rf"^{REPO_PREFIX}/issues/comments/(\d+)$", base_path) + if m: + cid = int(m.group(1)) + for c in state.get("comments", []): + if c["id"] == cid: + return comment_to_api(c) + die(f"HTTP 404: Comment {cid} not found", 1) + + m = re.match(rf"^{REPO_PREFIX}/labels/(.+)$", base_path) + if m: + name = urllib.parse.unquote(m.group(1)) + lbl = state.get("labels", {}).get(name) + if lbl is None: + die(f"HTTP 404: Label '{name}' not found", 1) + return dict(lbl) + + m = re.match(rf"^{REPO_PREFIX}/labels$", base_path) + if m: + return [dict(l) for l in state.get("labels", {}).values()] + + m = re.match(r"^search/issues$", base_path) + if m: + return handle_search(state, params) + + die(f"HTTP 404: Unknown GET endpoint: {base_path}", 1) + + +def handle_search(state, params): + q = params.get("q", "") + # After URL decoding (unquote_plus already converted '+' to space), + # split on whitespace only — a literal '+' in a search term would have + # been percent-encoded as %2B and should NOT be treated as a separator. + terms = [t.strip() for t in re.split(r"\s+", q) if t.strip()] + repo_filter = None + author = None + item_type = None + is_merged = False + is_open = False + is_closed = False + is_issue = False + is_pr = False + assignee = None + state_filter = None + + for term in terms: + if term.startswith("repo:"): + repo_filter = term[5:] + elif term.startswith("author:"): + author = term[7:] + elif term.startswith("type:"): + item_type = term[5:] + elif term.startswith("assignee:"): + assignee = term[9:] + elif term.startswith("state:"): + state_filter = term[6:] + elif term.startswith("is:"): + qualifier = term[3:] + if qualifier == "merged": + is_merged = True + elif qualifier == "open": + is_open = True + elif qualifier == "closed": + is_closed = True + elif qualifier == "issue": + is_issue = True + elif qualifier == "pr": + is_pr = True + + # repo: filter — fake models a single repo, so if repo: doesn't match the + # configured GITHUB_REPOSITORY, return zero results. + configured_repo = state.get("repo", "test/repo") + if repo_filter is not None and repo_filter != configured_repo: + return { + "total_count": 0, + "incomplete_results": False, + "items": [], + } + + # Resolve type from is: qualifiers if type: not specified + if item_type is None: + if is_issue: + item_type = "issue" + elif is_pr: + item_type = "pr" + + # Resolve state filter + if state_filter is None: + if is_open: + state_filter = "open" + elif is_closed: + state_filter = "closed" + + results = [] + + # Include issues (unless explicitly filtering to PRs only) + if item_type != "pr": + for _num_str, issue in state.get("issues", {}).items(): + if assignee and assignee not in issue.get("_assignees", []): + continue + if author and issue.get("user", {}).get("login") != author: + continue + if state_filter: + if state_filter == "open" and issue.get("state", "open") != "open": + continue + if state_filter == "closed" and issue.get("state") != "closed": + continue + results.append(issue_to_api(issue, state)) + + # Include PRs (unless explicitly filtering to issues only) + if item_type != "issue": + for _num_str, pr in state.get("prs", {}).items(): + if author and pr.get("user", {}).get("login") != author: + continue + if is_merged and not pr.get("merged_at"): + continue + if state_filter: + if state_filter == "open" and pr.get("state", "open") != "open": + continue + if state_filter == "closed" and pr.get("state") != "closed": + continue + if assignee: + pr_assignees = pr.get("_assignees", pr.get("assignees", [])) + if assignee not in pr_assignees: + continue + results.append(pr_to_issue_api(pr, state)) + + # Final type filter + if item_type == "pr": + results = [r for r in results if r.get("pull_request") is not None] + elif item_type == "issue": + results = [r for r in results if r.get("pull_request") is None] + + return { + "total_count": len(results), + "incomplete_results": state.get("search_incomplete", False), + "items": results, + } + + +def handle_post(state, base_path, body): + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/comments$", base_path) + if m: + num = int(m.group(1)) + cid = state.get("next_comment_id", 100) + state["next_comment_id"] = cid + 1 + comment = { + "id": cid, + "issue_number": num, + "body": body.get("body", "") if body else "", + "user": {"login": "github-actions[bot]", "type": "Bot"}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + "updated_at": state.get("now", "2025-07-23T00:00:00Z"), + } + state.setdefault("comments", []).append(comment) + save_state(state) + return comment_to_api(comment) + + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/labels$", base_path) + if m: + num = int(m.group(1)) + issue = state.get("issues", {}).get(str(num)) + if issue is None: + die(f"HTTP 404: Issue #{num} not found", 1) + if body and isinstance(body, list): + label_names = body + elif body and "labels" in body and isinstance(body["labels"], list): + label_names = body["labels"] + else: + label_names = [] + for name in label_names: + if name not in issue.get("_label_names", []): + issue.setdefault("_label_names", []).append(name) + _add_timeline_event(state, num, { + "event": "labeled", + "actor": {"login": "github-actions[bot]", "type": "Bot"}, + "label": {"name": name}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + issue["updated_at"] = state.get("now", "2025-07-23T00:00:00Z") + save_state(state) + return issue_to_api(issue, state) + + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/assignees$", base_path) + if m: + num = int(m.group(1)) + issue = state.get("issues", {}).get(str(num)) + if issue is None: + die(f"HTTP 404: Issue #{num} not found", 1) + if body and "assignees" in body and isinstance(body["assignees"], list): + logins = body["assignees"] + elif body and isinstance(body, list): + logins = body + else: + logins = [] + unassignable = state.get("unassignable_logins", []) + for login in logins: + if login in unassignable: + continue + if login not in issue.get("_assignees", []): + issue.setdefault("_assignees", []).append(login) + _add_timeline_event(state, num, { + "event": "assigned", + "actor": {"login": "github-actions[bot]", "type": "Bot"}, + "assignee": {"login": login}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + issue["updated_at"] = state.get("now", "2025-07-23T00:00:00Z") + save_state(state) + return issue_to_api(issue, state) + + m = re.match(rf"^{REPO_PREFIX}/labels$", base_path) + if m: + name = body.get("name", "") if body else "" + if not name: + die( + json.dumps( + {"message": "Validation Failed", "errors": [{"field": "name", "code": "missing"}]} + ), + 1, + ) + if len(name) > 50: + die( + json.dumps( + { + "message": "Validation Failed", + "errors": [{"field": "name", "code": "too_long", + "message": "length must be <= 50"}], + } + ), + 1, + ) + if name in state.get("labels", {}): + die( + json.dumps( + {"message": "Validation Failed", "errors": [{"code": "already_exists"}]} + ), + 1, + ) + lbl = { + "name": name, + "color": body.get("color", "ededed") if body else "ededed", + "description": body.get("description", "") if body else "", + } + state.setdefault("labels", {})[name] = lbl + save_state(state) + return dict(lbl) + + die(f"HTTP 404: Unknown POST endpoint: {base_path}", 1) + + +def validate_array_field(body, field_name): + """Reject string-typed array fields (--field sends strings, not arrays).""" + if field_name in body and not isinstance(body[field_name], list): + die( + json.dumps( + { + "message": "Validation Failed", + "errors": [ + { + "field": field_name, + "code": "invalid", + "message": f"Expected array, got string for '{field_name}'", + } + ], + } + ), + 1, + ) + + +def handle_patch(state, base_path, body): + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)$", base_path) + if m: + num = int(m.group(1)) + issue = state.get("issues", {}).get(str(num)) + if issue is None: + die(f"HTTP 404: Issue #{num} not found", 1) + if body: + validate_array_field(body, "assignees") + validate_array_field(body, "labels") + if "assignees" in body: + issue["_assignees"] = list(body["assignees"]) + if "labels" in body: + issue["_label_names"] = list(body["labels"]) + if "state" in body: + issue["state"] = body["state"] + if "title" in body: + issue["title"] = body["title"] + if "body" in body: + issue["body"] = body["body"] + issue["updated_at"] = state.get("now", "2025-07-23T00:00:00Z") + save_state(state) + return issue_to_api(issue, state) + + m = re.match(rf"^{REPO_PREFIX}/issues/comments/(\d+)$", base_path) + if m: + cid = int(m.group(1)) + for c in state.get("comments", []): + if c["id"] == cid: + if body and "body" in body: + c["body"] = body["body"] + c["updated_at"] = state.get("now", "2025-07-23T00:00:00Z") + save_state(state) + return comment_to_api(c) + die(f"HTTP 404: Comment {cid} not found", 1) + + die(f"HTTP 404: Unknown PATCH endpoint: {base_path}", 1) + + +def handle_delete(state, base_path, body): + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/assignees$", base_path) + if m: + num = int(m.group(1)) + issue = state.get("issues", {}).get(str(num)) + if issue is None: + die(f"HTTP 404: Issue #{num} not found", 1) + logins = [] + if body and "assignees" in body and isinstance(body["assignees"], list): + logins = body["assignees"] + for login in logins: + if login in issue.get("_assignees", []): + issue["_assignees"] = [a for a in issue["_assignees"] if a != login] + _add_timeline_event(state, num, { + "event": "unassigned", + "actor": {"login": "github-actions[bot]", "type": "Bot"}, + "assignee": {"login": login}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + issue["updated_at"] = state.get("now", "2025-07-23T00:00:00Z") + save_state(state) + return issue_to_api(issue, state) + + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/labels/(.+)$", base_path) + if m: + num = int(m.group(1)) + name = urllib.parse.unquote(m.group(2)) + issue = state.get("issues", {}).get(str(num)) + if issue is None: + die(f"HTTP 404: Issue #{num} not found", 1) + # GitHub returns 404 for DELETE of a label not attached to the issue. + if name not in issue.get("_label_names", []): + die( + json.dumps({ + "message": "Not Found", + "documentation_url": "https://docs.github.com/rest/issues/labels", + "status": "404", + }), + 1, + ) + issue["_label_names"] = [l for l in issue["_label_names"] if l != name] + _add_timeline_event(state, num, { + "event": "unlabeled", + "actor": {"login": "github-actions[bot]", "type": "Bot"}, + "label": {"name": name}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + issue["updated_at"] = state.get("now", "2025-07-23T00:00:00Z") + save_state(state) + return issue_to_api(issue, state) + + m = re.match(rf"^{REPO_PREFIX}/issues/(\d+)/labels$", base_path) + if m: + num = int(m.group(1)) + issue = state.get("issues", {}).get(str(num)) + if issue is None: + die(f"HTTP 404: Issue #{num} not found", 1) + old_labels = list(issue.get("_label_names", [])) + issue["_label_names"] = [] + for name in old_labels: + _add_timeline_event(state, num, { + "event": "unlabeled", + "actor": {"login": "github-actions[bot]", "type": "Bot"}, + "label": {"name": name}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + issue["updated_at"] = state.get("now", "2025-07-23T00:00:00Z") + save_state(state) + return issue_to_api(issue, state) + + die(f"HTTP 404: Unknown DELETE endpoint: {base_path}", 1) + + +# --------------------------------------------------------------------------- +# jq delegation +# --------------------------------------------------------------------------- + +def run_jq(data_str, jq_filter): + proc = subprocess.run( + ["jq", "-r", jq_filter], + input=data_str, + capture_output=True, + text=True, + ) + return proc.returncode, proc.stdout, proc.stderr + + +def acquire_lock(): + """Acquire an exclusive OS file lock for process-safe state access. + + Returns the lock file descriptor, or None if locking is unavailable. + The lock file is created if it doesn't exist. The lock is exclusive + (LOCK_EX) and blocking — callers serialize automatically. + """ + if fcntl is None or not LockFile: + return None + lock_fd = os.open(LockFile, os.O_CREAT | os.O_RDWR, 0o644) + fcntl.flock(lock_fd, fcntl.LOCK_EX) + return lock_fd + + +def release_lock(lock_fd): + """Release the file lock and close the lock file descriptor.""" + if lock_fd is None: + return + fcntl.flock(lock_fd, fcntl.LOCK_UN) + os.close(lock_fd) + + +# --------------------------------------------------------------------------- +# Main dispatch +# --------------------------------------------------------------------------- + +def main(): + argv = sys.argv[1:] + + if not argv: + die("usage: gh api [flags]") + + if argv[0] != "api": + die(f"Fake gh only supports 'api' subcommand, got: {argv[0]}") + + parsed = parse_api_args(argv[1:]) + method = parsed["method"] + raw_path = parsed["path"] + body = parsed["body"] + do_paginate = parsed["paginate"] + jq_filter = parsed["jq_filter"] + silent = parsed["silent"] + + if raw_path is None: + die("error: no path specified") + + raw_path = raw_path.lstrip("/") + base_path, params = split_path(raw_path) + + lock_fd = acquire_lock() + try: + _process_request(method, base_path, params, body, do_paginate, jq_filter, silent) + finally: + release_lock(lock_fd) + + +def _process_request(method, base_path, params, body, do_paginate, jq_filter, silent): + state = load_state() + + # Apply side effects exactly once (race simulation) + apply_side_effects(state, method, base_path) + state = load_state() + + # Check failure injection (method + endpoint + nth aware) + should_fail, fail_info = check_fail(state, method, base_path) + if should_fail: + fail_type = fail_info.get("type", "error") if isinstance(fail_info, dict) else fail_info + http_status = fail_info.get("http_status") if isinstance(fail_info, dict) else None + log_operation(state, method, base_path, "failed") + save_state(state) + if fail_type == "malformed": + raw = "not valid json {{{" + if jq_filter: + rc, _filtered, err = run_jq(raw, jq_filter) + if rc != 0: + sys.stderr.write(err) + sys.exit(rc) + if not silent: + sys.stdout.write(raw + "\n") + sys.exit(0) + elif fail_type == "not_found": + status = http_status or 404 + die( + json.dumps({ + "message": "Not Found", + "documentation_url": "", + "status": str(status), + }), + 1, + ) + else: + status = http_status or 500 + die( + json.dumps({ + "message": "Server Error", + "documentation_url": "", + "status": str(status), + }), + 1, + ) + + if method == "GET": + result = handle_get(state, base_path, params, do_paginate) + elif method == "POST": + result = handle_post(state, base_path, body) + elif method == "PATCH": + result = handle_patch(state, base_path, body) + elif method == "DELETE": + result = handle_delete(state, base_path, body) + else: + die(f"Unsupported method: {method}", 1) + + log_operation(state, method, base_path, "ok") + save_state(state) + + # Apply post-handler side effects (after state was mutated by this request) + apply_post_side_effects(state, method, base_path) + + # Emit configured stderr warnings (simulates gh CLI writing to stderr + # while still returning valid JSON on stdout — e.g. deprecation notices). + for warn in state.get("stderr_warnings", []): + if warn.get("method", "GET").upper() != method: + continue + endpoint = warn.get("endpoint", "") + if endpoint != base_path: + regex = re.escape(endpoint).replace(r"\*", ".*") + if not re.fullmatch(regex, base_path): + continue + sys.stderr.write(warn.get("message", "") + "\n") + + if isinstance(result, list): + pages = split_pages(result, params, do_paginate, state) + else: + pages = [result] + + outputs = [] + for page in pages: + page_json = json.dumps(page, indent=2) + if jq_filter: + rc, filtered, err = run_jq(page_json, jq_filter) + if rc != 0: + sys.stderr.write(err) + sys.exit(rc) + outputs.append(filtered.rstrip("\n")) + else: + outputs.append(page_json) + + if not silent: + sys.stdout.write("\n".join(outputs) + "\n") + sys.exit(0) + + +if __name__ == "__main__": + main() From c71fe4a4a61afceb769539182c8b40736c93d75c Mon Sep 17 00:00:00 2001 From: acoliver Date: Fri, 24 Jul 2026 01:56:02 -0300 Subject: [PATCH 4/5] fix(ci): satisfy strict shell validation --- .github/scripts/assign-issue.sh | 7 +++++++ .github/scripts/record-assignment-history.sh | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/.github/scripts/assign-issue.sh b/.github/scripts/assign-issue.sh index 1076681b5f..ab4132583d 100755 --- a/.github/scripts/assign-issue.sh +++ b/.github/scripts/assign-issue.sh @@ -254,6 +254,10 @@ ensure_label_exists() { return 1 ;; 3) return 1 ;; # API error + *) + echo "‼ Unexpected label validation status: ${rc}" >&2 + return 1 + ;; esac # Create the label. if ! gh api --method POST "repos/${REPO}/labels" \ @@ -662,6 +666,9 @@ if [[ "${is_eligible}" != "true" ]]; then 2) abort_infra_error "‼ Failed to query historical assignment for @${USER_LOGIN}" ;; + *) + abort_infra_error "‼ Unexpected historical assignment status for @${USER_LOGIN}: ${hist_rc}" + ;; esac fi diff --git a/.github/scripts/record-assignment-history.sh b/.github/scripts/record-assignment-history.sh index 8b3d58c6d3..e4503056ec 100755 --- a/.github/scripts/record-assignment-history.sh +++ b/.github/scripts/record-assignment-history.sh @@ -64,6 +64,10 @@ case ${rc:-0} in echo "‼ Failed to check label ${LABEL_NAME} (API error)" >&2 exit 1 ;; + *) + echo "‼ Unexpected label validation status: ${rc:-0}" >&2 + exit 1 + ;; esac if ! gh api --method POST "repos/${REPO}/labels" \ From f4ebf79a11aa1190300f8252b71e3a804981a34c Mon Sep 17 00:00:00 2001 From: acoliver Date: Fri, 24 Jul 2026 04:26:33 -0300 Subject: [PATCH 5/5] fix(ci): preserve human ownership across assignment races --- .github/scripts/assign-constants.sh | 22 + .github/scripts/assign-issue.sh | 308 +++++-- .github/scripts/record-assignment-history.sh | 58 +- .github/scripts/unassign-stale-issues.sh | 142 ++- .github/workflows/assign.yml | 19 +- scripts/tests/assign-helpers.js | 118 ++- scripts/tests/assign-remediation2.test.js | 15 +- scripts/tests/assign-remediation3.test.js | 52 +- scripts/tests/assign-remediation4.test.js | 81 +- scripts/tests/assign-remediation5.test.js | 69 +- scripts/tests/assign-remediation7.test.js | 1 - scripts/tests/assign-remediation8.test.js | 806 ++++++++++++++++++ .../tests/assign-workflow-behaviors.test.js | 55 +- scripts/tests/assign-workflow.test.js | 7 +- scripts/tests/fake-gh.py | 57 +- 15 files changed, 1463 insertions(+), 347 deletions(-) create mode 100644 .github/scripts/assign-constants.sh create mode 100644 scripts/tests/assign-remediation8.test.js diff --git a/.github/scripts/assign-constants.sh b/.github/scripts/assign-constants.sh new file mode 100644 index 0000000000..e3ba91f67c --- /dev/null +++ b/.github/scripts/assign-constants.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Shared constants for assignment automation scripts. +# +# Source-only — do NOT execute this file directly. +# Sourced by assign-issue.sh and record-assignment-history.sh to eliminate +# production drift for history-label policy values. +# +# Must remain shellcheck-clean (enable=all, severity=style). +# shellcheck disable=SC2034 + +HISTORY_PREFIX='asnhist--' +HISTORY_COLOR='0E8A16' +HISTORY_DESC='Issue assignment history index' + +# Validate a GitHub login: nonempty, alphanumeric + hyphen, max 39 chars. +# Returns 0 on valid; nonzero on invalid. +validate_github_login() { + local login="$1" + [[ -n "${login}" ]] || return 1 + [[ "${#login}" -le 39 ]] || return 1 + [[ "${login}" =~ ^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?$ ]] || return 1 +} diff --git a/.github/scripts/assign-issue.sh b/.github/scripts/assign-issue.sh index ab4132583d..fa61b40c25 100755 --- a/.github/scripts/assign-issue.sh +++ b/.github/scripts/assign-issue.sh @@ -18,9 +18,9 @@ MARKER='' AUTO_ASSIGNED_LABEL='auto-assigned' AUTO_ASSIGNED_COLOR='0E8A16' AUTO_ASSIGNED_DESC='Assigned via /assign automation' -HISTORY_PREFIX='asnhist--' -HISTORY_COLOR='0E8A16' -HISTORY_DESC='Issue assignment history index' +# Source shared history-label constants (policy values + login validation). +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/assign-constants.sh" MAX_ASSIGNMENTS=3 BOT_LOGIN='github-actions[bot]' HISTORY_FILE="${ASSIGNMENT_HISTORY_FILE:-.github/assignment-history.txt}" @@ -42,6 +42,20 @@ USER_LOGIN="${COMMENTER_LOGIN}" ISSUE="${ISSUE_NUMBER}" REPO="${GITHUB_REPOSITORY}" export ASSIGNEE_LOGIN="${USER_LOGIN}" + +ASSIGN_TEMP_DIR="$(mktemp -d)" +MUTATION_STARTED=false +LIFECYCLE_COMMITTED=false +label_added_by_this_run=false + +# shellcheck disable=SC2329 # Invoked by EXIT/INT/TERM traps. +cleanup_temp_files() { + if [[ -n "${ASSIGN_TEMP_DIR:-}" && -d "${ASSIGN_TEMP_DIR}" ]]; then + rm -rf -- "${ASSIGN_TEMP_DIR}" + fi +} + +trap cleanup_temp_files EXIT export GITHUB_REPOSITORY="${REPO}" # --------------------------------------------------------------------------- @@ -116,7 +130,7 @@ get_open_assigned_count() { get_merged_pr_count() { local user="$1" local _stderr_file raw - _stderr_file="$(mktemp)" + _stderr_file="$(mktemp "${ASSIGN_TEMP_DIR}/capture.XXXXXX")" raw="$(gh api "search/issues?q=repo:${REPO}+author:${user}+type:pr+is:merged&per_page=1" --jq '.' 2>"${_stderr_file}")" || { local _diag _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" @@ -164,7 +178,7 @@ has_history_label() { local user="$1" local label_name="${HISTORY_PREFIX}${user}" local _stderr_file raw - _stderr_file="$(mktemp)" + _stderr_file="$(mktemp "${ASSIGN_TEMP_DIR}/capture.XXXXXX")" raw="$(gh api "repos/${REPO}/labels/${label_name}" --jq '.' 2>"${_stderr_file}")" || { local _diag _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" @@ -219,7 +233,7 @@ validate_label_definition() { local expected_color="$2" local expected_desc="$3" local _stderr_file raw - _stderr_file="$(mktemp)" + _stderr_file="$(mktemp "${ASSIGN_TEMP_DIR}/capture.XXXXXX")" raw="$(gh api "repos/${REPO}/labels/${label_name}" --jq '.' 2>"${_stderr_file}")" || { local _diag _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" @@ -310,7 +324,7 @@ remove_label() { local issue_num="$1" local label_name="$2" local encoded - encoded="$(printf '%s' "${label_name}" | jq -sRr '@uri' | sed 's/%0A$//;s/%0a$//')" + encoded="$(printf '%s' "${label_name}" | jq -sRr '@uri')" gh api --method DELETE "repos/${REPO}/issues/${issue_num}/labels/${encoded}" \ --silent >/dev/null 2>&1 } @@ -326,60 +340,163 @@ remove_label() { # NOT treat a failed rollback as success. # --------------------------------------------------------------------------- +rollback_ownership_from_snapshot() { + local assignees_json="$1" + local timeline_json="$2" + local login="$3" + + echo "${timeline_json}" | jq -r \ + --argjson current_assignees "${assignees_json}" \ + --arg login "${login}" \ + --arg bot "${BOT_LOGIN}" ' + if type != "array" then error("timeline is not an array") else . end + | to_entries as $entries + | ($entries | map(select( + (.value.event == "assigned" or .value.event == "unassigned") and + (.value.assignee | type == "object") and + .value.assignee.login == $login + ))) as $transitions + | if ($current_assignees | map(.login) | index($login)) == null then + "ABSENT" + else + ($transitions | map(select(.value.assignee.login == $login)) | last // null) as $latest + | if $latest != null and + $latest.value.event == "assigned" and + ($latest.value.actor.login // "") == $bot then + "BOT \($latest.key) \($entries | length)" + else + "PRESERVE" + end + end + ' 2>/dev/null +} + +marker_removal_is_safe() { + local assignees_json="$1" + local timeline_json="$2" + + echo "${timeline_json}" | jq -e \ + --argjson current_assignees "${assignees_json}" \ + --arg bot "${BOT_LOGIN}" ' + if type != "array" then error("timeline is not an array") else . end + | to_entries + | map(select(.value.event == "assigned" or .value.event == "unassigned")) as $transitions + | [$current_assignees[].login as $login + | ($transitions | map(select( + (.value.assignee | type == "object") and + .value.assignee.login == $login + )) | last // null) as $latest + | $latest != null and + $latest.value.event == "assigned" and + ($latest.value.actor.login // "") != "" and + $latest.value.actor.login != $bot + ] | all + ' >/dev/null 2>&1 +} + +human_takeover_before_rollback_unassign() { + local timeline_json="$1" + local login="$2" + local bot_assignment_pos="$3" + local snapshot_length="$4" + + echo "${timeline_json}" | jq -e \ + --arg login "${login}" \ + --arg bot "${BOT_LOGIN}" \ + --argjson bot_pos "${bot_assignment_pos}" \ + --argjson snapshot_length "${snapshot_length}" ' + to_entries as $entries + | ($entries | map(select( + .key >= $snapshot_length and + .value.event == "unassigned" and + .value.assignee.login == $login and + .value.actor.login == $bot + )) | first // null) as $cleanup_unassign + | $cleanup_unassign != null and + any($entries[]; + .key > $bot_pos and + .key < $cleanup_unassign.key and + .value.event == "assigned" and + .value.assignee.login == $login and + (.value.actor.login // "") != $bot + ) + ' >/dev/null 2>&1 +} + rollback_this_run() { local issue_num="$1" local login="$2" local label_name="$3" - local rolled_label="${4:-false}" # whether this run added the label - - # Remove this run's assignee (best-effort; DELETE may be a no-op if - # already gone, which is fine). - remove_assignee "${issue_num}" "${login}" || true + local rolled_label="${4:-false}" + local assignees_json timeline_json ownership - # Re-read exact assignee JSON after removal. - local post_assignees_json - if ! post_assignees_json="$(get_issue_assignees_json 2>/dev/null)"; then - echo "‼ Rollback: failed to re-read assignees after removing @${login} on #${issue_num}" >&2 - # If we can't verify, leave the label for safety (diagnosable state). + if ! assignees_json="$(get_issue_assignees_json 2>/dev/null)" || + ! timeline_json="$(fetch_timeline_json)" || + ! ownership="$(rollback_ownership_from_snapshot "${assignees_json}" "${timeline_json}" "${login}")"; then + echo "‼ Rollback: failed to establish authoritative ownership for @${login} on #${issue_num}" >&2 return 1 fi - # Check if this login is still present (DELETE may have failed or was - # re-added by a post-handler race). - local still_assigned - still_assigned="$(echo "${post_assignees_json}" | jq -r --arg login "${login}" \ - '[.[].login] | index($login) != null')" + if [[ "${ownership}" == BOT\ * ]]; then + local bot_assignment_pos snapshot_length + read -r _ bot_assignment_pos snapshot_length <<<"${ownership}" + if ! remove_assignee "${issue_num}" "${login}"; then + echo "‼ Rollback: targeted DELETE failed for @${login} on #${issue_num}" >&2 + return 1 + fi - if [[ "${still_assigned}" == "true" ]]; then - # Assignee DELETE did not take effect (or was re-added). Retain marker - # for next-run recovery and fail. - echo "‼ Rollback verification FAILED: @${login} still assigned to #${issue_num}" >&2 + if ! assignees_json="$(get_issue_assignees_json 2>/dev/null)" || + ! timeline_json="$(fetch_timeline_json)"; then + echo "‼ Rollback: failed to verify targeted DELETE for @${login} on #${issue_num}" >&2 + return 1 + fi + + if human_takeover_before_rollback_unassign "${timeline_json}" "${login}" "${bot_assignment_pos}" "${snapshot_length}"; then + add_assignee "${issue_num}" "${login}" || true + if ! assignees_json="$(get_issue_assignees_json 2>/dev/null)" || + ! echo "${assignees_json}" | jq -e --arg login "${login}" '[.[].login] | index($login) != null' >/dev/null 2>&1; then + echo "‼ Rollback compensation FAILED for human-owned @${login} on #${issue_num}" >&2 + else + echo "‼ Rollback detected a concurrent human takeover of @${login}; restored assignee and retained marker" >&2 + fi + return 1 + fi + + if echo "${assignees_json}" | jq -e --arg login "${login}" '[.[].login] | index($login) != null' >/dev/null 2>&1; then + echo "‼ Rollback verification FAILED: @${login} still assigned to #${issue_num}" >&2 + return 1 + fi + elif [[ "${ownership}" == "PRESERVE" ]]; then + echo "Rollback: preserving human/unknown-owned @${login} on #${issue_num}" >&2 + elif [[ "${ownership}" != "ABSENT" ]]; then + echo "‼ Rollback: ambiguous ownership result for @${login} on #${issue_num}" >&2 return 1 fi - # Count remaining assignees. - local remaining_count - remaining_count="$(echo "${post_assignees_json}" | jq 'length')" - - # Decide whether to remove the label. - # If other assignees remain (competing winner), preserve the marker. - if [[ "${remaining_count}" -gt 0 ]]; then - # A competing winner is still assigned — preserve the marker. - return 0 + local preserve_marker_for_bot_contender=false + if echo "${assignees_json}" | jq -e --arg login "${login}" \ + 'any(.[]; .login != $login)' >/dev/null 2>&1; then + preserve_marker_for_bot_contender=true fi - # No other assignee remains; this run owned the marker. Remove the label. if [[ "${rolled_label}" == "true" ]]; then - remove_label "${issue_num}" "${label_name}" || true - local verify_labels - if ! verify_labels="$(get_issue_labels_json 2>/dev/null)"; then - echo "‼ Rollback: failed to verify label removal on #${issue_num}" >&2 + if ! assignees_json="$(get_issue_assignees_json 2>/dev/null)" || + ! timeline_json="$(fetch_timeline_json)"; then + echo "‼ Rollback: failed to verify marker ownership on #${issue_num}" >&2 return 1 fi - if echo "${verify_labels}" | jq -e --arg lbl "${label_name}" \ - '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then - echo "‼ Rollback verification FAILED: label '${label_name}' still on #${issue_num}" >&2 - return 1 + if [[ "${preserve_marker_for_bot_contender}" != "true" ]] && + marker_removal_is_safe "${assignees_json}" "${timeline_json}"; then + remove_label "${issue_num}" "${label_name}" || true + local verify_labels + if ! verify_labels="$(get_issue_labels_json 2>/dev/null)"; then + echo "‼ Rollback: failed to verify label removal on #${issue_num}" >&2 + return 1 + fi + if echo "${verify_labels}" | jq -e --arg lbl "${label_name}" '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then + echo "‼ Rollback verification FAILED: label '${label_name}' still on #${issue_num}" >&2 + return 1 + fi fi fi return 0 @@ -388,7 +505,12 @@ rollback_this_run() { # Verified rollback that exits nonzero if rollback itself fails. # Never treats a failed rollback as success. If rollback fails, the # state is left diagnosable (assignee/label may remain for next run). -# Args: issue_num login label_name rolled_label feedback_msg exit_code +# +# On SUCCESSFUL rollback, posts feedback only when post_feedback_on_success +# is "true". Contention-loser callers pass "false" to avoid overwriting the +# winner's sticky. +# +# Args: issue_num login label_name rolled_label feedback_msg exit_code [post_feedback_on_success] verified_rollback_and_fail() { local issue_num="$1" local login="$2" @@ -396,12 +518,35 @@ verified_rollback_and_fail() { local rolled_label="$4" local feedback_msg="$5" local exit_code="${6:-1}" + local post_feedback_on_success="${7:-true}" if ! rollback_this_run "${issue_num}" "${login}" "${label_name}" "${rolled_label}"; then echo "‼ Rollback FAILED for @${login} on #${issue_num} — state left for diagnosis" >&2 post_sticky_feedback "${feedback_msg}" || true exit "${exit_code}" fi + + if [[ "${post_feedback_on_success}" == "true" ]]; then + post_sticky_feedback "${feedback_msg}" || true + fi + exit "${exit_code}" +} + +# shellcheck disable=SC2329 # Invoked by INT/TERM traps. +handle_lifecycle_signal() { + local signal_name="$1" + local exit_code="$2" + trap - INT TERM + echo "‼ Received ${signal_name} during assignment lifecycle for @${USER_LOGIN} on #${ISSUE}" >&2 + if [[ "${MUTATION_STARTED}" == "true" && "${LIFECYCLE_COMMITTED}" != "true" ]]; then + if rollback_this_run "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}"; then + echo "Rollback completed after ${signal_name}" >&2 + else + echo "‼ Rollback after ${signal_name} could not be verified; recoverable marker state was retained" >&2 + fi + fi + cleanup_temp_files + exit "${exit_code}" } # --------------------------------------------------------------------------- @@ -446,17 +591,29 @@ elect_winner() { if type != "array" then error("timeline is not an array") else . end | to_entries as $all_entries | ($all_entries | map(select(.value.event == "assigned" or .value.event == "unassigned"))) as $assign_entries + | ($assign_entries | map( + select( + (.value.assignee | type == "object") + and (.value.assignee.login | type == "string") + and (.value.assignee.login | length > 0) + ) + )) as $valid_assign_entries + | ($assign_entries | length) as $total_assign_entries + | ($valid_assign_entries | length) as $valid_count + | if $total_assign_entries > $valid_count then + error("malformed assignee data in timeline transition events") + else . end | ($current_assignees | map(.login)) as $logins | [ $logins[] | . as $login - | ($assign_entries | map(select(.value.assignee.login == $login))) as $login_transitions + | ($valid_assign_entries | map(select(.value.assignee.login == $login))) as $login_transitions | ($login_transitions | last // null) as $last_entry | if $last_entry != null then { login: $login, pos: $last_entry.key, - actor: $last_entry.value.actor.login, + actor: ($last_entry.value.actor.login // "unknown"), event: $last_entry.value.event } else @@ -555,7 +712,7 @@ post_sticky_feedback() { body="$(printf '%s\n%s\n' "${MARKER}" "${message}")" local _comments_stderr_file - _comments_stderr_file="$(mktemp)" + _comments_stderr_file="$(mktemp "${ASSIGN_TEMP_DIR}/capture.XXXXXX")" local all_comments_raw all_comments_raw="$(gh api "repos/${REPO}/issues/${ISSUE}/comments" --paginate 2>"${_comments_stderr_file}")" || { echo "‼ Failed to fetch comments for feedback" >&2 @@ -566,21 +723,32 @@ post_sticky_feedback() { rm -f "${_comments_stderr_file}" local all_comment_ids - all_comment_ids="$(echo "${all_comments_raw}" | jq -s 'add | [(.[]? | select(.user.login == $bot and ((.body // "") | startswith($marker))) | .id)]' \ + all_comment_ids="$(echo "${all_comments_raw}" | jq -s 'add | [(.[]? | select(.user.login == $bot and ((.body // "") | startswith($marker))) | .id)] | sort' \ --arg bot "${BOT_LOGIN}" --arg marker "${MARKER}")" || { echo "‼ Failed to parse comments for feedback" >&2 return 1 } - local existing_id - existing_id="$(echo "${all_comment_ids}" | jq -r 'if type == "array" then .[0] // empty else empty end')" + local count + count="$(echo "${all_comment_ids}" | jq 'length')" - if [[ -n "${existing_id}" ]]; then - gh api --method PATCH "repos/${REPO}/issues/comments/${existing_id}" \ - -f body="${body}" --silent >/dev/null 2>&1 || return 1 - else + if [[ "${count}" -eq 0 ]]; then gh api --method POST "repos/${REPO}/issues/${ISSUE}/comments" \ -f body="${body}" --silent >/dev/null 2>&1 || return 1 + return 0 + fi + + local keep_id + keep_id="$(echo "${all_comment_ids}" | jq -r '.[-1]')" + + gh api --method PATCH "repos/${REPO}/issues/comments/${keep_id}" \ + -f body="${body}" --silent >/dev/null 2>&1 || return 1 + + if [[ "${count}" -gt 1 ]]; then + echo "${all_comment_ids}" | jq -r '.[:-1][]' | while IFS= read -r dup_id; do + gh api --method DELETE "repos/${REPO}/issues/comments/${dup_id}" \ + --silent >/dev/null 2>&1 || true + done fi } @@ -717,7 +885,9 @@ if [[ "${pre_open_count}" -ge "${MAX_ASSIGNMENTS}" ]]; then fi # Step 5: Add auto-assigned label (targeted POST). -label_added_by_this_run=false +MUTATION_STARTED=true +trap 'handle_lifecycle_signal INT 130' INT +trap 'handle_lifecycle_signal TERM 143' TERM if add_label "${ISSUE}" "${AUTO_ASSIGNED_LABEL}"; then label_added_by_this_run=true @@ -737,7 +907,6 @@ if ! add_assignee "${ISSUE}" "${USER_LOGIN}"; then echo "‼ Assignee POST failed for #${ISSUE}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: GitHub API error during assignment. Please try again or contact a maintainer." 1 - exit 1 fi # Step 7: Deterministic winner election via authoritative paginated timeline. @@ -751,7 +920,6 @@ SHOULD_ROLLBACK="false" if ! run_election; then verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: election verification failed. Please contact a maintainer." 1 - exit 1 fi if [[ "${SHOULD_ROLLBACK}" == "true" ]]; then @@ -760,9 +928,8 @@ if [[ "${SHOULD_ROLLBACK}" == "true" ]]; then "[ERROR] Could not assign @${USER_LOGIN}: a human has taken ownership of this issue." 1 else verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ - "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 + "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 false fi - exit 1 fi # Winner enforcement: wait/retry for losers to rollback, then verify @@ -776,7 +943,6 @@ for _e_attempt in $(seq 1 $((enforcement_retries + 1))); do echo "‼ Failed to verify assignees for #${ISSUE}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: unable to verify assignment state. Please contact a maintainer." 1 - exit 1 fi assignee_count="$(echo "${verified_assignees_json}" | jq 'length')" @@ -792,8 +958,7 @@ for _e_attempt in $(seq 1 $((enforcement_retries + 1))); do if [[ "${has_commenter}" != "true" ]]; then echo "WARNING: @${USER_LOGIN} no longer assigned to #${ISSUE} during enforcement" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ - "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 - exit 1 + "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 false fi recheck_timeline="" @@ -804,28 +969,24 @@ for _e_attempt in $(seq 1 $((enforcement_retries + 1))); do echo "‼ Enforcement re-election: API error for #${ISSUE}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: election verification failed. Please contact a maintainer." 1 - exit 1 fi if ! recheck_winner="$(elect_winner "${recheck_assignees}" "${recheck_timeline}")"; then echo "‼ Enforcement re-election: error for #${ISSUE}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: election verification failed. Please contact a maintainer." 1 - exit 1 fi if [[ -z "${recheck_winner}" ]]; then echo "WARNING: Human ownership detected during enforcement on #${ISSUE}; rolling back @${USER_LOGIN}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: a human has taken ownership of this issue." 1 - exit 1 fi if [[ "${recheck_winner}" != "${USER_LOGIN}" ]]; then echo "WARNING: @${USER_LOGIN} is no longer the winner (@${recheck_winner} won) on #${ISSUE}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ - "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 - exit 1 + "[ERROR] Could not assign @${USER_LOGIN}: another assignment was selected. Please try a different issue." 1 false fi if [[ "${_e_attempt}" -le "${enforcement_retries}" ]]; then @@ -836,8 +997,7 @@ done if [[ "${enforcement_ok}" != "true" ]]; then echo "WARNING: Post-election contention on #${ISSUE}: assignees = $(echo "${verified_assignees_json}" | jq -r '[.[].login] | join(", ")')" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ - "[ERROR] Could not assign @${USER_LOGIN}: unable to enforce single-winner assignment. Please contact a maintainer." 1 - exit 1 + "[ERROR] Could not assign @${USER_LOGIN}: unable to enforce single-winner assignment. Please contact a maintainer." 1 false fi # Step 8: Verify label was added. @@ -845,14 +1005,12 @@ verified_labels_json="" if ! verified_labels_json="$(get_issue_labels_json)"; then verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: unable to verify label state. Please contact a maintainer." 1 - abort_infra_error "‼ Failed to verify labels for #${ISSUE}" fi if ! echo "${verified_labels_json}" | jq -e --arg lbl "${AUTO_ASSIGNED_LABEL}" '[.[].name] | index($lbl) != null' >/dev/null 2>&1; then echo "‼ Label verification failed for #${ISSUE}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: the label could not be verified. Please contact a maintainer." 1 - exit 1 fi # Step 9: Post-mutation cap enforcement (fail-closed). @@ -861,7 +1019,6 @@ if ! post_open_count="$(get_open_assigned_count "${USER_LOGIN}")"; then echo "‼ Post-mutation cap query failed for @${USER_LOGIN}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: unable to verify the assignment cap due to an API error. Please try again or contact a maintainer." 1 - exit 1 fi if [[ "${post_open_count}" -gt "${MAX_ASSIGNMENTS}" ]]; then @@ -869,7 +1026,6 @@ if [[ "${post_open_count}" -gt "${MAX_ASSIGNMENTS}" ]]; then # Verified rollback must succeed before exiting. If it fails, exit nonzero. verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: assignment would exceed the **${MAX_ASSIGNMENTS}**-issue cap. Another assignment may have completed concurrently." 1 - exit 0 fi # Step 10: Create durable history label directly (GitHub suppresses recursive @@ -880,9 +1036,11 @@ if ! bash "$(dirname "${BASH_SOURCE[0]}")/record-assignment-history.sh"; then echo "‼ Failed to create history label for @${USER_LOGIN}" >&2 verified_rollback_and_fail "${ISSUE}" "${USER_LOGIN}" "${AUTO_ASSIGNED_LABEL}" "${label_added_by_this_run}" \ "[ERROR] Could not assign @${USER_LOGIN}: failed to record assignment history. Please try again or contact a maintainer." 1 - exit 1 fi +LIFECYCLE_COMMITTED=true +trap - INT TERM + echo "[OK] Assigned @${USER_LOGIN} to issue #${ISSUE}" post_sticky_feedback "[OK] Assigned @${USER_LOGIN} to this issue via \`/assign\` (${eligibility_reason}). diff --git a/.github/scripts/record-assignment-history.sh b/.github/scripts/record-assignment-history.sh index e4503056ec..0bfa1d620a 100755 --- a/.github/scripts/record-assignment-history.sh +++ b/.github/scripts/record-assignment-history.sh @@ -7,9 +7,18 @@ # collision failure as assign-issue.sh. set -euo pipefail -HISTORY_PREFIX='asnhist--' -HISTORY_COLOR='0E8A16' -HISTORY_DESC='Issue assignment history index' +# Source shared constants (history-label policy values + login validation). +# shellcheck source-path=SCRIPTDIR +source "$(dirname "${BASH_SOURCE[0]}")/assign-constants.sh" + +# Check required tool availability with clear diagnostics before use. +for _tool in gh jq; do + if ! command -v "${_tool}" >/dev/null 2>&1; then + printf '‼ Missing required tool: %s\n' "${_tool}" >&2 + exit 1 + fi +done +unset _tool : "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" : "${ASSIGNEE_LOGIN:?Missing ASSIGNEE_LOGIN}" @@ -19,34 +28,53 @@ if [[ -n "${GH_TOKEN:-}" ]]; then elif [[ -n "${GITHUB_TOKEN:-}" ]]; then export GH_TOKEN="${GITHUB_TOKEN}" else - echo "‼ Missing \$GH_TOKEN / \$GITHUB_TOKEN" >&2 + printf '‼ Missing %s / %s\n' "\$GH_TOKEN" "\$GITHUB_TOKEN" >&2 exit 1 fi REPO="${GITHUB_REPOSITORY}" LOGIN="${ASSIGNEE_LOGIN}" + +# Validate login syntax before label construction. +if ! validate_github_login "${LOGIN}"; then + printf '‼ Invalid GitHub login: %q\n' "${LOGIN}" >&2 + exit 1 +fi + LABEL_NAME="${HISTORY_PREFIX}${LOGIN}" +# Track temp files for cleanup on signals/exit. +_HISTORY_TMPFILES=() + +_history_cleanup() { + for _f in "${_HISTORY_TMPFILES[@]:-}"; do + [[ -n "${_f}" ]] && rm -f "${_f}" 2>/dev/null || true + done +} +trap _history_cleanup EXIT +trap 'trap - EXIT; _history_cleanup; exit 130' INT TERM + validate_history_label() { local label_name="$1" local _stderr_file raw _stderr_file="$(mktemp)" + _HISTORY_TMPFILES+=("${_stderr_file}") raw="$(gh api "repos/${REPO}/labels/${label_name}" --jq '.' 2>"${_stderr_file}")" || { local _diag _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" rm -f "${_stderr_file}" # Distinguish 404 (absence) from other API failures (error). - if echo "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then + if printf '%s' "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then return 1 fi # Non-404 API error — log sanitized diagnostics and signal error. - echo "‼ validate_history_label: API error for '${label_name}': $(echo "${_diag}" | head -1)" >&2 + printf '‼ validate_history_label: API error for %q: %s\n' "${label_name}" "$(printf '%s' "${_diag}" | head -1)" >&2 return 2 } rm -f "${_stderr_file}" local color desc - color="$(echo "${raw}" | jq -r '.color // ""')" || return 2 - desc="$(echo "${raw}" | jq -r '.description // ""')" || return 2 + color="$(printf '%s' "${raw}" | jq -r '.color // ""')" || return 2 + desc="$(printf '%s' "${raw}" | jq -r '.description // ""')" || return 2 if [[ "${color}" != "${HISTORY_COLOR}" ]] || [[ "${desc}" != "${HISTORY_DESC}" ]]; then return 1 fi @@ -56,16 +84,16 @@ validate_history_label() { validate_history_label "${LABEL_NAME}" || rc=$? case ${rc:-0} in 0) - echo "Label ${LABEL_NAME} already exists with correct definition" + printf 'Label %s already exists with correct definition\n' "${LABEL_NAME}" exit 0 ;; 1) ;; # absent — proceed to create 2) - echo "‼ Failed to check label ${LABEL_NAME} (API error)" >&2 + printf '‼ Failed to check label %s (API error)\n' "${LABEL_NAME}" >&2 exit 1 ;; *) - echo "‼ Unexpected label validation status: ${rc:-0}" >&2 + printf '‼ Unexpected label validation status: %s\n' "${rc:-0}" >&2 exit 1 ;; esac @@ -81,18 +109,18 @@ if ! gh api --method POST "repos/${REPO}/labels" \ validate_history_label "${LABEL_NAME}" || rc=$? case ${rc} in 0) - echo "Label ${LABEL_NAME} already exists (created concurrently)" + printf 'Label %s already exists (created concurrently)\n' "${LABEL_NAME}" exit 0 ;; 1) - echo "‼ Label '${LABEL_NAME}' exists with conflicting definition" >&2 + printf '‼ Label %q exists with conflicting definition\n' "${LABEL_NAME}" >&2 exit 1 ;; *) - echo "‼ Failed to create label ${LABEL_NAME}" >&2 + printf '‼ Failed to create label %s\n' "${LABEL_NAME}" >&2 exit 1 ;; esac fi -echo "Created label ${LABEL_NAME}" +printf 'Created label %s\n' "${LABEL_NAME}" diff --git a/.github/scripts/unassign-stale-issues.sh b/.github/scripts/unassign-stale-issues.sh index 478c109abe..45ab76acde 100755 --- a/.github/scripts/unassign-stale-issues.sh +++ b/.github/scripts/unassign-stale-issues.sh @@ -33,6 +33,19 @@ fi REPO="${GITHUB_REPOSITORY}" +CLEANUP_TEMP_DIR="$(mktemp -d)" + +# shellcheck disable=SC2329 # Invoked by EXIT/INT/TERM traps. +cleanup_temp_files() { + if [[ -n "${CLEANUP_TEMP_DIR:-}" && -d "${CLEANUP_TEMP_DIR}" ]]; then + rm -rf -- "${CLEANUP_TEMP_DIR}" + fi +} + +trap cleanup_temp_files EXIT +trap 'cleanup_temp_files; trap - EXIT; exit 130' INT +trap 'cleanup_temp_files; trap - EXIT; exit 143' TERM + retry_gh() { local attempt for attempt in 1 2 3 4; do @@ -74,12 +87,44 @@ iso_to_epoch() { date -u -j -f "%Y-%m-%dT%H:%M:%S" "${normalized}" +%s 2>/dev/null } +# Run a command with bounded retry, capturing combined stdout. +# Sets the global RETRY_CAPTURE_OUT on success. Returns 0 on success, 1 on +# exhaustion. Stderr from the command is suppressed (goes to /dev/null). +# Args: command... +retry_gh_capture() { + local _capture_file + _capture_file="$(mktemp "${CLEANUP_TEMP_DIR}/capture.XXXXXX")" + local attempt + for attempt in 1 2 3 4; do + : >"${_capture_file}" + if "$@" >"${_capture_file}" 2>/dev/null; then + RETRY_CAPTURE_OUT="$(cat "${_capture_file}")" + rm -f "${_capture_file}" + return 0 + fi + if [[ "${attempt}" -lt 4 ]]; then + echo "Attempt ${attempt} failed, retrying: $*" >&2 + sleep "${ASSIGN_RETRY_DELAY}" + fi + done + rm -f "${_capture_file}" + echo "All retries exhausted for: $*" >&2 + return 1 +} + # gh api --paginate emits one JSON array per page; jq -s merges them. +# Uses retry_gh_capture for bounded retry on transient failures. The gh call +# and jq merge are run as a single bash -c with pipefail so that a gh failure +# (nonzero exit, empty stdout) is detected even though jq succeeds on empty +# input. Stdout is captured cleanly without stderr corruption. fetch_issue_timeline() { local issue_number="$1" - gh api "repos/${REPO}/issues/${issue_number}/timeline?per_page=100" \ - --paginate 2>/dev/null \ - | jq -s 'if all(.[]; type == "array") then add else error("non-array page in timeline") end' + local _raw_pages + if ! retry_gh_capture gh api "repos/${REPO}/issues/${issue_number}/timeline?per_page=100" --paginate; then + return 1 + fi + _raw_pages="${RETRY_CAPTURE_OUT}" + printf '%s' "${_raw_pages}" | jq -s 'if all(.[]; type == "array") then add else error("non-array page in timeline") end' } # Read current assignees as a raw JSON array. @@ -92,7 +137,7 @@ get_issue_assignees_json() { # Provenance extraction from a pre-fetched timeline snapshot. # Args: assignees_json, timeline_json. -# Output: "LOGIN TIMESTAMP" or empty. Returns nonzero on error/ambiguity. +# Output: "LOGIN TIMESTAMP POSITION" or empty. Returns nonzero on error/ambiguity. find_provenance_from_timeline() { local assignees_json="$1" local timeline_json="$2" @@ -132,7 +177,7 @@ find_provenance_from_timeline() { | if $has_invalidating_unlabel then empty else - "\($login) \($assigned_at)" + "\($login) \($assigned_at) \($assigned_pos)" end else empty @@ -192,7 +237,7 @@ remove_label_targeted() { local issue_number="$1" local label_name="$2" local encoded - encoded="$(printf '%s' "${label_name}" | jq -sRr '@uri' | sed 's/%0A$//;s/%0a$//')" + encoded="$(printf '%s' "${label_name}" | jq -sRr '@uri')" if retry_gh gh api --method DELETE "repos/${REPO}/issues/${issue_number}/labels/${encoded}" \ --silent >/dev/null 2>&1; then return 0 @@ -224,6 +269,49 @@ remove_assignee_targeted() { } # Remove auto-assigned label only (no assignees to clean). + +restore_assignee_targeted() { + local issue_number="$1" + local login="$2" + retry_gh gh api --method POST "repos/${REPO}/issues/${issue_number}/assignees" \ + -f "assignees[]=${login}" --silent >/dev/null 2>&1 || true + + local verify_assignees + if ! verify_assignees="$(get_issue_assignees_json "${issue_number}")"; then + return 1 + fi + echo "${verify_assignees}" | jq -e --arg login "${login}" \ + '[.[].login] | index($login) != null' >/dev/null 2>&1 +} + +human_takeover_before_cleanup_unassign() { + local timeline_json="$1" + local login="$2" + local bot_assignment_pos="$3" + local snapshot_length="$4" + + echo "${timeline_json}" | jq -e \ + --arg login "${login}" \ + --arg bot "${BOT_LOGIN}" \ + --argjson bot_pos "${bot_assignment_pos}" \ + --argjson snapshot_length "${snapshot_length}" ' + to_entries as $entries + | ($entries | map(select( + .key >= $snapshot_length and + .value.event == "unassigned" and + .value.assignee.login == $login and + .value.actor.login == $bot + )) | first // null) as $cleanup_unassign + | $cleanup_unassign != null and + any($entries[]; + .key > $bot_pos and + .key < $cleanup_unassign.key and + .value.event == "assigned" and + .value.assignee.login == $login and + (.value.actor.login // "") != $bot + ) + ' >/dev/null 2>&1 +} remove_label_only() { local issue_number="$1" remove_label_targeted "${issue_number}" "${AUTO_ASSIGNED_LABEL}" @@ -235,12 +323,12 @@ remove_label_only() { # 2 = conflicting definition; 3 = API error. validate_marker_label() { local _stderr_file raw - _stderr_file="$(mktemp)" + _stderr_file="$(mktemp "${CLEANUP_TEMP_DIR}/capture.XXXXXX")" raw="$(gh api "repos/${REPO}/labels/${AUTO_ASSIGNED_LABEL}" --jq '.' 2>"${_stderr_file}")" || { local _diag _diag="$(cat "${_stderr_file}" 2>/dev/null || true)" rm -f "${_stderr_file}" - if echo "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then + if printf '%s' "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then return 1 fi return 3 @@ -293,11 +381,13 @@ process_issue() { return $? fi - local bot_login bot_assigned_at + local bot_login bot_assignment_rest bot_assigned_at bot_assigned_pos bot_login="${bot_assignment%% *}" - bot_assigned_at="${bot_assignment#* }" + bot_assignment_rest="${bot_assignment#* }" + bot_assigned_at="${bot_assignment_rest%% *}" + bot_assigned_pos="${bot_assignment_rest##* }" - echo " Bot-assigned @${bot_login} at ${bot_assigned_at}" + echo " Bot-assigned @${bot_login} at ${bot_assigned_at} (timeline position ${bot_assigned_pos})" if [[ "${bot_login}" == "${EXEMPT_LOGIN}" ]]; then echo " Keeping @${EXEMPT_LOGIN} on #${issue_number} (exempt)" @@ -358,15 +448,16 @@ process_issue() { return $? fi - local revalidated_login revalidated_ts + local revalidated_login revalidated_rest revalidated_pos revalidated_login="${pre_delete_assignment%% *}" - revalidated_ts="${pre_delete_assignment#* }" + revalidated_rest="${pre_delete_assignment#* }" + revalidated_pos="${revalidated_rest##* }" if [[ "${revalidated_login}" != "${bot_login}" ]]; then echo " Warning: Provenance login mismatch for #${issue_number}; preserving state" >&2 return 0 fi - if [[ "${revalidated_ts}" != "${bot_assigned_at}" ]]; then - echo " Warning: Provenance timestamp changed for #${issue_number}; preserving state" >&2 + if [[ "${revalidated_pos}" != "${bot_assigned_pos}" ]]; then + echo " Warning: Provenance timeline position changed for #${issue_number}; preserving state" >&2 return 0 fi @@ -392,11 +483,32 @@ process_issue() { echo " Unassigning stale @${bot_login} from #${issue_number}" + local pre_delete_timeline_length + pre_delete_timeline_length="$(echo "${pre_delete_timeline_json}" | jq 'length')" || { + echo " Warning: Could not capture pre-delete timeline position for #${issue_number}" >&2 + return 1 + } + # Remove assignee first via targeted DELETE. if ! remove_assignee_targeted "${issue_number}" "${bot_login}"; then return 1 fi + local post_assignee_delete_timeline="" + post_assignee_delete_timeline="$(fetch_issue_timeline "${issue_number}")" || { + echo " Warning: Post-assignee-DELETE timeline read failed for #${issue_number}; retaining marker" >&2 + return 1 + } + + if human_takeover_before_cleanup_unassign "${post_assignee_delete_timeline}" "${bot_login}" "${revalidated_pos}" "${pre_delete_timeline_length}"; then + if restore_assignee_targeted "${issue_number}" "${bot_login}"; then + echo " Warning: Human same-login takeover detected during DELETE; restored @${bot_login} and retained marker for #${issue_number}" >&2 + else + echo " Warning: Human same-login takeover detected but compensation FAILED for @${bot_login} on #${issue_number}; marker retained" >&2 + fi + return 1 + fi + # Immediately re-read and verify login is absent BEFORE label DELETE. # If still present (successful no-op or post-handler race), retain marker. local mid_delete_assignees_json="" diff --git a/.github/workflows/assign.yml b/.github/workflows/assign.yml index 5f14cb08d5..a2e37d8ad4 100644 --- a/.github/workflows/assign.yml +++ b/.github/workflows/assign.yml @@ -41,10 +41,12 @@ jobs: # via toJSON equality (not startsWith) so `/assign foo` never matches. # Ignore PR review comments and Bot accounts (spam hardening). # - # No workflow concurrency: concurrent same-issue runs converge via - # postconditions enforced by assign-issue.sh (exactly-one-assignee - # verification, contention rollback, post-cap rollback). This ensures - # no valid comments are silently dropped. + # Serialize each actor's attempts across issues to bound fan-out while + # preserving independent progress for different actors. In-flight attempts + # complete rather than being cancelled with partial mutations. + concurrency: + group: assign-${{ github.event.comment.user.id }} + cancel-in-progress: false if: | github.event_name == 'issue_comment' && github.event.issue.pull_request == null && @@ -77,10 +79,11 @@ jobs: name: Record assignment history runs-on: ubuntu-latest timeout-minutes: 5 - # Fires on issue assignment events for human/manual assignments. - # Automated /assign assignments also create the history label directly - # in assign-issue.sh because GitHub suppresses recursive - # issues:assigned workflows caused by GITHUB_TOKEN. + # Indexes assignment events outside /assign (e.g. human/manual assignments). + # Does NOT filter bot senders: issue #2630 treats any prior assignment as + # history. GITHUB_TOKEN recursion is already suppressed by GitHub, so + # automated /assign assignments are also recorded directly in + # assign-issue.sh (this job may not fire for those). if: | github.event_name == 'issues' && github.event.action == 'assigned' diff --git a/scripts/tests/assign-helpers.js b/scripts/tests/assign-helpers.js index 7f97fc8192..0287e77737 100644 --- a/scripts/tests/assign-helpers.js +++ b/scripts/tests/assign-helpers.js @@ -14,13 +14,26 @@ */ import { execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, readFileSync, mkdirSync } from 'fs'; +import { + mkdtempSync, + writeFileSync, + readFileSync, + mkdirSync, + rmSync, +} from 'fs'; import { tmpdir } from 'os'; import * as path from 'path'; const ROOT = path.resolve(import.meta.dirname, '../..'); const FAKE_GH = path.join(import.meta.dirname, 'fake-gh.py'); +let eventIdCounter = 200000; + +function nextEventId() { + eventIdCounter += 1; + return eventIdCounter; +} + /** * Create a temporary directory representing a fake GitHub repo. * Returns an object with methods to manage state and run scripts. @@ -39,11 +52,6 @@ export function createFakeRepo(initialState = {}) { ); execFileSync('chmod', ['+x', ghWrapper]); - // Write trusted contributors file in the fake repo dir (unused after remediation - // but harmless to have present for compatibility) - const trustedFile = path.join(dir, 'trusted-contributors.txt'); - writeFileSync(trustedFile, 'acoliver\n'); - const defaultState = { now: '2025-07-23T00:00:00Z', next_comment_id: 100, @@ -64,7 +72,6 @@ export function createFakeRepo(initialState = {}) { return { dir, stateFile, - trustedFile, binDir, readState() { @@ -159,6 +166,81 @@ export function createFakeRepo(initialState = {}) { }; } +/** + * Run record-assignment-history.sh against a fresh stateful fake gh. + * Creates its own temp directory, writes the given state, runs the script, + * reads the final state, and cleans up. + * + * Returns { stdout, stderr, status, state }. + */ +export function runRecordHistory({ state, assigneeLogin, extraEnv = {} }) { + const dir = mkdtempSync(path.join(tmpdir(), 'record-hist-')); + const stateFile = path.join(dir, 'state.json'); + const binDir = path.join(dir, 'bin'); + mkdirSync(binDir, { recursive: true }); + const ghWrapper = path.join(binDir, 'gh'); + writeFileSync( + ghWrapper, + `#!/usr/bin/env bash +exec python3 "${FAKE_GH}" "$@" +`, + ); + execFileSync('chmod', ['+x', ghWrapper]); + + const initialState = { + now: '2025-07-23T00:00:00Z', + issues: {}, + prs: {}, + comments: [], + labels: {}, + events: {}, + timeline: {}, + fail_config: {}, + ...state, + }; + writeFileSync(stateFile, JSON.stringify(initialState, null, 2)); + + const env = { + ...process.env, + GH_TOKEN: 'fake', + GITHUB_TOKEN: 'fake', + GITHUB_REPOSITORY: 'test/repo', + ASSIGNEE_LOGIN: assigneeLogin, + GH_FAKE_STATE: stateFile, + PATH: binDir + ':' + process.env.PATH, + ...extraEnv, + }; + + try { + const stdout = execFileSync( + 'bash', + [path.join(ROOT, '.github/scripts/record-assignment-history.sh')], + { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); + return { stdout, stderr: '', status: 0, state: finalState }; + } catch (err) { + let finalState; + try { + finalState = JSON.parse(readFileSync(stateFile, 'utf8')); + } catch { + finalState = { + issues: {}, + labels: {}, + comments: [], + }; + } + return { + stdout: err.stdout?.toString() ?? '', + stderr: err.stderr?.toString() ?? '', + status: err.status ?? 1, + state: finalState, + }; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + /** * Create a default state with the auto-assigned label defined. */ @@ -242,13 +324,12 @@ export function makePR({ * Create an "assigned" timeline event. */ export function makeAssignedEvent({ - number: _number, assignee, actor = 'github-actions[bot]', createdAt = '2025-07-01T00:00:00Z', }) { return { - id: Math.floor(Math.random() * 100000), + id: nextEventId(), event: 'assigned', actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, assignee: { login: assignee }, @@ -262,14 +343,13 @@ export function makeAssignedEvent({ * (the script must reject cross-repo PRs). */ export function makeCrossRefEvent({ - number: _number, prNumber, prAuthor, createdAt = '2025-07-05T00:00:00Z', repositoryUrl = 'https://api.github.com/repos/test/repo', }) { return { - id: Math.floor(Math.random() * 100000), + id: nextEventId(), event: 'cross-referenced', actor: { login: prAuthor, type: 'User' }, source: { @@ -289,13 +369,12 @@ export function makeCrossRefEvent({ * Create a "labeled" timeline event. */ export function makeLabeledEvent({ - number: _number, label, actor = 'github-actions[bot]', createdAt = '2025-07-01T00:00:00Z', }) { return { - id: Math.floor(Math.random() * 100000), + id: nextEventId(), event: 'labeled', actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, label: { name: label }, @@ -307,13 +386,12 @@ export function makeLabeledEvent({ * Create an "unlabeled" timeline event. */ export function makeUnlabeledEvent({ - number: _number, label, actor = 'github-actions[bot]', createdAt = '2025-07-02T00:00:00Z', }) { return { - id: Math.floor(Math.random() * 100000), + id: nextEventId(), event: 'unlabeled', actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, label: { name: label }, @@ -325,13 +403,12 @@ export function makeUnlabeledEvent({ * Create an "unassigned" timeline event. */ export function makeUnassignedEvent({ - number: _number, assignee, actor = 'github-actions[bot]', createdAt = '2025-07-02T00:00:00Z', }) { return { - id: Math.floor(Math.random() * 100000), + id: nextEventId(), event: 'unassigned', actor: { login: actor, type: actor.endsWith('[bot]') ? 'Bot' : 'User' }, assignee: { login: assignee }, @@ -342,12 +419,9 @@ export function makeUnassignedEvent({ /** * Create a "closed" timeline event (indicating a linked PR was merged/closed). */ -export function makeClosedEvent({ - number: _number, - createdAt = '2025-07-10T00:00:00Z', -}) { +export function makeClosedEvent({ createdAt = '2025-07-10T00:00:00Z' }) { return { - id: Math.floor(Math.random() * 100000), + id: nextEventId(), event: 'closed', actor: { login: 'someone', type: 'User' }, created_at: createdAt, diff --git a/scripts/tests/assign-remediation2.test.js b/scripts/tests/assign-remediation2.test.js index 76347e44ee..edad224e6c 100644 --- a/scripts/tests/assign-remediation2.test.js +++ b/scripts/tests/assign-remediation2.test.js @@ -424,10 +424,10 @@ describe('B: Targeted REST mutations', () => { it('jq @uri encodes special-character label names for DELETE', () => { // The cleanup script uses `jq -sRr '@uri'` to URL-encode label names. - // This verifies that special characters (comma, space, slash) are properly - // encoded so the DELETE endpoint receives the correct path. The raw+slurp - // mode produces a single string (not an array), which is the correct - // behavior for @uri. + // This verifies that space and slash in label names are properly encoded + // so the DELETE endpoint receives the correct path. The raw+slurp mode + // produces a single string (not an array), which is the correct behavior + // for @uri. The label under test is 'priority: high/urgent' (space + slash). const assignedAt = daysAgo(20); const repo = createFakeRepo( defaultStateWith({ @@ -850,7 +850,7 @@ describe('E: PR filtering', () => { // =========================================================================== describe('F: Per-issue concurrency and post-cap rollback', () => { - it('assign job has NO concurrency block (postconditions handle races)', async () => { + it('serializes attempts per stable actor ID without cancelling in progress', async () => { const yaml = (await import('js-yaml')).default; const fs = await import('fs'); const source = fs.readFileSync( @@ -864,7 +864,10 @@ describe('F: Per-issue concurrency and post-cap rollback', () => { const workflow = yaml.load(source); expect(workflow.jobs, 'workflow should have jobs').toBeDefined(); expect(workflow.jobs.assign, 'assign job should exist').toBeDefined(); - expect(workflow.jobs.assign.concurrency).toBeUndefined(); + expect(workflow.jobs.assign.concurrency).toEqual({ + group: 'assign-${{ github.event.comment.user.id }}', + 'cancel-in-progress': false, + }); }); it('race: concurrent assignment pushes over cap, rollback occurs', () => { diff --git a/scripts/tests/assign-remediation3.test.js b/scripts/tests/assign-remediation3.test.js index 278ea679ff..7f7a470e8f 100644 --- a/scripts/tests/assign-remediation3.test.js +++ b/scripts/tests/assign-remediation3.test.js @@ -31,6 +31,8 @@ import { makeIssue, makePR, makeAssignedEvent, + makeLabeledEvent, + makeUnlabeledEvent, daysAgo, failOnNth, } from './assign-helpers.js'; @@ -45,42 +47,6 @@ function defaultStateWith(overrides) { return { ...defaultState(), ...overrides }; } -function makeLabeledEvent({ - number: _n, - label = 'auto-assigned', - actor = 'github-actions[bot]', - createdAt = '2025-07-01T00:00:00Z', -}) { - return { - id: Math.floor(Math.random() * 100000), - event: 'labeled', - actor: { - login: actor, - type: actor.endsWith('[bot]') ? 'Bot' : 'User', - }, - label: { name: label }, - created_at: createdAt, - }; -} - -function makeUnlabeledEvent({ - number: _n, - label = 'auto-assigned', - actor = 'github-actions[bot]', - createdAt = '2025-07-02T00:00:00Z', -}) { - return { - id: Math.floor(Math.random() * 100000), - event: 'unlabeled', - actor: { - login: actor, - type: actor.endsWith('[bot]') ? 'Bot' : 'User', - }, - label: { name: label }, - created_at: createdAt, - }; -} - // =========================================================================== // 1: Durable history for /assign itself // =========================================================================== @@ -382,15 +348,17 @@ describe('4: Final cap read fail-closed', () => { }); // =========================================================================== -// 5: No concurrency coalescing +// 5: Per-actor concurrency serialization // =========================================================================== -describe('5: No concurrency coalescing', () => { - it('assign job has NO concurrency block in assign.yml', () => { +describe('5: Per-actor concurrency serialization', () => { + it('uses the stable commenter ID without issue number and does not cancel', () => { const source = readRootFile('.github/workflows/assign.yml'); const workflow = yaml.load(source); - // The assign job must NOT have a concurrency key at all - expect(workflow.jobs.assign.concurrency).toBeUndefined(); + expect(workflow.jobs.assign.concurrency).toEqual({ + group: 'assign-${{ github.event.comment.user.id }}', + 'cancel-in-progress': false, + }); }); it('assign.yml has no mention of concurrency coalescing', () => { @@ -440,6 +408,8 @@ describe('5: No concurrency coalescing', () => { const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + // Contention detected → nonzero exit + expect(result.status).not.toBe(0); // alice must be rolled back (contention detected) expect(result.state.issues['42']._assignees).not.toContain('alice'); // The concurrent assignee remains (not our responsibility to remove) diff --git a/scripts/tests/assign-remediation4.test.js b/scripts/tests/assign-remediation4.test.js index dadeaba2c7..88e6d40e9a 100644 --- a/scripts/tests/assign-remediation4.test.js +++ b/scripts/tests/assign-remediation4.test.js @@ -19,11 +19,6 @@ */ import { describe, expect, it } from 'vitest'; -import { writeFileSync, rmSync } from 'fs'; -import * as nodePath from 'path'; -import { execFileSync } from 'child_process'; -import { mkdtempSync, mkdirSync, readFileSync } from 'fs'; -import { tmpdir } from 'os'; import { createFakeRepo, defaultState, @@ -35,10 +30,9 @@ import { makeUnlabeledEvent, daysAgo, failOnNth, + runRecordHistory, } from './assign-helpers.js'; -const ROOT = nodePath.resolve(import.meta.dirname, '../..'); - function defaultStateWith(overrides) { return { ...defaultState(), ...overrides }; } @@ -496,79 +490,6 @@ describe('F4: REST-based cap count', () => { // F5: Extracted record-history script // =========================================================================== -/** - * Helper to run the record-assignment-history.sh script directly against - * a fake gh, for behavioral testing of the extracted script. - */ -function runRecordHistory({ state, assigneeLogin, extraEnv = {} }) { - const dir = mkdtempSync(nodePath.join(tmpdir(), 'record-hist-')); - const stateFile = nodePath.join(dir, 'state.json'); - const binDir = nodePath.join(dir, 'bin'); - mkdirSync(binDir, { recursive: true }); - const ghWrapper = nodePath.join(binDir, 'gh'); - const fakeGh = nodePath.join(ROOT, 'scripts/tests/fake-gh.py'); - writeFileSync( - ghWrapper, - `#!/usr/bin/env bash\nexec python3 "${fakeGh}" "$@"\n`, - ); - execFileSync('chmod', ['+x', ghWrapper]); - - const defaultState = { - now: '2025-07-23T00:00:00Z', - issues: {}, - prs: {}, - comments: [], - labels: {}, - events: {}, - timeline: {}, - fail_config: {}, - ...state, - }; - writeFileSync(stateFile, JSON.stringify(defaultState, null, 2)); - - const env = { - ...process.env, - GH_TOKEN: 'fake', - GITHUB_TOKEN: 'fake', - GITHUB_REPOSITORY: 'test/repo', - ASSIGNEE_LOGIN: assigneeLogin, - GH_FAKE_STATE: stateFile, - PATH: binDir + ':' + process.env.PATH, - ...extraEnv, - }; - - try { - const stdout = execFileSync( - 'bash', - [nodePath.join(ROOT, '.github/scripts/record-assignment-history.sh')], - { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }, - ); - const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); - return { stdout, stderr: '', status: 0, state: finalState }; - } catch (err) { - // Read final state for assertions, but do NOT mask the original process - // error if state read/parse fails — return a structured fallback state. - let finalState; - try { - finalState = JSON.parse(readFileSync(stateFile, 'utf8')); - } catch { - finalState = { - _stateReadError: true, - labels: {}, - issues: {}, - }; - } - return { - stdout: err.stdout?.toString() ?? '', - stderr: err.stderr?.toString() ?? '', - status: err.status ?? 1, - state: finalState, - }; - } finally { - rmSync(dir, { recursive: true, force: true }); - } -} - describe('F5: Extracted record-history script', () => { const HISTORY_COLOR = '0E8A16'; const HISTORY_DESC = 'Issue assignment history index'; diff --git a/scripts/tests/assign-remediation5.test.js b/scripts/tests/assign-remediation5.test.js index 66e4680e7f..65c18e25c8 100644 --- a/scripts/tests/assign-remediation5.test.js +++ b/scripts/tests/assign-remediation5.test.js @@ -17,11 +17,7 @@ */ import { describe, expect, it } from 'vitest'; -import { writeFileSync, rmSync } from 'fs'; -import { mkdtempSync, mkdirSync, readFileSync } from 'fs'; import { execFileSync } from 'child_process'; -import { tmpdir } from 'os'; -import * as nodePath from 'path'; import { createFakeRepo, defaultState, @@ -31,10 +27,9 @@ import { makeLabeledEvent, daysAgo, failOnNth, + runRecordHistory, } from './assign-helpers.js'; -const ROOT = nodePath.resolve(import.meta.dirname, '../..'); - function defaultStateWith(overrides) { return { ...defaultState(), ...overrides }; } @@ -224,68 +219,6 @@ describe('G1b: fake-gh search and events fidelity', () => { // G2: record-assignment-history.sh validate_history_label return codes // =========================================================================== -/** - * Helper to run the record-assignment-history.sh script directly against - * a fake gh. - */ -function runRecordHistory({ state, assigneeLogin, extraEnv = {} }) { - const dir = mkdtempSync(nodePath.join(tmpdir(), 'record-hist-g2-')); - const stateFile = nodePath.join(dir, 'state.json'); - const binDir = nodePath.join(dir, 'bin'); - mkdirSync(binDir, { recursive: true }); - const ghWrapper = nodePath.join(binDir, 'gh'); - const fakeGh = nodePath.join(ROOT, 'scripts/tests/fake-gh.py'); - writeFileSync( - ghWrapper, - `#!/usr/bin/env bash\nexec python3 "${fakeGh}" "$@"\n`, - ); - execFileSync('chmod', ['+x', ghWrapper]); - - const defaultState = { - now: '2025-07-23T00:00:00Z', - issues: {}, - prs: {}, - comments: [], - labels: {}, - events: {}, - timeline: {}, - fail_config: {}, - ...state, - }; - writeFileSync(stateFile, JSON.stringify(defaultState, null, 2)); - - const env = { - ...process.env, - GH_TOKEN: 'fake', - GITHUB_TOKEN: 'fake', - GITHUB_REPOSITORY: 'test/repo', - ASSIGNEE_LOGIN: assigneeLogin, - GH_FAKE_STATE: stateFile, - PATH: binDir + ':' + process.env.PATH, - ...extraEnv, - }; - - try { - const stdout = execFileSync( - 'bash', - [nodePath.join(ROOT, '.github/scripts/record-assignment-history.sh')], - { encoding: 'utf8', env, stdio: ['ignore', 'pipe', 'pipe'] }, - ); - const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); - return { stdout, stderr: '', status: 0, state: finalState }; - } catch (err) { - const finalState = JSON.parse(readFileSync(stateFile, 'utf8')); - return { - stdout: err.stdout?.toString() ?? '', - stderr: err.stderr?.toString() ?? '', - status: err.status ?? 1, - state: finalState, - }; - } finally { - rmSync(dir, { recursive: true, force: true }); - } -} - describe('G2: record-history return-code capture', () => { const HISTORY_COLOR = '0E8A16'; const HISTORY_DESC = 'Issue assignment history index'; diff --git a/scripts/tests/assign-remediation7.test.js b/scripts/tests/assign-remediation7.test.js index 5ad1648773..aebc4a177c 100644 --- a/scripts/tests/assign-remediation7.test.js +++ b/scripts/tests/assign-remediation7.test.js @@ -403,7 +403,6 @@ function setupConcurrencyRepo(initialState) { * POST assignee). The winner election must converge deterministically. */ function spawnAssignProcess({ - dir: _dir, stateFile, pathWithFakeGh, issueNumber, diff --git a/scripts/tests/assign-remediation8.test.js b/scripts/tests/assign-remediation8.test.js new file mode 100644 index 0000000000..b037cdbaae --- /dev/null +++ b/scripts/tests/assign-remediation8.test.js @@ -0,0 +1,806 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Behavioral tests for PR #2634 new review findings (round 8). + * + * Covers: + * 1: verified_rollback_and_fail posts feedback after successful rollback + * (non-contention); contention-loser rollback remains silent. + * 2: record-assignment-history.sh hardening (login validation, tool checks). + * 3: Constants drift independently asserted (not imported from production). + * 4: elect_winner fails closed on malformed assignee data in timeline. + * 5: Sticky feedback converges duplicate bot marker comments. + * 6: Bounded retry around cleanup timeline GETs. + * 7: Label URI encoding retains jq @uri (no sed stripping needed). + * 8: Deterministic event IDs (no Math.random collision with filler range). + * 14: fake-gh label filter ALL-label subset semantics. + */ + +import { describe, expect, it } from 'vitest'; +import { execFileSync } from 'child_process'; +import * as nodePath from 'path'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + makeFillerEvents, + daysAgo, + failOnNth, + runRecordHistory, +} from './assign-helpers.js'; + +const HISTORY_PREFIX = 'asnhist--'; +const HISTORY_COLOR = '0E8A16'; +const HISTORY_DESC = 'Issue assignment history index'; +const MARKER = ''; + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} + +// =========================================================================== +// Finding 1: verified_rollback_and_fail posts feedback on successful rollback +// =========================================================================== + +describe('F1: rollback feedback posting', () => { + it('assignee-POST failure rollback posts explanatory sticky feedback', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + type: 'error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Nonzero exit + expect(result.status).not.toBe(0); + // alice rolled back + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // auto-assigned label rolled back + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + // Sticky feedback was posted (explanatory message about API error) + const markerComments = result.state.comments.filter( + (c) => + c.issue_number === 42 && + c.user.login === 'github-actions[bot]' && + c.body.startsWith(MARKER), + ); + expect(markerComments.length).toBeGreaterThanOrEqual(1); + expect(markerComments[0].body).toContain('GitHub API error'); + }); + + it('post-mutation cap rollback posts explanatory sticky feedback', () => { + // Alice has 2 open issues. After successful POST to #42, she has 3. + // A side effect also adds alice to #50 concurrently (making 4 > 3 cap). + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + 1: makeIssue({ number: 1, assignees: ['alice'] }), + 2: makeIssue({ number: 2, assignees: ['alice'] }), + 50: makeIssue({ number: 50, assignees: [] }), + }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 50, + assignee: 'alice', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Cap exceeded → rollback (exit 0 per design for cap) + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // Sticky feedback posted about cap + const markerComments = result.state.comments.filter( + (c) => + c.issue_number === 42 && + c.user.login === 'github-actions[bot]' && + c.body.startsWith(MARKER), + ); + expect(markerComments.length).toBeGreaterThanOrEqual(1); + expect(markerComments[0].body).toContain('cap'); + }); + + it('contention-loser rollback remains silent (no sticky overwrite)', () => { + // A concurrent winner is injected immediately before alice's assignee + // POST. The election determines concurrent-user is the winner (earlier + // timeline position). Alice's rollback must NOT post a sticky comment. + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + 101: makePR({ number: 101, author: 'concurrent-user', merged: true }), + }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'add_assignee', + issue: 42, + assignee: 'concurrent-user', + actor: 'github-actions[bot]', + }, + ], + }), + ); + + const result = repo.runAssign({ + issueNumber: 42, + commenter: 'alice', + extraEnv: { ASSIGN_ELECTION_DELAY: '0' }, + }); + + // alice rolled back (contention) + expect(result.state.issues['42']._assignees).not.toContain('alice'); + // concurrent-user remains (the winner) + expect(result.state.issues['42']._assignees).toContain('concurrent-user'); + // NO sticky feedback was posted by this run (silent rollback) + const markerComments = result.state.comments.filter( + (c) => + c.issue_number === 42 && + c.user.login === 'github-actions[bot]' && + c.body.startsWith(MARKER), + ); + expect(markerComments.length).toBe(0); + }); +}); + +// =========================================================================== +// Finding 2: record-assignment-history.sh hardening +// =========================================================================== + +describe('F2: record-history hardening', () => { + it('rejects empty login with clear error', () => { + const result = runRecordHistory({ + state: { labels: {} }, + assigneeLogin: '', + }); + + expect(result.status).not.toBe(0); + // No label created + expect(result.state.labels['asnhist--']).toBeUndefined(); + }); + + it('rejects login with invalid characters', () => { + const result = runRecordHistory({ + state: { labels: {} }, + assigneeLogin: 'invalid user!', + }); + + expect(result.status).not.toBe(0); + expect(result.state.labels['asnhist--invalid user!']).toBeUndefined(); + }); + + it('accepts a normal valid login', () => { + const result = runRecordHistory({ + state: { labels: {} }, + assigneeLogin: 'valid-user', + }); + + expect(result.status).toBe(0); + expect(result.state.labels['asnhist--valid-user']).toBeDefined(); + }); + + it('uses printf not echo for diagnostics (no interpretation of format)', () => { + // A login with %s should not cause printf to consume it as a format spec + // in error messages. The validation happens before label construction, + // but the printf in diagnostics must not interpret % in the login. + const result = runRecordHistory({ + state: { labels: {} }, + assigneeLogin: 'evil%shere', + }); + + // The login has a space so it's invalid + expect(result.status).not.toBe(0); + }); +}); + +// =========================================================================== +// Finding 3: Constants drift independently asserted +// =========================================================================== + +describe('F3: constants policy values independently asserted', () => { + it('HISTORY_PREFIX is asnhist--', () => { + expect(HISTORY_PREFIX).toBe('asnhist--'); + }); + + it('HISTORY_COLOR is 0E8A16', () => { + expect(HISTORY_COLOR).toBe('0E8A16'); + }); + + it('HISTORY_DESC is the canonical description', () => { + expect(HISTORY_DESC).toBe('Issue assignment history index'); + }); + + it('history label name fits within GitHub 50-char limit for max username', () => { + const maxLogin = 'a'.repeat(39); + const labelName = `${HISTORY_PREFIX}${maxLogin}`; + expect(labelName.length).toBeLessThanOrEqual(50); + }); +}); + +// =========================================================================== +// Finding 4: elect_winner fails closed on malformed assignee data +// =========================================================================== + +describe('F4: elect_winner malformed-timeline fail-closed', () => { + it('assigned event with null assignee causes election to fail closed', () => { + // When a concurrent side-effect injects a second assignee with a + // malformed timeline entry (null assignee), the election must fail + // closed rather than silently electing the wrong winner. + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: [] }), + }, + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/labels', + on_nth: 1, + action: 'add_assignee', + issue: 42, + assignee: 'concurrent-user', + }, + ], + }), + ); + + // Inject a malformed assigned event into the timeline BEFORE running assign + repo.updateState((s) => { + s.timeline['42'] = [ + { + id: 777777, + event: 'assigned', + actor: { login: 'github-actions[bot]', type: 'Bot' }, + assignee: null, + created_at: daysAgo(1), + }, + ]; + }); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + // Election should encounter the malformed event and fail closed + expect(result.status).not.toBe(0); + // alice should NOT be assigned (fail closed) + expect(result.state.issues['42']._assignees).not.toContain('alice'); + }); + + it('assigned event with non-object assignee fails closed', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + makeAssignedEvent({ + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: daysAgo(20), + }), + // Malformed: assigned event with non-object assignee (string) + { + id: 888888, + event: 'assigned', + actor: { login: 'github-actions[bot]', type: 'Bot' }, + assignee: 'not-an-object', + created_at: daysAgo(15), + }, + ], + }, + }), + ); + + // Malformed assignment transitions make provenance ambiguous. Cleanup + // must fail closed and preserve the assignment. + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); +}); + +// =========================================================================== +// Finding 5: Sticky feedback converges duplicate bot marker comments +// =========================================================================== + +describe('F5: sticky feedback converges duplicate markers', () => { + it('multiple bot marker comments converge to exactly one; user marker untouched', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ number: 42, assignees: ['someone-else'] }), + }, + comments: [ + { + id: 100, + issue_number: 42, + body: `${MARKER}\nOld bot feedback 1`, + user: { login: 'github-actions[bot]', type: 'Bot' }, + created_at: '2025-07-10T00:00:00Z', + updated_at: '2025-07-10T00:00:00Z', + }, + { + id: 101, + issue_number: 42, + body: `${MARKER}\nOld bot feedback 2`, + user: { login: 'github-actions[bot]', type: 'Bot' }, + created_at: '2025-07-11T00:00:00Z', + updated_at: '2025-07-11T00:00:00Z', + }, + { + id: 102, + issue_number: 42, + body: 'This is a user comment, not a marker', + user: { login: 'human-user', type: 'User' }, + created_at: '2025-07-12T00:00:00Z', + updated_at: '2025-07-12T00:00:00Z', + }, + { + id: 103, + issue_number: 42, + body: `${MARKER}\nOld bot feedback 3`, + user: { login: 'github-actions[bot]', type: 'Bot' }, + created_at: '2025-07-13T00:00:00Z', + updated_at: '2025-07-13T00:00:00Z', + }, + ], + prs: { + 100: makePR({ number: 100, author: 'alice', merged: true }), + }, + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).toBe(0); + + // Exactly one bot marker comment should remain + const botMarkers = result.state.comments.filter( + (c) => + c.issue_number === 42 && + c.user.login === 'github-actions[bot]' && + c.body.startsWith(MARKER), + ); + expect(botMarkers.length).toBe(1); + + // The user comment must be untouched + const userComment = result.state.comments.find((c) => c.id === 102); + expect(userComment).toBeDefined(); + expect(userComment.body).toBe('This is a user comment, not a marker'); + expect(userComment.user.login).toBe('human-user'); + }); +}); + +// =========================================================================== +// Finding 6: Bounded retry around cleanup timeline GETs +// =========================================================================== + +describe('F6: bounded retry around cleanup timeline GETs', () => { + it('timeline GET fails then succeeds on retry (with zero delay)', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + // Fail the timeline GET on the 1st occurrence only + fail_config: failOnNth({ + method: 'GET', + endpoint: 'repos/test/repo/issues/42/timeline', + on_nth: 1, + type: 'error', + }), + }), + ); + + const result = repo.runCleanup({ extraEnv: { ASSIGN_RETRY_DELAY: '0' } }); + + // Retry succeeded — should process the issue + expect(result.status).toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('stale-user'); + }); + + it('timeline GET exhausted retries fails closed (with zero delay)', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user'], + labels: ['auto-assigned'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + ], + }, + // Fail ALL timeline GETs (flat config applies to all occurrences) + fail_config: { + 'repos/test/repo/issues/42/timeline': 'error', + }, + }), + ); + + const result = repo.runCleanup({ extraEnv: { ASSIGN_RETRY_DELAY: '0' } }); + + // All retries exhausted — fail closed + expect(result.status).not.toBe(0); + // State preserved + expect(result.state.issues['42']._assignees).toContain('stale-user'); + }); +}); + +// =========================================================================== +// Finding 8: Deterministic event IDs +// =========================================================================== + +describe('F8: deterministic event IDs do not collide with filler range', () => { + it('event IDs from helpers are > 200000 and do not collide with filler IDs', () => { + const fillers = makeFillerEvents(10); + const event = makeAssignedEvent({ + assignee: 'test-user', + actor: 'github-actions[bot]', + }); + + // Filler IDs are in the 900000+ range + const fillerIds = fillers.map((f) => f.id); + // Helper-generated IDs start at 200001+ + expect(event.id).toBeGreaterThan(200000); + + // No collision + expect(fillerIds).not.toContain(event.id); + }); +}); + +// =========================================================================== +// Finding 14: fake-gh label filter ALL-label subset semantics +// =========================================================================== + +describe('F14: fake-gh label filter ALL-label subset', () => { + it('issues listing requires ALL requested labels (subset match)', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 1: makeIssue({ + number: 1, + assignees: [], + labels: ['auto-assigned', 'bug'], + }), + 2: makeIssue({ + number: 2, + assignees: [], + labels: ['auto-assigned'], + }), + 3: makeIssue({ + number: 3, + assignees: [], + labels: ['bug'], + }), + }, + }), + ); + + // Request BOTH labels — only issue #1 should match + repo.updateState((s) => { + s.page_size = 100; + }); + + const result = execFileSync( + 'bash', + [ + '-c', + `PATH="${repo.binDir}:$PATH" GH_FAKE_STATE="${repo.stateFile}" ` + + `gh api 'repos/test/repo/issues?state=open&labels=auto-assigned,bug&per_page=100' --paginate`, + ], + { encoding: 'utf8' }, + ); + + const issues = JSON.parse(result); + expect(issues.length).toBe(1); + expect(issues[0].number).toBe(1); + }); + + // =========================================================================== + // Full-review follow-up: ownership rollback, cleanup compensation, signals + // =========================================================================== + + describe('ownership-aware assignment rollback', () => { + it('never deletes a same-login human assignment made during label POST', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/labels', + on_nth: 1, + action: 'add_assignee', + issue: 42, + assignee: 'alice', + actor: 'maintainer', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + expect( + (result.state._op_log ?? []).filter( + (op) => + op.method === 'DELETE' && + op.endpoint === 'repos/test/repo/issues/42/assignees', + ), + ).toHaveLength(0); + }); + + it('rolls back an assignee POST that applied before returning an error', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + type: 'applied_error', + }), + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).not.toContain('alice'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + }); + + it('preserves a human same-login takeover after an ambiguous applied POST', () => { + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + fail_config: failOnNth({ + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + type: 'applied_error', + }), + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + timing: 'post', + action: 'unassign_reassign', + issue: 42, + assignee: 'alice', + actor: 'maintainer', + }, + ], + }), + ); + + const result = repo.runAssign({ issueNumber: 42, commenter: 'alice' }); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toContain('alice'); + expect(result.state.issues['42']._label_names).not.toContain( + 'auto-assigned', + ); + expect( + (result.state._op_log ?? []).filter( + (op) => + op.method === 'DELETE' && + op.endpoint === 'repos/test/repo/issues/42/assignees', + ), + ).toHaveLength(0); + }); + }); + + describe('cleanup same-login takeover compensation', () => { + it('restores a human takeover detected after targeted DELETE and retains marker', () => { + const assignedAt = daysAgo(20); + const repo = createFakeRepo( + defaultStateWith({ + issues: { + 42: makeIssue({ + number: 42, + assignees: ['stale-user', 'co-owner'], + labels: ['auto-assigned', 'bug'], + }), + }, + timeline: { + 42: [ + makeLabeledEvent({ + label: 'auto-assigned', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + assignee: 'stale-user', + actor: 'github-actions[bot]', + createdAt: assignedAt, + }), + makeAssignedEvent({ + assignee: 'co-owner', + actor: 'maintainer', + createdAt: assignedAt, + }), + ], + }, + side_effects: [ + { + method: 'DELETE', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + action: 'unassign_reassign', + issue: 42, + assignee: 'stale-user', + actor: 'maintainer', + }, + ], + }), + ); + + const result = repo.runCleanup(); + + expect(result.status).not.toBe(0); + expect(result.state.issues['42']._assignees).toEqual( + expect.arrayContaining(['stale-user', 'co-owner']), + ); + expect(result.state.issues['42']._label_names).toEqual( + expect.arrayContaining(['auto-assigned', 'bug']), + ); + expect( + (result.state._op_log ?? []).filter( + (op) => + op.method === 'POST' && + op.endpoint === 'repos/test/repo/issues/42/assignees', + ), + ).toHaveLength(1); + }); + }); + + describe('assignment signal lifecycle', () => { + it('rolls back bot-owned mutations when TERM arrives after assignee mutation', () => { + const hookFile = nodePath.join( + process.cwd(), + 'tmp', + `assign-signal-${process.pid}`, + ); + const repo = createFakeRepo( + defaultStateWith({ + issues: { 42: makeIssue({ number: 42, assignees: [] }) }, + prs: { 100: makePR({ number: 100, author: 'alice', merged: true }) }, + side_effects: [ + { + method: 'POST', + endpoint: 'repos/test/repo/issues/42/assignees', + on_nth: 1, + timing: 'post', + action: 'pause', + hook_file: hookFile, + seconds: 0.5, + }, + ], + }), + ); + const assignScript = nodePath.join( + import.meta.dirname, + '../..', + '.github/scripts/assign-issue.sh', + ); + const env = { + ...process.env, + GH_TOKEN: 'fake-token', + GITHUB_TOKEN: 'fake-token', + GITHUB_REPOSITORY: 'test/repo', + ISSUE_NUMBER: '42', + COMMENTER_LOGIN: 'alice', + GH_FAKE_STATE: repo.stateFile, + ASSIGN_ELECTION_DELAY: '0', + PATH: `${repo.binDir}:${process.env.PATH}`, + ASSIGN_SCRIPT: assignScript, + SIGNAL_HOOK: hookFile, + }; + + let status = 0; + try { + execFileSync( + 'bash', + [ + '-c', + 'rm -f "$SIGNAL_HOOK"; bash "$ASSIGN_SCRIPT" >/dev/null 2>&1 & pid=$!; found=false; for _ in $(seq 1 500); do if [[ -f "$SIGNAL_HOOK" ]]; then found=true; break; fi; sleep 0.01; done; if [[ "$found" != true ]]; then kill "$pid" 2>/dev/null || true; wait "$pid" 2>/dev/null || true; exit 99; fi; kill -TERM "$pid"; wait "$pid"', + ], + { env, stdio: ['ignore', 'pipe', 'pipe'] }, + ); + } catch (error) { + status = error.status ?? 1; + } + + const state = repo.readState(); + expect(status).not.toBe(0); + expect(state.issues['42']._assignees).not.toContain('alice'); + expect(state.issues['42']._label_names).not.toContain('auto-assigned'); + }); + }); +}); diff --git a/scripts/tests/assign-workflow-behaviors.test.js b/scripts/tests/assign-workflow-behaviors.test.js index df2cc74983..33e42e77cc 100644 --- a/scripts/tests/assign-workflow-behaviors.test.js +++ b/scripts/tests/assign-workflow-behaviors.test.js @@ -39,6 +39,12 @@ import { describe('assign.yml workflow configuration', () => { const source = readRootFile('.github/workflows/assign.yml'); const workflow = yaml.load(source); + + it('workflow has jobs defined', () => { + expect(workflow.jobs, 'workflow should have jobs').toBeDefined(); + expect(workflow.jobs.assign, 'assign job should exist').toBeDefined(); + }); + const job = workflow.jobs?.assign; it('triggers on issue_comment created and issues assigned', () => { @@ -57,7 +63,7 @@ describe('assign.yml workflow configuration', () => { it('gates on exact /assign for issues (not PRs) and rejects bots', () => { expect(job, 'assign job should exist').toBeTruthy(); - const condition = normalize(job.if); + const condition = normalize(job?.if ?? ''); expect(condition).toContain('github.event.issue.pull_request == null'); expect(condition).toContain("github.event.comment.user.type != 'Bot'"); expect(condition).toContain("github.event.comment.body == '/assign'"); @@ -70,14 +76,18 @@ describe('assign.yml workflow configuration', () => { ); }); - it('assign job has NO concurrency block (postconditions handle races)', () => { - expect(job.concurrency).toBeUndefined(); + it('serializes attempts per stable actor ID without cancelling in progress', () => { + expect(job?.concurrency).toEqual({ + group: 'assign-${{ github.event.comment.user.id }}', + 'cancel-in-progress': false, + }); }); it('passes env vars and runs the script', () => { - const runStep = job.steps?.find( + const runStep = job?.steps?.find( (s) => s.name === 'Run assign-issue script', ); + expect(runStep, 'Run assign-issue script step should exist').toBeTruthy(); expect(runStep?.run).toContain('./.github/scripts/assign-issue.sh'); expect(runStep?.env?.ISSUE_NUMBER).toBe('${{ github.event.issue.number }}'); expect(runStep?.env?.COMMENTER_LOGIN).toBe( @@ -89,29 +99,44 @@ describe('assign.yml workflow configuration', () => { describe('assign.yml record-history job', () => { const workflow = yaml.load(readRootFile('.github/workflows/assign.yml')); + + it('workflow has a record-history job', () => { + expect(workflow.jobs, 'workflow should have jobs').toBeDefined(); + expect( + workflow.jobs['record-history'], + 'record-history job should exist', + ).toBeDefined(); + }); + const job = workflow.jobs?.['record-history']; it('has a record-history job that fires on issues:assigned events', () => { expect(job, 'record-history job should exist').toBeTruthy(); - expect(normalize(job.if)).toContain("github.event_name == 'issues'"); - expect(normalize(job.if)).toContain("github.event.action == 'assigned'"); + expect(normalize(job?.if ?? '')).toContain("github.event_name == 'issues'"); + expect(normalize(job?.if ?? '')).toContain( + "github.event.action == 'assigned'", + ); }); it('uses least-privilege permissions', () => { - expect(job.permissions).toEqual({ + expect(job?.permissions).toEqual({ contents: 'read', issues: 'write', }); }); it('validates login from event payload and delegates to record-history script', () => { - const runStep = job.steps?.find( + const runStep = job?.steps?.find( (s) => s.name === 'Record assignment-history label', ); + expect( + runStep, + 'Record assignment-history label step should exist', + ).toBeTruthy(); expect(runStep?.env?.ASSIGNEE_LOGIN).toBe( '${{ github.event.assignee.login }}', ); - const runText = normalize(runStep?.run); + const runText = normalize(runStep?.run ?? ''); // Must invoke the extracted record-history script expect(runText).toContain('record-assignment-history.sh'); // Must NOT contain inline label creation logic (delegated to script) @@ -122,6 +147,12 @@ describe('assign.yml record-history job', () => { describe('assign-stale-cleanup.yml workflow configuration', () => { const source = readRootFile('.github/workflows/assign-stale-cleanup.yml'); const workflow = yaml.load(source); + + it('workflow has a cleanup job', () => { + expect(workflow.jobs, 'workflow should have jobs').toBeDefined(); + expect(workflow.jobs.cleanup, 'cleanup job should exist').toBeDefined(); + }); + const job = workflow.jobs?.cleanup; it('runs on a daily schedule and workflow_dispatch', () => { @@ -130,10 +161,10 @@ describe('assign-stale-cleanup.yml workflow configuration', () => { }); it('guards scheduled runs to the canonical upstream repository', () => { - expect(normalize(job?.if)).toContain( + expect(normalize(job?.if ?? '')).toContain( "github.repository == 'vybestack/llxprt-code'", ); - expect(normalize(job?.if)).not.toContain('llpxrt-code'); + expect(normalize(job?.if ?? '')).not.toContain('llpxrt-code'); }); }); @@ -728,7 +759,7 @@ describe('CONTRIBUTING.md self-assign docs', () => { 'Self Assigning Issues section should exist', ).toBeTruthy(); const sectionText = normalize(selfAssignSection[0]); - expect(sectionText).not.toMatch(/owner|member|collaborator/i); + expect(sectionText).not.toMatch(/\bowner\b|\bmember\b|\bcollaborator\b/i); // Must not claim write access is the only cause of assignment failure expect(sectionText).not.toContain('write access is the only'); }); diff --git a/scripts/tests/assign-workflow.test.js b/scripts/tests/assign-workflow.test.js index 4cba9f9a26..7673bf3dbc 100644 --- a/scripts/tests/assign-workflow.test.js +++ b/scripts/tests/assign-workflow.test.js @@ -64,8 +64,11 @@ describe('.github/workflows/assign.yml', () => { ); }); - it('assign job has NO concurrency block (postconditions handle races)', () => { - expect(job.concurrency).toBeUndefined(); + it('serializes attempts per stable actor ID without cancelling in progress', () => { + expect(job.concurrency).toEqual({ + group: 'assign-${{ github.event.comment.user.id }}', + 'cancel-in-progress': false, + }); }); it('passes env vars and runs the script', () => { diff --git a/scripts/tests/fake-gh.py b/scripts/tests/fake-gh.py index 3135717345..a1883b9498 100644 --- a/scripts/tests/fake-gh.py +++ b/scripts/tests/fake-gh.py @@ -34,6 +34,7 @@ import re import subprocess import sys +import time import urllib.parse try: @@ -360,6 +361,30 @@ def _apply_side_effect_action(state, se): issue["_assignees"] = issue.get("_assignees", []) if a not in issue["_assignees"]: issue["_assignees"].append(a) + elif action == "unassign_reassign" and issue is not None: + a = se["assignee"] + actor = se.get("actor", "concurrent-user") + if a in issue.get("_assignees", []): + issue["_assignees"] = [login for login in issue["_assignees"] if login != a] + _add_timeline_event(state, se.get("issue"), { + "event": "unassigned", + "actor": {"login": actor, "type": "User"}, + "assignee": {"login": a}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + issue.setdefault("_assignees", []).append(a) + _add_timeline_event(state, se.get("issue"), { + "event": "assigned", + "actor": {"login": actor, "type": "User"}, + "assignee": {"login": a}, + "created_at": state.get("now", "2025-07-23T00:00:00Z"), + }) + elif action == "pause": + hook_file = se.get("hook_file") + if hook_file: + with open(hook_file, "w") as f: + f.write("paused\n") + time.sleep(float(se.get("seconds", 1))) elif action == "remove_label" and issue is not None: lbl = se["label"] issue["_label_names"] = [ @@ -632,7 +657,7 @@ def handle_get(state, base_path, params, do_paginate): if label_filter: wanted = set(label_filter.split(",")) have = set(issue.get("_label_names", [])) - if not wanted.intersection(have): + if not wanted.issubset(have): continue results.append(issue_to_api(issue, state)) # GitHub /issues includes PRs too (unless filtered by query) @@ -650,7 +675,7 @@ def handle_get(state, base_path, params, do_paginate): if label_filter: wanted = set(label_filter.split(",")) have = set(pr.get("_label_names", [])) - if not wanted.intersection(have): + if not wanted.issubset(have): continue results.append(pr_to_issue_api(pr, state)) return results @@ -1034,6 +1059,17 @@ def handle_delete(state, base_path, body): save_state(state) return issue_to_api(issue, state) + m = re.match(rf"^{REPO_PREFIX}/issues/comments/(\d+)$", base_path) + if m: + cid = int(m.group(1)) + comments = state.get("comments", []) + for i, c in enumerate(comments): + if c["id"] == cid: + del comments[i] + save_state(state) + return "" + die(f"HTTP 404: Comment {cid} not found", 1) + die(f"HTTP 404: Unknown DELETE endpoint: {base_path}", 1) @@ -1116,9 +1152,12 @@ def _process_request(method, base_path, params, body, do_paginate, jq_filter, si # Check failure injection (method + endpoint + nth aware) should_fail, fail_info = check_fail(state, method, base_path) + applied_failure = False if should_fail: fail_type = fail_info.get("type", "error") if isinstance(fail_info, dict) else fail_info http_status = fail_info.get("http_status") if isinstance(fail_info, dict) else None + applied_failure = fail_type == "applied_error" + if should_fail and not applied_failure: log_operation(state, method, base_path, "failed") save_state(state) if fail_type == "malformed": @@ -1169,6 +1208,20 @@ def _process_request(method, base_path, params, body, do_paginate, jq_filter, si # Apply post-handler side effects (after state was mutated by this request) apply_post_side_effects(state, method, base_path) + if applied_failure: + state = load_state() + log_operation(state, method, base_path, "failed_after_apply") + save_state(state) + status = http_status or 500 + die( + json.dumps({ + "message": "Server Error", + "documentation_url": "", + "status": str(status), + }), + 1, + ) + # Emit configured stderr warnings (simulates gh CLI writing to stderr # while still returning valid JSON on stdout — e.g. deprecation notices). for warn in state.get("stderr_warnings", []):