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-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 new file mode 100755 index 0000000000..fa61b40c25 --- /dev/null +++ b/.github/scripts/assign-issue.sh @@ -0,0 +1,1048 @@ +#!/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' +# 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}" + +: "${GITHUB_REPOSITORY:?Missing GITHUB_REPOSITORY}" +: "${ISSUE_NUMBER:?Missing ISSUE_NUMBER}" +: "${COMMENTER_LOGIN:?Missing COMMENTER_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 + +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}" + +# --------------------------------------------------------------------------- +# 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 "${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)" + 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 "${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)" + 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 "${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)" + 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 + *) + echo "‼ Unexpected label validation status: ${rc}" >&2 + return 1 + ;; + 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')" + 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_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}" + local assignees_json timeline_json ownership + + 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 + + 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 ! 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 + + 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 + + if [[ "${rolled_label}" == "true" ]]; then + 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 [[ "${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 +} + +# 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). +# +# 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" + local label_name="$3" + 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}" +} + +# --------------------------------------------------------------------------- +# 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 + | ($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 + | ($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 // "unknown"), + 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 _comments_stderr_file + _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 + 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)] | sort' \ + --arg bot "${BOT_LOGIN}" --arg marker "${MARKER}")" || { + echo "‼ Failed to parse comments for feedback" >&2 + return 1 + } + + local count + count="$(echo "${all_comment_ids}" | jq 'length')" + + 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 +} + +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. +# --------------------------------------------------------------------------- + +echo " Processing /assign for issue #${ISSUE} by @${USER_LOGIN}" + +# 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 + +# 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 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 + +# 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_count="" +merged_count="$(get_merged_pr_count "${USER_LOGIN}")" || abort_infra_error "‼ Failed to query merged PRs for @${USER_LOGIN}" + +if [[ "${merged_count}" -gt 0 ]]; then + is_eligible=true + eligibility_reason="prior merged PR(s)" +fi + +if [[ "${is_eligible}" != "true" ]]; then + 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}" + ;; + *) + abort_infra_error "‼ Unexpected historical assignment status for @${USER_LOGIN}: ${hist_rc}" + ;; + esac +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 + +echo "[OK] Eligible via: ${eligibility_reason}" + +# 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 + +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). +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 +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 +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" + +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 +fi + +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 false + fi +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 + 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')" + + 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 false + 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 + 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 + 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 + 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 false + fi + + if [[ "${_e_attempt}" -le "${enforcement_retries}" ]]; then + sleep "${ELECTION_DELAY}" + fi +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 false +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 +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 +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 +fi + +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 +fi + +# 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 +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}). + +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..0bfa1d620a --- /dev/null +++ b/.github/scripts/record-assignment-history.sh @@ -0,0 +1,126 @@ +#!/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 + +# 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}" + +if [[ -n "${GH_TOKEN:-}" ]]; then + export GH_TOKEN +elif [[ -n "${GITHUB_TOKEN:-}" ]]; then + export GH_TOKEN="${GITHUB_TOKEN}" +else + 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 printf '%s' "${_diag}" | grep -qE 'HTTP.?404|404.*not found'; then + return 1 + fi + # Non-404 API error — log sanitized diagnostics and signal error. + 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="$(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 + return 0 +} + +validate_history_label "${LABEL_NAME}" || rc=$? +case ${rc:-0} in + 0) + printf 'Label %s already exists with correct definition\n' "${LABEL_NAME}" + exit 0 + ;; + 1) ;; # absent — proceed to create + 2) + printf '‼ Failed to check label %s (API error)\n' "${LABEL_NAME}" >&2 + exit 1 + ;; + *) + printf '‼ Unexpected label validation status: %s\n' "${rc:-0}" >&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) + printf 'Label %s already exists (created concurrently)\n' "${LABEL_NAME}" + exit 0 + ;; + 1) + printf '‼ Label %q exists with conflicting definition\n' "${LABEL_NAME}" >&2 + exit 1 + ;; + *) + printf '‼ Failed to create label %s\n' "${LABEL_NAME}" >&2 + exit 1 + ;; + esac +fi + +printf 'Created label %s\n' "${LABEL_NAME}" diff --git a/.github/scripts/unassign-stale-issues.sh b/.github/scripts/unassign-stale-issues.sh new file mode 100755 index 0000000000..45ab76acde --- /dev/null +++ b/.github/scripts/unassign-stale-issues.sh @@ -0,0 +1,628 @@ +#!/usr/bin/env bash +# 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}" + +: "${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 + export GH_TOKEN="${GITHUB_TOKEN}" +else + echo "‼️ Missing \$GH_TOKEN / \$GITHUB_TOKEN" >&2 + exit 1 +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 + if "$@"; then + return 0 + fi + if [[ "${attempt}" -lt 4 ]]; then + echo "Attempt ${attempt} failed, retrying: $*" >&2 + sleep "${ASSIGN_RETRY_DELAY}" + fi + done + echo "All retries exhausted for: $*" >&2 + return 1 +} + +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 + date -u -v-"${STALE_DAYS}"d +%Y-%m-%dT%H:%M:%SZ +} + +iso_to_epoch() { + local iso="$1" + 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 +} + +# 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" + 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. +get_issue_assignees_json() { + local issue_number="$1" + gh api "repos/${REPO}/issues/${issue_number}" \ + --jq '.assignees // []' 2>/dev/null +} + + +# Provenance extraction from a pre-fetched timeline snapshot. +# Args: assignees_json, timeline_json. +# Output: "LOGIN TIMESTAMP POSITION" 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) \($assigned_pos)" + 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 + + 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')" + 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 +} + +# 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 +} + +# 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}" +} + +# 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 "${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 printf '%s' "${_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_json="$2" + local threshold_epoch="$3" + + local assignee_count + assignee_count="$(echo "${assignees_json}" | jq 'length' 2>/dev/null || echo '?')" + + echo "🔄 Checking auto-assigned issue #${issue_number} (assignees: ${assignee_count})" + + if [[ "${assignee_count}" == "0" ]]; then + echo " No assignees; removing stale ${AUTO_ASSIGNED_LABEL} label" + 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_assignment_rest bot_assigned_at bot_assigned_pos + bot_login="${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} (timeline position ${bot_assigned_pos})" + + if [[ "${bot_login}" == "${EXEMPT_LOGIN}" ]]; then + echo " Keeping @${EXEMPT_LOGIN} on #${issue_number} (exempt)" + return 0 + fi + + 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 + + if [[ "${bot_assigned_epoch}" -gt "${threshold_epoch}" ]]; then + echo " Assignment for #${issue_number} is newer than ${STALE_DAYS} days; keeping" + return 0 + 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 [[ "${has_pr}" == "true" ]]; then + echo " @${bot_login} has qualifying linked PR activity for #${issue_number}; keeping" + return 0 + 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 + } + + 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 + } + + 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_rest revalidated_pos + revalidated_login="${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_pos}" != "${bot_assigned_pos}" ]]; then + echo " Warning: Provenance timeline position 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}" + + 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="" + 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}" + +# 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})" + +GLOBAL_FAIL=0 + +if ! CANDIDATES_RAW="$(discover_candidates)"; then + echo "‼️ Candidate discovery failed" >&2 + exit 1 +fi + +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_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 new file mode 100644 index 0000000000..a534831714 --- /dev/null +++ b/.github/workflows/assign-stale-cleanup.yml @@ -0,0 +1,49 @@ +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: + # Daily at 07:00 UTC + - cron: '0 7 * * *' + workflow_dispatch: {} + +permissions: + contents: read + issues: write + pull-requests: read + +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..a2e37d8ad4 --- /dev/null +++ b/.github/workflows/assign.yml @@ -0,0 +1,109 @@ +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 merged-PR eligibility checks under an explicit permissions + # block (public repos are not exempt once permissions: is set). + pull-requests: read + +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 and Bot accounts (spam hardening). + # + # 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 && + 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"') + 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 }} + 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 + # 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' + 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 76567060f6..508edc456f 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 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` 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 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..0287e77737 --- /dev/null +++ b/scripts/tests/assign-helpers.js @@ -0,0 +1,487 @@ +/** + * @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, + 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. + */ +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]); + + 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, + 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(), + }; + } + }, + }; +} + +/** + * 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. + */ +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({ + assignee, + actor = 'github-actions[bot]', + createdAt = '2025-07-01T00:00:00Z', +}) { + return { + id: nextEventId(), + 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({ + prNumber, + prAuthor, + createdAt = '2025-07-05T00:00:00Z', + repositoryUrl = 'https://api.github.com/repos/test/repo', +}) { + return { + id: nextEventId(), + 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({ + label, + actor = 'github-actions[bot]', + createdAt = '2025-07-01T00:00:00Z', +}) { + return { + id: nextEventId(), + 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({ + label, + actor = 'github-actions[bot]', + createdAt = '2025-07-02T00:00:00Z', +}) { + return { + id: nextEventId(), + 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({ + assignee, + actor = 'github-actions[bot]', + createdAt = '2025-07-02T00:00:00Z', +}) { + return { + id: nextEventId(), + 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({ createdAt = '2025-07-10T00:00:00Z' }) { + return { + id: nextEventId(), + 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..edad224e6c --- /dev/null +++ b/scripts/tests/assign-remediation2.test.js @@ -0,0 +1,934 @@ +/** + * @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 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({ + 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('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( + 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).toEqual({ + group: 'assign-${{ github.event.comment.user.id }}', + 'cancel-in-progress': false, + }); + }); + + 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..7f7a470e8f --- /dev/null +++ b/scripts/tests/assign-remediation3.test.js @@ -0,0 +1,819 @@ +/** + * @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, + makeLabeledEvent, + makeUnlabeledEvent, + 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 }; +} + +// =========================================================================== +// 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: Per-actor concurrency serialization +// =========================================================================== + +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); + 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', () => { + 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' }); + + // 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) + 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..88e6d40e9a --- /dev/null +++ b/scripts/tests/assign-remediation4.test.js @@ -0,0 +1,673 @@ +/** + * @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 { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + makeUnassignedEvent, + makeUnlabeledEvent, + daysAgo, + failOnNth, + runRecordHistory, +} from './assign-helpers.js'; + +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 +// =========================================================================== + +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..65c18e25c8 --- /dev/null +++ b/scripts/tests/assign-remediation5.test.js @@ -0,0 +1,766 @@ +/** + * @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 { execFileSync } from 'child_process'; +import { + createFakeRepo, + defaultState, + makeIssue, + makePR, + makeAssignedEvent, + makeLabeledEvent, + daysAgo, + failOnNth, + runRecordHistory, +} from './assign-helpers.js'; + +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 +// =========================================================================== + +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..aebc4a177c --- /dev/null +++ b/scripts/tests/assign-remediation7.test.js @@ -0,0 +1,651 @@ +/** + * @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({ + 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-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 new file mode 100644 index 0000000000..33e42e77cc --- /dev/null +++ b/scripts/tests/assign-workflow-behaviors.test.js @@ -0,0 +1,774 @@ +/** + * @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); + + 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', () => { + 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('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( + (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( + '${{ 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')); + + 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'", + ); + }); + + 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, + 'Record assignment-history label step should exist', + ).toBeTruthy(); + 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); + + 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', () => { + 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(/\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'); + }); +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function defaultStateWith(overrides) { + return { ...defaultState(), ...overrides }; +} diff --git a/scripts/tests/assign-workflow.test.js b/scripts/tests/assign-workflow.test.js new file mode 100644 index 0000000000..7673bf3dbc --- /dev/null +++ b/scripts/tests/assign-workflow.test.js @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Vybestack LLC + * 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'; +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 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"'`, + ); + expect(condition).toContain( + `toJSON(github.event.comment.body) == '"/assign\\r\\n"'`, + ); + expect(source).not.toMatch( + /startsWith\(toJSON\(github\.event\.comment\.body\)/, + ); + }); + + 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 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'", + ); + 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', + 'pull-requests': 'read', + }); + 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 uses gh api exclusively with fail-closed guards', () => { + 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('gh api'); + expect(script).toContain('merged'); + expect(script).toContain('github-actions[bot]'); + }); + + 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', + ); + 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('github-actions[bot]'); + expect(script).toContain('timeline'); + }); +}); + +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('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..a1883b9498 --- /dev/null +++ b/scripts/tests/fake-gh.py @@ -0,0 +1,1260 @@ +#!/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 time +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 == "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"] = [ + 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.issubset(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.issubset(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) + + 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) + + +# --------------------------------------------------------------------------- +# 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) + 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": + 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) + + 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", []): + 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()