diff --git a/.claude/commands/pm/issue-close.md b/.claude/commands/pm/issue-close.md index 37e268a92..6a6dcf521 100644 --- a/.claude/commands/pm/issue-close.md +++ b/.claude/commands/pm/issue-close.md @@ -107,9 +107,6 @@ open_blockers=$(gh api --paginate "repos/{owner}/{repo}/issues/$issue_number/dep --jq '.[] | select(.state != "closed") | .number') || exit 1 [ -z "$open_blockers" ] || { echo "❌ Native blockers remain open" >&2; exit 1; } issue_labels=$(gh issue view "$issue_number" --json labels --jq '.labels[].name') || exit 1 -category_count=$(printf '%s\n' "$issue_labels" | awk '$0 == "bug" || $0 == "enhancement" { n++ } END { print n + 0 }') -triage_count=$(printf '%s\n' "$issue_labels" | awk '/^(needs-triage|needs-info|ready-for-agent|ready-for-human|wontfix)$/ { n++ } END { print n + 0 }') -[ "$category_count" -eq 1 ] && [ "$triage_count" -eq 1 ] || { echo "❌ Issue labels violate the lifecycle contract" >&2; exit 1; } if printf '%s\n' "$issue_labels" | grep -Fxq tracking; then open_children=$(gh api --paginate "repos/{owner}/{repo}/issues/$issue_number/sub_issues" \ @@ -122,8 +119,8 @@ if printf '%s\n' "$issue_labels" | grep -Fxq tracking; then fi if printf '%s\n' "$issue_labels" | grep -Fxq runtime; then - runtime_record=$(gh issue view "$issue_number" --json comments \ - --jq '[.comments[].body] | join("\n")') || exit 1 + runtime_record=$(gh issue view "$issue_number" --json body,comments \ + --jq '[.body] + [.comments[].body] | join("\n")') || exit 1 runtime_record="$runtime_record $completion_evidence" for field in "Exact target" "Named controller" "Candidate identity" \ diff --git a/.claude/commands/pm/issue-start.md b/.claude/commands/pm/issue-start.md index 99523a5e1..02dce7d8f 100644 --- a/.claude/commands/pm/issue-start.md +++ b/.claude/commands/pm/issue-start.md @@ -4,12 +4,14 @@ allowed-tools: Bash, Read, Write, LS, Task # Issue Start -Verify the issue has one change contract, acceptance evidence, out-of-scope -boundary, and declared dependency. Then create one dedicated worktree and -record its contract, owner, path, branch, base SHA, allowed files, and -dependency in the worktree-private `agent-worktree.yml` path returned by -`git rev-parse --git-path agent-worktree.yml`. - -Launch one writable agent only. Any concurrent analysis or review is read-only. -Stop if ownership, the base SHA, or file scope is ambiguous. Do not clean up a -worktree or branch without explicit repository-owner authorization. +Read the issue outcome, proof, comments, and native dependencies. Stop only when +the next behavior, authority, or required input is genuinely ambiguous. + +Keep one write owner. Use the current checkout for an isolated local change with +known ownership and dirty state. Create a dedicated branch/worktree and private +`agent-worktree.yml` record only for concurrent, published, or multi-session +work. + +Do not create another issue for an owner handoff or another attempt at the same +outcome. Record attempts, failures, cleanup, and evidence on the existing issue. +Never remove a branch or worktree without explicit repository-owner authority. diff --git a/.claude/rules/agent-coordination.md b/.claude/rules/agent-coordination.md index 40544d9e8..5b1fe8cd9 100644 --- a/.claude/rules/agent-coordination.md +++ b/.claude/rules/agent-coordination.md @@ -1,27 +1,17 @@ # Agent Coordination -Each section, issue, and PR has one write owner, branch, and worktree. Never -place multiple writable agents in one worktree or have them exchange work by -pulling a shared branch. +One active contract has one write owner. Read-only research and review may run +in parallel. -Before the first write, the owner records: +Use the current checkout for an isolated local change when its ownership and +dirty state are known. Use a dedicated branch and worktree when work is +concurrent, published, or likely to span sessions. A managed worktree records +its contract, owner, path, branch, base SHA, allowed files, and dependencies in +the private path returned by `git rev-parse --git-path agent-worktree.yml`. -```yaml -contract: Issue #{number}: {one behavior contract} -owner: {agent or human} -worktree: .worktrees/codex/{issue-slug} -branch: codex/{issue-slug} -base_sha: {exact integration-base SHA} -allowed_files: [{paths or patterns}] -dependency: None | #{blocking-issue} -``` +Stop on overlapping ownership or unexpected branch movement. Re-read branch, +`HEAD`, status, and PR head before publishing or merging. Ownership handoff does +not create a new issue; update the existing contract record. -Write this YAML to the worktree-private path returned by -`git rev-parse --git-path agent-worktree.yml`. It is the runtime record; this -policy file is only its template and must not be overwritten. - -Read-only research and review may run in parallel. A second writable change is -either a separate issue/worktree or waits for an explicit ownership transfer. -Before every edit, commit, rebase, push, or merge, re-read the branch, `HEAD`, -and worktree status; stop on unexpected movement. Report final worktree status -to the coordinator. Cleanup requires explicit repository-owner authorization. +Never remove a worktree or branch without explicit repository-owner +authorization for the exact target. diff --git a/.claude/rules/branch-operations.md b/.claude/rules/branch-operations.md index 8ec7a270a..939070679 100644 --- a/.claude/rules/branch-operations.md +++ b/.claude/rules/branch-operations.md @@ -1,11 +1,12 @@ # Branch Operations -A branch belongs to one independently mergeable section, issue, and PR. Its -dedicated worktree has exactly one write owner. +Use a branch when a change will be published or must be isolated from concurrent +writes. A branch has one write owner and contains one independently reviewable +behavior. -Before edits, commits, rebases, pushes, or merges, re-read the branch, `HEAD`, -and status. Do not pull or push another issue's branch as a synchronization -mechanism; depend on the merged predecessor or an explicitly declared stack. +Re-read branch, `HEAD`, status, and PR head before publishing or merging. Do not +use another contract's branch as a synchronization mechanism; depend on its +merged result or an explicit stack. -Preserve branches and worktrees after merge, failure, or abandonment until the -repository owner explicitly authorizes cleanup of exact targets. +Do not delete a branch or worktree without explicit repository-owner +authorization for the exact target. diff --git a/.claude/rules/worktree-operations.md b/.claude/rules/worktree-operations.md index c36f54e62..e2c07281f 100644 --- a/.claude/rules/worktree-operations.md +++ b/.claude/rules/worktree-operations.md @@ -1,13 +1,12 @@ # Worktree Operations -One section, issue, and PR is one writable rollback unit. Create one dedicated -worktree and branch for that unit; do not place two writable agents in it. +A worktree isolates concurrent, published, or multi-session writes. It is not +required for read-only work or an isolated local change with known ownership. -Before the first write, record the contract, owner, worktree path, branch, base -SHA, allowed files, and dependency. Re-read the branch, `HEAD`, and status -before edits, commits, rebases, pushes, or merges. A reviewer is read-only. +Each writable worktree has one owner. Before its first write, record the +contract, owner, path, branch, base SHA, allowed files, and dependencies in the +private `agent-worktree.yml` path returned by Git. -Use the declared integration base, not a blind checkout of `main`. Preserve -worktrees after merge or abandonment until the repository owner explicitly -authorizes removal. `git worktree list --porcelain` is the read-only inventory -command; do not use `remove`, `prune`, or branch deletion as recovery steps. +Stop on overlap or unexpected movement. Preserve worktrees after merge or +abandonment until the repository owner explicitly authorizes exact cleanup; +never use removal, prune, reset, or branch deletion as recovery. diff --git a/.claude/scripts/pm/test-github-first-issue-mutations.sh b/.claude/scripts/pm/test-github-first-issue-mutations.sh index c18814e1c..62fb5ef6f 100755 --- a/.claude/scripts/pm/test-github-first-issue-mutations.sh +++ b/.claude/scripts/pm/test-github-first-issue-mutations.sh @@ -27,7 +27,9 @@ case "$*" in 'issue view 123 --json comments --jq'*) if [ "${GH_SCENARIO:-}" = runtime_comments ]; then echo 'Exact target: svc; Named controller: alice; Candidate identity: sha-1; Configuration identity: cfg-1; Rollback identity: sha-0; Rollback procedure: restore; Stop rules: any failure; Terminal result: passed; Cleanup evidence: removed'; fi ;; 'issue view 123 --json body,comments --jq'*) - if [ "${GH_SCENARIO:-}" = runtime_body_only ]; then echo 'Exact target: svc; Named controller: alice; Candidate identity: sha-1; Configuration identity: cfg-1; Rollback identity: sha-0; Rollback procedure: restore; Stop rules: any failure; Terminal result: passed; Cleanup evidence: removed'; fi ;; + case "${GH_SCENARIO:-}" in + runtime_body_only|runtime_comments) echo 'Exact target: svc; Named controller: alice; Candidate identity: sha-1; Configuration identity: cfg-1; Rollback identity: sha-0; Rollback procedure: restore; Stop rules: any failure; Terminal result: passed; Cleanup evidence: removed' ;; + esac ;; 'issue view 123 --json number,title,state,url') [ "${GH_SCENARIO:-}" != read_fail ] && echo '{"number":123}' ;; 'issue view 123 --json state,updatedAt,url --jq'*) [ "${GH_SCENARIO:-}" != reopen_final_read_fail ] || exit 1 @@ -74,13 +76,14 @@ refuse normal 'Acceptance checks: Result : passed; Result: passed' refuse normal 'Acceptance checks: - Result: passed; Result: passed' refuse normal 'Acceptance checks: ~~Result:~~ passed; Result: passed' refuse normal 'Acceptance checks: focused; Result: failed' -for scenario in blocker no_category conflict_category no_triage conflict_triage; do refuse "$scenario" "$valid"; done +refuse blocker "$valid" +for scenario in no_category conflict_category no_triage conflict_triage; do allow "$scenario" "$valid"; done refuse tracking "$valid" refuse tracking_child "$valid; Parent acceptance audit: passed" allow tracking_box "$valid; Parent acceptance audit: passed" runtime='Exact target: svc; Named controller: alice; Candidate identity: sha-1; Configuration identity: cfg-1; Rollback identity: sha-0; Rollback procedure: restore; Stop rules: any failure; Terminal result: passed; Cleanup evidence: removed' refuse runtime "$valid; ${runtime/Terminal result: passed/Terminal result: failed}" -refuse runtime_body_only "$valid" +allow runtime_body_only "$valid" for field in "Exact target" "Named controller" "Candidate identity" "Configuration identity" "Rollback identity" "Rollback procedure" "Stop rules" "Terminal result" "Cleanup evidence"; do missing=$(printf '%s' "$runtime" | sed -E "s/(^|; )$field: [^;]*(; |$)/\\1/") placeholder=$(printf '%s' "$runtime" | sed -E "s/($field: )[^;]*/\\1TBD/") diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 9fecf5f68..fd32adab4 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -1,32 +1,18 @@ name: Bug report -description: Report reproducible broken behavior for triage -labels: ["bug", "needs-triage"] +description: Record reproducible broken behavior +labels: ["bug"] body: - type: textarea - id: current_behavior + id: evidence attributes: - label: Current behavior - description: Describe what is broken and where it was observed. + label: Broken behavior and evidence + description: What happened, where, and the smallest repeatable observation. validations: required: true - type: textarea - id: reproduction + id: expected attributes: - label: Focused reproduction - description: Provide the smallest repeatable steps and exact evidence. - validations: - required: true - - type: textarea - id: expected_behavior - attributes: - label: Expected behavior - description: Describe the correct observable result. - validations: - required: true - - type: textarea - id: boundary - attributes: - label: Safety and scope boundary - description: Name affected trust domains and work that must remain unchanged. + label: Expected result + description: State the correct observable behavior. validations: required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8005e3226..64eb98dc3 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,2 +1,2 @@ -blank_issues_enabled: false +blank_issues_enabled: true contact_links: [] diff --git a/.github/ISSUE_TEMPLATE/engineering-change.yml b/.github/ISSUE_TEMPLATE/engineering-change.yml index 7ebe86683..5087ce2ab 100644 --- a/.github/ISSUE_TEMPLATE/engineering-change.yml +++ b/.github/ISSUE_TEMPLATE/engineering-change.yml @@ -1,39 +1,18 @@ name: Engineering change -description: Propose one independently mergeable and rollbackable improvement -labels: ["enhancement", "needs-triage"] +description: Record one independently verifiable improvement +labels: ["enhancement"] body: - type: textarea - id: contract + id: outcome attributes: - label: Change contract - description: State one behavior and why it is needed. + label: Outcome + description: What observable behavior should change, and why? validations: required: true - type: textarea - id: acceptance + id: proof attributes: - label: Acceptance evidence - description: List externally observable checks, including a safety counterexample where applicable. - validations: - required: true - - type: textarea - id: dependencies - attributes: - label: Dependencies and merge order - description: Use GitHub native blocked-by relationships after publication; write None when independent. - validations: - required: true - - type: textarea - id: out_of_scope - attributes: - label: Out of scope - description: Name adjacent behavior and trust domains excluded from this issue. - validations: - required: true - - type: textarea - id: rollout - attributes: - label: Rollout and rollback impact - description: State the production impact or None. + label: Proof + description: What smallest check will prove the outcome? validations: required: true diff --git a/.github/ISSUE_TEMPLATE/prd.yml b/.github/ISSUE_TEMPLATE/prd.yml index 72f3fb7ad..f925742c0 100644 --- a/.github/ISSUE_TEMPLATE/prd.yml +++ b/.github/ISSUE_TEMPLATE/prd.yml @@ -1,47 +1,26 @@ -name: Product requirement -description: Define a multi-step outcome before decomposition -title: "PRD: " -labels: ["enhancement", "needs-triage", "tracking"] +name: Product outcome +description: Coordinate a durable multi-owner or multi-session outcome +title: "Outcome: " +labels: ["enhancement", "tracking"] body: - type: textarea id: problem attributes: - label: Problem Statement - description: Describe the user-visible problem and why it matters. + label: Problem + description: What user-visible problem must be solved? validations: required: true - type: textarea - id: solution + id: outcome attributes: - label: Solution - description: Describe the outcome without prescribing speculative machinery. + label: Observable outcome + description: What result proves the problem is solved? validations: required: true - type: textarea - id: stories + id: constraints attributes: - label: User Stories - description: List the actors, capabilities, and benefits this outcome must cover. + label: Constraints + description: Record only real authority, safety, or compatibility boundaries. validations: - required: true - - type: textarea - id: decisions - attributes: - label: Implementation Decisions - description: Record approved boundaries, interfaces, and rollout units. - validations: - required: true - - type: textarea - id: testing - attributes: - label: Testing Decisions - description: Name the public seams and acceptance evidence. - validations: - required: true - - type: textarea - id: out_of_scope - attributes: - label: Out of Scope - description: Name adjacent work that this PRD does not authorize. - validations: - required: true + required: false diff --git a/.github/ISSUE_TEMPLATE/runtime-rollout.yml b/.github/ISSUE_TEMPLATE/runtime-rollout.yml index ae088f8f0..03b2dc14c 100644 --- a/.github/ISSUE_TEMPLATE/runtime-rollout.yml +++ b/.github/ISSUE_TEMPLATE/runtime-rollout.yml @@ -1,53 +1,46 @@ -name: Runtime rollout -description: Request one bounded live mutation or runtime-evidence contract -labels: ["enhancement", "needs-triage", "runtime"] +name: Runtime outcome +description: Request and record one bounded live outcome +labels: ["enhancement", "runtime"] body: - type: input id: target attributes: - label: Exact target identity - description: Name the bounded host, cluster resource, service, or repository setting. + label: Exact target validations: required: true - type: input id: controller attributes: - label: Named controller - description: Name the one authorized writer for this rollout. + label: One controller validations: required: true - type: textarea id: candidate attributes: - label: Candidate and configuration identity - description: Record immutable source, artifact/image/binary, and configuration identities. + label: Candidate identity validations: required: true - type: textarea - id: rollback + id: configuration attributes: - label: Rollback identity and procedure - description: Record the immutable previous state and bounded rollback action. + label: Configuration identity validations: required: true - type: textarea - id: stop_rules + id: rollback attributes: - label: Stop rules - description: Name failures that stop mutation or prevent promotion. + label: Rollback identity and procedure validations: required: true - type: textarea - id: success + id: stop attributes: - label: Success and cleanup evidence - description: Define independent readback, post-change observation, cleanup, and rollback-expiry evidence. + label: Stop and cleanup rules validations: required: true - type: textarea - id: out_of_scope + id: proof attributes: - label: Out of scope - description: Name adjacent resources, services, and trust domains that must not change. + label: Success and direct readback validations: required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 8349ce928..d40d260f3 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,34 +1,17 @@ -## Change contract +## Change - + ## Issue relationship - + None -## Out of scope +## Validation - + -## Dependencies and merge order +## Runtime impact and rollback - - -## Focused validation - - - -## Rollout and rollback - - - -## Scope exception - - + diff --git a/.github/scripts/issue-lifecycle-audit.rb b/.github/scripts/issue-lifecycle-audit.rb deleted file mode 100755 index d82416b1c..000000000 --- a/.github/scripts/issue-lifecycle-audit.rb +++ /dev/null @@ -1,514 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "json" -require "open3" -require "optparse" - -CATEGORIES = %w[bug enhancement].freeze -TRIAGE_STATES = %w[needs-triage needs-info ready-for-agent ready-for-human wontfix].freeze -RUNTIME_CONTROL_FIELDS = { - "Target" => /\A(?:[-*]\s*)?(?:\*\*)?(?:Target|Exact target identity)(?:\*\*)?\s*:\s*(.+)\z/i, - "Candidate" => /\A(?:[-*]\s*)?(?:\*\*)?(?:Candidate|Candidate and configuration identity)(?:\*\*)?\s*:\s*(.+)\z/i, - "Controller" => /\A(?:[-*]\s*)?(?:\*\*)?(?:Controller|Named controller)(?:\*\*)?\s*:\s*(.+)\z/i, - "Stop rule" => /\A(?:[-*]\s*)?(?:\*\*)?Stop rules?(?:\*\*)?\s*:\s*(.+)\z/i, - "Rollback" => /\A(?:[-*]\s*)?(?:\*\*)?(?:Rollback|Rollback identity)(?:\*\*)?\s*:\s*(.+)\z/i -}.freeze -CLOSING_KEYWORD_SOURCE = "(?:close[sd]?|fix(?:es|ed)?|resolve[sd]?)" -QUALIFIED_ISSUE_REFERENCE = "(?:([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+))?#(\\d+)\\b" -CLOSING_PATTERN = Regexp.new("\\b(#{CLOSING_KEYWORD_SOURCE})\\s*:?\\s+#{QUALIFIED_ISSUE_REFERENCE}", Regexp::IGNORECASE) -NEGATED_CLOSING_PATTERN = Regexp.new( - "\\b(?:(?:do(?:es)?|did|will|would|should|can|could|must)\\s+not|cannot|doesn't|don't|didn't|won't|can't|never|not)(?:\\s+[A-Za-z-]+){0,3}\\s+#{CLOSING_KEYWORD_SOURCE}\\s*:?\\s+#{QUALIFIED_ISSUE_REFERENCE}", - Regexp::IGNORECASE -) - -class GitHubReadOnly - API_VERSION = "2026-03-10" - - def initialize(repo) - raise "invalid repository #{repo.inspect}; expected OWNER/REPO" unless repo.to_s.match?(/\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/) - - @repo = repo - end - - attr_reader :repo - - def get(path, allow_404 = false) - stdout, stderr, status = Open3.capture3( - "gh", "api", "--method", "GET", - "-H", "Accept: application/vnd.github+json", - "-H", "X-GitHub-Api-Version: #{API_VERSION}", - path - ) - return nil if allow_404 && !status.success? && stderr.include?("HTTP 404") - raise "GitHub API GET #{path} failed: #{stderr.strip}" unless status.success? - - JSON.parse(stdout) - end - - def paginate(path) - page = 1 - items = [] - loop do - separator = path.include?("?") ? "&" : "?" - batch = get("#{path}#{separator}per_page=100&page=#{page}") - raise "GitHub API GET #{path} did not return an array" unless batch.is_a?(Array) - - items.concat(batch) - break if batch.length < 100 - - page += 1 - end - items - end -end -def markdown_section(markdown, title) - match = markdown.to_s.match(/^##(?:#)?[ \t]+#{Regexp.escape(title)}[ \t]*\r?\n(?
.*?)(?=^##(?:#)?[ \t]+|\z)/mi) - match && match[:body] -end - -def visible_markdown(body) - body.to_s.gsub(//m, "") -end - -def names(values) - Array(values).map { |value| value.is_a?(Hash) ? value["name"] || value["login"] : value }.compact -end - -def native_parent_number(issue) - parent = issue["parent"] - parent.is_a?(Hash) ? parent["number"] : parent -end - -def blockers(issue) - Array(issue["blocked_by"]).map do |blocker| - blocker.is_a?(Hash) ? blocker : { "number" => blocker, "state" => "open" } - end -end - -def summary_numbers(body, title) - section = markdown_section(visible_markdown(body), title) - section && section.scan(/#(\d+)/).flatten.map(&:to_i).uniq.sort -end - -def references(numbers) - Array(numbers).empty? ? "None" : Array(numbers).map { |number| "##{number}" }.join(", ") -end - -def invalid_control_value?(value) - value.to_s.strip.empty? || value.to_s.strip.match?(/\A(?:none|n\/a|tbd|unknown|-)\z/i) -end - -def issue_form_value(body, *titles) - titles.each do |title| - section = markdown_section(visible_markdown(body), title) - next unless section - - value = section.lines.map(&:strip).reject(&:empty?).join(" ") - return value unless value.empty? - end - - nil -end - -def hydrate_live_issue!(github, repo, issue) - return unless issue && !issue.key?("pull_request") - - parent_url = issue["parent_issue_url"].to_s - issue["parent"] = parent_url[/\/issues\/(\d+)\z/, 1]&.to_i - summary = issue["issue_dependencies_summary"] - issue["blocked_by"] = if summary && summary["total_blocked_by"].to_i.zero? - [] - else - github.paginate("repos/#{repo}/issues/#{issue.fetch("number")}/dependencies/blocked_by") - end -end - -def missing_runtime_control(body) - section = markdown_section(visible_markdown(body), "Runtime control") - unless section - field_values = { - "Target" => issue_form_value(body, "Exact target identity", "Target"), - "Candidate" => issue_form_value(body, "Candidate and configuration identity", "Candidate"), - "Controller" => issue_form_value(body, "Named controller", "Controller"), - "Stop rule" => issue_form_value(body, "Stop rules", "Stop rule"), - "Rollback" => issue_form_value(body, "Rollback identity and procedure", "Rollback identity", "Rollback") - } - return field_values.each_with_object([]) { |(name, value), missing| missing << name if invalid_control_value?(value) } - end - - lines = section.lines.map(&:strip).reject(&:empty?) - RUNTIME_CONTROL_FIELDS.each_with_object([]) do |(name, pattern), missing| - value = lines.map { |line| line.match(pattern) }.compact.map { |match| match[1].strip }.first - missing << name if invalid_control_value?(value) - end -end - -def literal_escaped_newline_artifact?(body) - text = body.to_s - return false unless text.include?("\\n") - - !text.include?("\n") || text.match?(/(?:\A|\n)[#]{1,6}[ \t]+[^\\\r\n]+\\n(?:\\n)?/) -end - -def active_owner_violation(issue) - assignee_count = names(issue["assignees"]).length - return if assignee_count == 1 - - "Issue ##{issue.fetch("number")}: active implementation requires exactly one assignee; found #{assignee_count}" -end - -def audit_issue(issue, has_open_pr) - number = issue.fetch("number") - labels = names(issue["labels"]) - violations = [] - categories = labels & CATEGORIES - states = labels & TRIAGE_STATES - - violations << "Issue ##{number}: expected exactly one category label; found #{categories.empty? ? "none" : categories.join(", ")}" unless categories.length == 1 - violations << "Issue ##{number}: expected exactly one triage state label; found #{states.empty? ? "none" : states.join(", ")}" unless states.length == 1 - violations << "Issue ##{number}: body contains a literal escaped newline (\\n); publish multiline Markdown through a body file" if literal_escaped_newline_artifact?(issue["body"]) - violations << "Issue ##{number}: tracking issues cannot use ready-for-agent" if labels.include?("tracking") && labels.include?("ready-for-agent") - - if labels.include?("runtime") && labels.include?("ready-for-agent") - missing = missing_runtime_control(issue["body"]) - violations << "Issue ##{number}: runtime ready-for-agent is missing Runtime control: #{missing.join(", ")}" unless missing.empty? - open_blockers = blockers(issue).reject { |blocker| blocker["state"].to_s.downcase == "closed" }.map { |blocker| blocker["number"] } - violations << "Issue ##{number}: runtime ready-for-agent has open native blocker #{references(open_blockers)}" unless open_blockers.empty? - end - - owner_violation = active_owner_violation(issue) if has_open_pr - violations << owner_violation if owner_violation - - parent_summary = summary_numbers(issue["body"], "Parent") - native_parent = native_parent_number(issue) - native_parents = native_parent ? [native_parent] : [] - if parent_summary && parent_summary != native_parents - violations << "Issue ##{number}: Parent summary references #{references(parent_summary)}; native parent is #{references(native_parents)}" - elsif !parent_summary && !native_parents.empty? - violations << "Issue ##{number}: Parent summary is missing; native parent is #{references(native_parents)}" - end - - blocker_summary = summary_numbers(issue["body"], "Blocked by") - native_blockers = blockers(issue).map { |blocker| blocker["number"] }.compact.uniq.sort - if blocker_summary && blocker_summary != native_blockers - violations << "Issue ##{number}: Blocked by summary references #{references(blocker_summary)}; native blocked-by is #{references(native_blockers)}" - elsif !blocker_summary && !native_blockers.empty? - violations << "Issue ##{number}: Blocked by summary is missing; native blocked-by is #{references(native_blockers)}" - end - - violations -end - -def visible_relationship(body) - section = markdown_section(visible_markdown(body), "Issue relationship") - lines = section.to_s.lines.map(&:strip).reject(&:empty?) - return nil unless lines.length == 1 - return { "kind" => "none" } if lines.first == "None" - - match = lines.first.match(/\A(Refs|Closes) #(\d+)\z/) - match && { "kind" => match[1].downcase, "number" => match[2].to_i } -end - -def relationship_numbers(body) - section = markdown_section(visible_markdown(body), "Issue relationship") - section.to_s.scan(/\b(?:Refs|Closes) #(\d+)\b/).flatten.map(&:to_i).uniq -end - -def closing_keywords(text) - text.to_s.to_enum(:scan, CLOSING_PATTERN).map do - match = Regexp.last_match - { "keyword" => match[1], "repository" => match[2], "number" => match[3].to_i } - end -end - -def closing_description(closing) - target = closing["repository"] ? "#{closing["repository"]}##{closing["number"]}" : "##{closing["number"]}" - "#{closing["keyword"]} #{target}" -end - -def closing_targets_issue?(closing, number, repo) - return false unless closing["number"] == number - - !closing["repository"] || (repo && closing["repository"].casecmp?(repo)) -end - -def relationship_description(relationship) - return "None" if relationship["kind"] == "none" - - "#{relationship["kind"] == "refs" ? "Refs" : "Closes"} ##{relationship["number"]}" -end - -def audit_pull_request(pull_request, issues, default_branch, repo) - number = pull_request.fetch("number") - relationship = visible_relationship(pull_request["body"]) - unless relationship - return ["PR ##{number}: expected exactly one visible issue relationship (Refs #N, Closes #N, or None)"] - end - - violations = [] - visible_body = visible_markdown(pull_request["body"]) - body_closings = closing_keywords(visible_body) - title = pull_request["title"].to_s - title_closings = closing_keywords(title) - violations << "PR ##{number} body: negated closing phrase is forbidden" if visible_body.match?(NEGATED_CLOSING_PATTERN) - violations << "PR ##{number} title: negated closing phrase is forbidden" if title.match?(NEGATED_CLOSING_PATTERN) - - if relationship["kind"] == "closes" - expected_number = relationship["number"] - unexpected = body_closings.reject { |closing| closing_targets_issue?(closing, expected_number, repo) } - if unexpected.any? || body_closings.length != 1 - violations << "PR ##{number} body: visible Closes ##{expected_number} must be the only closing keyword relationship" - end - unexpected_title = title_closings.reject { |closing| closing_targets_issue?(closing, expected_number, repo) } - if unexpected_title.any? || title_closings.length > 1 - violations << "PR ##{number} title: visible Closes ##{expected_number} must be the only closing keyword relationship" - end - else - body_closings.each do |closing| - violations << "PR ##{number} body: closing keyword #{closing_description(closing)} requires visible Closes ##{closing["number"]}" - end - title_closings.each do |closing| - violations << "PR ##{number} title: closing keyword #{closing_description(closing)} requires visible Closes ##{closing["number"]}" - end - end - - target = relationship["number"] && issues[relationship["number"]] - if relationship["number"] && !target - violations << "PR ##{number}: #{relationship_description(relationship)} references an issue unavailable to the audit" - end - - if relationship["kind"] == "closes" - base = pull_request["base"].is_a?(Hash) ? pull_request.dig("base", "ref") : pull_request["base"] - violations << "PR ##{number}: Closes ##{relationship["number"]} targets #{base}, not default branch #{default_branch}" if base != default_branch - target_labels = target ? names(target["labels"]) : [] - violations << "PR ##{number}: runtime issue ##{relationship["number"]} cannot be closed by a pull request" if target_labels.include?("runtime") - violations << "PR ##{number}: tracking issue ##{relationship["number"]} cannot be closed by a pull request" if target_labels.include?("tracking") - end - - commits = Array(pull_request["commits"]) - expected_commit_count = pull_request["expected_commit_count"] - if expected_commit_count && commits.length != expected_commit_count - violations << "PR ##{number}: fetched #{commits.length} of #{expected_commit_count} commit messages; audit cannot prove every commit safe" - end - commits.each_with_index do |commit, index| - message = commit["message"] || commit.dig("commit", "message") || "" - identity = commit["sha"].to_s.empty? ? (index + 1).to_s : commit["sha"].to_s[0, 12] - violations << "PR ##{number} commit #{identity}: negated closing phrase is forbidden" if message.match?(NEGATED_CLOSING_PATTERN) - closing_keywords(message).each do |closing| - if relationship["kind"] != "closes" - violations << "PR ##{number} commit #{identity}: closing keyword #{closing_description(closing)} requires visible Closes ##{closing["number"]}" - elsif !closing_targets_issue?(closing, relationship["number"], repo) - violations << "PR ##{number} commit #{identity}: closing keyword #{closing_description(closing)} conflicts with visible Closes ##{relationship["number"]}" - end - end - end - - violations -end - -def cross_pr_double_close_violations(pull_requests) - closers_by_issue = {} - pull_requests.each do |pull_request| - relationship = visible_relationship(pull_request["body"]) - next unless relationship && relationship["kind"] == "closes" - - number = relationship["number"] - pr_number = pull_request.fetch("number") - (closers_by_issue[number] ||= []) << pr_number - end - - closers_by_issue.each_with_object([]) do |(issue_number, pr_numbers), violations| - next unless pr_numbers.length > 1 - - violations << "Issue ##{issue_number} is claimed closed by multiple open pull requests: PR ##{pr_numbers.sort.join(", #")}; exactly one may use Closes" - end -end - -EVIDENCE_SECTION_HEADINGS = [ - "Completion evidence", - "Runtime closure evidence", - "Runtime control" -].freeze - -def evidence_section_present?(body) - return false if body.nil? || body.empty? - - markdown = visible_markdown(body) - EVIDENCE_SECTION_HEADINGS.any? do |heading| - markdown.match?(/^(?:[#]{1,6}[ \t]+)?#{Regexp.escape(heading)}[ \t]*$/m) - end -end - -def comment_history_has_evidence?(comments) - # The issue API may return `comments` as an Integer count; hydrate_live_issue! - # replaces it with the comment array when available. Normalize both cases. - return false if comments.nil? || comments == 0 || (comments.is_a?(Array) && comments.empty?) - - return false unless comments.is_a?(Array) - - comments.any? do |comment| - body = comment.is_a?(Hash) ? comment["body"] : comment.to_s - evidence_section_present?(body) || body.to_s.include?("rollback identity") || body.to_s.include?("Named controller") - end -end - -def audit_closed_runtime_evidence(closed_runtime_issues) - closed_runtime_issues.each_with_object([]) do |issue, violations| - number = issue.fetch("number") - next if evidence_section_present?(issue["body"]) || comment_history_has_evidence?(issue["comments"]) - - violations << "Issue ##{number} (runtime, closed): missing completion/closure evidence (Target, Named controller, rollback identity, stop rules, terminal result) in body or comment history" - end -end - -def automatic_close_description(value, fixture) - return "enabled (fixture)" if fixture && value == true - return "disabled (fixture)" if fixture && value == false - return "#{value} (fixture)" if fixture && !value.nil? - - "unknown/unavailable via documented API" -end - -def load_live(repo, pr_number) - github = GitHubReadOnly.new(repo) - repository = github.get("repos/#{repo}") - entries = github.paginate("repos/#{repo}/issues?state=open") - issue_entries = entries.reject { |entry| entry.key?("pull_request") } - pull_entries = entries.select { |entry| entry.key?("pull_request") } - closed_runtime_entries = [] - unless pr_number - recent_closed = github.paginate("repos/#{repo}/issues?state=closed&sort=updated&direction=desc") - closed_issue_entries = recent_closed.reject { |entry| entry.key?("pull_request") } - closed_runtime_entries = closed_issue_entries.select do |entry| - names(entry["labels"]).include?("runtime") - end - closed_runtime_entries.each do |entry| - hydrate_live_issue!(github, repo, entry) - count = entry["comments"].to_i - entry["comments"] = if count.positive? - github.paginate("repos/#{repo}/issues/#{entry.fetch("number")}/comments") - else - [] - end - end - end - if pr_number - pull_entries = pull_entries.select { |entry| entry["number"] == pr_number } - raise "open PR ##{pr_number} was not returned by /issues" if pull_entries.empty? - end - - pull_requests = pull_entries.map do |entry| - pull_request = github.get("repos/#{repo}/pulls/#{entry.fetch("number")}") - pull_request["expected_commit_count"] = pull_request["commits"] - pull_request["commits"] = github.paginate("repos/#{repo}/pulls/#{entry.fetch("number")}/commits") - pull_request - end - - known = issue_entries.each_with_object({}) { |issue, by_number| by_number[issue.fetch("number")] = issue } - linked_issue_numbers = pull_requests.flat_map { |pull_request| relationship_numbers(pull_request["body"]) }.uniq - audited_issue_numbers = pr_number ? linked_issue_numbers : issue_entries.map { |issue| issue.fetch("number") } - linked_issue_numbers.each do |number| - next if known.key?(number) - - issue = github.get("repos/#{repo}/issues/#{number}", true) - known[number] = issue if issue && !issue.key?("pull_request") - end - audited_issue_numbers.each { |number| hydrate_live_issue!(github, repo, known[number]) } - - { - "source" => "live", - "repo" => repo, - "scope" => pr_number ? "pull request ##{pr_number}" : "all open issues and pull requests", - "default_branch" => repository.fetch("default_branch"), - "automatic_linked_issue_closing" => nil, - "issues" => known.values, - "audited_issue_numbers" => audited_issue_numbers, - "pull_requests" => pull_requests, - "closed_runtime_issues" => closed_runtime_entries - } -end - -def render(data, label, violations, fixture) - status = violations.empty? ? "PASS" : "FAIL" - lines = [ - "#{status} #{label}", - "# Issue Lifecycle Audit", - "", - "- Scope: #{data["scope"] || "fixture case #{label}"}", - "- Repository: #{data["repo"] || "fixture"}", - "- Default branch: `#{data.fetch("default_branch")}`", - "- automatic-linked-issue-closing: #{automatic_close_description(data["automatic_linked_issue_closing"], fixture)}", - "- Result: **#{status}** (#{violations.length} violation#{violations.length == 1 ? "" : "s"})" - ] - if violations.empty? - lines.concat(["", "No lifecycle violations found."]) - else - lines.concat(["", "## Violations", ""]) - violations.each_with_index { |violation, index| lines << "#{index + 1}. #{violation}" } - end - lines.join("\n") + "\n" -end - -options = {} -parser = OptionParser.new do |parser| - parser.banner = "Usage: issue-lifecycle-audit.rb [--repo OWNER/REPO [--pr N] | --fixture FILE --case NAME]" - parser.on("--repo OWNER/REPO", "Audit live GitHub data") { |value| options[:repo] = value } - parser.on("--pr NUMBER", Integer, "Audit only one open pull request") { |value| options[:pr] = value } - parser.on("--fixture FILE", "Read a fixture matrix instead of GitHub") { |value| options[:fixture] = value } - parser.on("--case NAME", "Audit one named fixture case") { |value| options[:case] = value } - parser.on("--summary FILE", "Append Markdown output to FILE") { |value| options[:summary] = value } -end - -begin - parser.parse! - fixture = !!options[:fixture] - if fixture - raise "--fixture requires --case" unless options[:case] - raise "--fixture cannot be combined with --repo or --pr" if options[:repo] || options[:pr] - matrix = JSON.parse(File.read(options[:fixture])) - data = matrix.fetch("cases").fetch(options[:case]) - label = options[:case] - else - repo = options[:repo] || ENV["GITHUB_REPOSITORY"] - raise "--repo OWNER/REPO is required outside GitHub Actions" unless repo - data = load_live(repo, options[:pr]) - label = options[:pr] ? "PR ##{options[:pr]}" : "live" - end - - violations = [] - open_pr_issue_numbers = data.fetch("pull_requests", []).flat_map { |pull_request| relationship_numbers(pull_request["body"]) }.uniq - audited_issue_numbers = data["audited_issue_numbers"] || data.fetch("issues", []).map { |issue| issue.fetch("number") } - data.fetch("issues", []).each do |issue| - next unless audited_issue_numbers.include?(issue.fetch("number")) - - violations.concat(audit_issue(issue, open_pr_issue_numbers.include?(issue.fetch("number")))) - end - data.fetch("issues", []).each do |issue| - number = issue.fetch("number") - next if audited_issue_numbers.include?(number) || !open_pr_issue_numbers.include?(number) - - owner_violation = active_owner_violation(issue) - violations << owner_violation if owner_violation - end - - issues = data.fetch("issues", []).each_with_object({}) do |issue, by_number| - by_number[issue.fetch("number")] = issue - end - data.fetch("pull_requests", []).each do |pull_request| - violations.concat(audit_pull_request(pull_request, issues, data.fetch("default_branch"), data["repo"])) - end - - violations.concat(cross_pr_double_close_violations(data.fetch("pull_requests", []))) - violations.concat(audit_closed_runtime_evidence(data.fetch("closed_runtime_issues", []))) - - output = render(data, label, violations, fixture) - File.open(options[:summary], "a") { |file| file.write(output) } if options[:summary] - puts output - exit(violations.empty? ? 0 : 1) -rescue StandardError => error - output = "ERROR issue lifecycle audit\n# Issue Lifecycle Audit\n\n- Result: **ERROR**\n- #{error.message}\n" - warn output - File.open(options[:summary], "a") { |file| file.write(output) } if options[:summary] rescue nil - exit 2 -end diff --git a/.github/scripts/issue-lifecycle-preflight.rb b/.github/scripts/issue-lifecycle-preflight.rb deleted file mode 100755 index 03cf61d59..000000000 --- a/.github/scripts/issue-lifecycle-preflight.rb +++ /dev/null @@ -1,1313 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "digest" -require "fileutils" -require "json" -require "open3" -require "optparse" -require "time" -require "tmpdir" -require "uri" - -EVIDENCE_EXCLUSIONS = ["GitHub metadata mutation", "branch protection and required checks", "deployment and runtime resources", "source code and Agent-led research PRD scope"].freeze -PREFLIGHT_SCHEMA = "monday.issue_lifecycle_preflight.v1" -MANIFEST_SCHEMA = "monday.issue_lifecycle_manifest.v1" -FORWARD_PLAN_SCHEMA = "monday.issue_lifecycle_forward_plan.v1" -REVERSE_PLAN_SCHEMA = "monday.issue_lifecycle_reverse_plan.v1" -RECEIPT_SCHEMA = "monday.issue_lifecycle_receipt.v1" -BUNDLE_FILES = %w[manifest.json manifest.json.sha256 preflight.json preflight.json.sha256].freeze -PAGE_KEYS = %w[api_version body_sha256 etag last_modified link media_type phase protocol request status].freeze -PLAN_STATE_KEYS = %w[assignees blocked_by body labels parent state].freeze -DERIVED_ISSUE_METADATA_KEYS = %w[assignee assignees body closed_at closed_by comments issue_dependencies_summary labels parent_issue_url state state_reason sub_issues_summary updated_at].freeze - -module Canonical - PRESERVE_ARRAY_ORDER = %w[comments commits events].freeze - IDENTITY_KEYS = %w[number id node_id sha filename name login context].freeze - - module_function - - def value(object, parent_key = nil) - case object - when Hash - object.keys.sort.each_with_object({}) do |key, result| - result[key] = value(object.fetch(key), key) - end - when Array - values = object.map { |entry| value(entry) } - return values if PRESERVE_ARRAY_ORDER.include?(parent_key) - - values.sort_by { |entry| array_sort_key(entry) } - else - object - end - end - - def dump(object); JSON.generate(value(object)) + "\n"; end - - def array_sort_key(entry) - if entry.is_a?(Hash) - key = IDENTITY_KEYS.find { |candidate| entry.key?(candidate) } - return [key, entry.fetch(key).to_s, JSON.generate(entry)] if key - end - ["", "", JSON.generate(entry)] - end -end - -class GitHubReadOnly - API_VERSION = "2026-03-10" - REST_ACCEPT = "application/vnd.github+json" - RELATIONSHIP_QUERY = <<~GRAPHQL.freeze - query IssueLifecycleRelationships($owner: String!, $name: String!, $cursor: String) { - repository(owner: $owner, name: $name) { - id - nameWithOwner - defaultBranchRef { name target { ... on Commit { oid } } } - issues(first: 50, after: $cursor, states: [OPEN, CLOSED], orderBy: {field: CREATED_AT, direction: ASC}) { - totalCount - nodes { - number - parent { number url repository { nameWithOwner } } - subIssues(first: 100) { totalCount nodes { number url repository { nameWithOwner } } pageInfo { hasNextPage } } - blockedBy(first: 100) { totalCount nodes { number url repository { nameWithOwner } } pageInfo { hasNextPage } } - blocking(first: 100) { totalCount nodes { number url repository { nameWithOwner } } pageInfo { hasNextPage } } - closedByPullRequestsReferences(first: 100) { - totalCount - nodes { number url repository { nameWithOwner } } - pageInfo { hasNextPage } - } - } - pageInfo { hasNextPage endCursor } - } - } - } - GRAPHQL - - attr_reader :pages - - def self.page_path(path, page) - separator = path.include?("?") ? "&" : "?" - "#{path}#{separator}per_page=100&page=#{page}" - end - - def self.link_relations(header) - return {} unless header - - header.split(",").each_with_object({}) do |part, relations| - match = part.match(/\A\s*<([^>]+)>;\s*rel="([^"]+)"\s*\z/) - raise "invalid pagination Link header" unless match - - match[2].split.each do |relation| - raise "duplicate pagination Link relation #{relation}" if relations.key?(relation) - - relations[relation] = api_path(match[1]) - end - end - end - - def self.api_path(url) - uri = URI(url) - raise "pagination link uses unexpected host #{uri.host.inspect}" unless uri.host == "api.github.com" - - path = uri.path.sub(%r{\A/}, "") - uri.query ? "#{path}?#{uri.query}" : path - end - - def initialize(repo) - unless repo.to_s.match?(/\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/) - raise "invalid repository #{repo.inspect}; expected OWNER/REPO" - end - - @repo = repo - @pages = [] - @phase = "capture" - end - - def with_phase(phase) - previous = @phase - @phase = phase - yield - ensure - @phase = previous - end - - def get(path) - body, headers, status = request( - "gh", "api", "--method", "GET", "--include", - "-H", "Accept: #{REST_ACCEPT}", - "-H", "X-GitHub-Api-Version: #{API_VERSION}", - path - ) - selected = headers["x-github-api-version-selected"] - raise "GitHub selected API version #{selected.inspect}, expected #{API_VERSION}" if selected && selected != API_VERSION - - record_page("rest", path, body, headers, status) - [body, headers] - end - - def paginate(path, collection_key: nil) - request_path = self.class.page_path(path, 1) - seen_requests = {} - seen_objects = {} - items = [] - expected_total = nil - page = 1 - - loop do - raise "pagination loop at #{request_path}" if seen_requests[request_path] - - seen_requests[request_path] = true - body, headers = get(request_path) - batch = collection_key ? body.fetch(collection_key) : body - raise "GitHub API GET #{request_path} did not return an array" unless batch.is_a?(Array) - - if collection_key - total = Integer(body.fetch("total_count")) - expected_total ||= total - raise "pagination total changed for #{path}: #{expected_total} to #{total}" unless total == expected_total - end - - batch.each do |entry| - identity = object_identity(entry) - raise "duplicate paginated object #{identity} from #{path}" if identity && seen_objects[identity] - - seen_objects[identity] = true if identity - items << entry - end - - next_path = self.class.link_relations(headers["link"])["next"] - break if !next_path && (batch.length < 100 || expected_total == items.length) - - page += 1 - request_path = next_path || self.class.page_path(path, page) - end - - if expected_total && items.length != expected_total - raise "incomplete pagination for #{path}: expected #{expected_total}, fetched #{items.length}" - end - - collection_key ? { "total_count" => expected_total, collection_key => items } : items - end - - def relationships - owner, name = @repo.split("/", 2) - cursor = nil - nodes = [] - expected_total = nil - seen_numbers = {} - repository_identity = nil - - loop do - command = [ - "gh", "api", "graphql", "--include", - "-f", "query=#{RELATIONSHIP_QUERY}", - "-F", "owner=#{owner}", "-F", "name=#{name}" - ] - command.concat(["-F", "cursor=#{cursor}"]) if cursor - body, headers, status = request(*command) - raise "GitHub GraphQL returned errors: #{body.fetch("errors").inspect}" if body.key?("errors") - - repository = body.dig("data", "repository") - identity = { - "id" => repository && repository["id"], - "full_name" => repository && repository["nameWithOwner"], - "default_branch" => repository && repository.dig("defaultBranchRef", "name"), - "default_branch_sha" => repository && repository.dig("defaultBranchRef", "target", "oid") - } - raise "GitHub GraphQL repository identity drift" if repository_identity && repository_identity != identity - - repository_identity = identity - connection = repository && repository["issues"] - raise "GitHub GraphQL omitted repository issue relationships" unless connection.is_a?(Hash) - - expected_total ||= connection["totalCount"] && Integer(connection["totalCount"]) - batch = connection.fetch("nodes") - raise "GitHub GraphQL relationship nodes are not an array" unless batch.is_a?(Array) - - batch.each do |node| - number = Integer(node.fetch("number")) - raise "duplicate GraphQL issue ##{number}" if seen_numbers[number] - - validate_nested_connections!(node, number) - seen_numbers[number] = true - nodes << normalize_relationships(node) - end - - label = "IssueLifecycleRelationships:#{cursor || "START"}" - record_page("graphql", label, body, headers, status) - page_info = connection.fetch("pageInfo") - break unless page_info.fetch("hasNextPage") - - cursor = page_info["endCursor"] - raise "GraphQL relationship pagination omitted endCursor" if cursor.to_s.empty? - end - - if expected_total && nodes.length != expected_total - raise "incomplete GraphQL issue pagination: expected #{expected_total}, fetched #{nodes.length}" - end - unless repository_identity["full_name"] == @repo && - repository_identity["default_branch_sha"].to_s.match?(/\A[0-9a-f]{40}\z/i) - raise "GitHub GraphQL returned invalid repository identity" - end - relationships = nodes.sort_by { |node| node.fetch("number") }.to_h { |node| [node.fetch("number"), node] } - [repository_identity, relationships] - end - - def assert_rest_unchanged!(pages) - pages.select { |page| page["protocol"] == "rest" }.each do |page| - etag = page["etag"] - raise "capture stability drift: endpoint #{page["request"]} omitted ETag" if etag.to_s.empty? - - _, headers, status = request( - "gh", "api", "--method", "GET", "--include", - "-H", "Accept: #{REST_ACCEPT}", "-H", "X-GitHub-Api-Version: #{API_VERSION}", - "-H", "If-None-Match: #{etag}", page["request"], not_modified: true - ) - unless status == 304 && headers["etag"] == etag - raise "capture stability drift: endpoint/header #{page["request"]} changed during readback" - end - @pages << page.merge("phase" => @phase, "status" => status, "etag" => headers["etag"], "last_modified" => headers["last-modified"], "link" => headers["link"], "api_version" => headers["x-github-api-version-selected"], "media_type" => headers["x-github-media-type"]) - end - end - - private - - def request(*command, not_modified: false) - stdout, stderr, status = Open3.capture3(*command) - allowed_304 = not_modified && stdout.gsub("\r\n", "\n").match?(/^HTTP\/[0-9.]+ 304 /) - raise "#{command.first(3).join(" ")} failed: #{stderr.strip}" unless status.success? || allowed_304 - - response_status, headers, body_text = parse_http(stdout, allow_empty: not_modified) - unless response_status.between?(200, 299) || (not_modified && response_status == 304) - raise "GitHub read returned HTTP #{response_status}: #{body_text.strip}" - end - [response_status == 304 ? nil : JSON.parse(body_text), headers, response_status] - rescue JSON::ParserError => error - raise "GitHub read returned invalid JSON: #{error.message}" - end - - def parse_http(output, allow_empty: false) - normalized = output.gsub("\r\n", "\n") - start = normalized.rindex(/^HTTP\/[0-9.]+ [0-9]{3}.*$/) - raise "GitHub read omitted HTTP response headers" unless start - - header_text, body = normalized[start..].split("\n\n", 2) - raise "GitHub read omitted a response body" unless body || allow_empty - - lines = header_text.lines(chomp: true) - status = Integer(lines.shift.match(/\AHTTP\/[0-9.]+ ([0-9]{3})/)[1]) - headers = lines.each_with_object({}) do |line, result| - name, value = line.split(":", 2) - next unless value - - result[name.downcase] = value.strip - end - [status, headers, body.to_s] - end - - def record_page(protocol, request_name, body, headers, status) - @pages << { - "phase" => @phase, - "protocol" => protocol, - "request" => request_name, - "status" => status, - "etag" => headers["etag"], - "last_modified" => headers["last-modified"], - "link" => headers["link"], - "api_version" => headers["x-github-api-version-selected"], - "media_type" => headers["x-github-media-type"], - "body_sha256" => Digest::SHA256.hexdigest(Canonical.dump(body)) - } - end - - def object_identity(entry) - return unless entry.is_a?(Hash) - - %w[id node_id filename sha number].each do |key| - return "#{key}:#{entry.fetch(key)}" if entry.key?(key) - end - nil - end - - def validate_nested_connections!(node, number) - complete = ->(related) { related.key?("number") && !related["url"].to_s.empty? && !related.dig("repository", "nameWithOwner").to_s.empty? } - raise "Issue ##{number} parent relationship identity is incomplete" if node["parent"] && !complete.call(node["parent"]) - %w[subIssues blockedBy blocking closedByPullRequestsReferences].each do |key| - connection = node.fetch(key) - raise "Issue ##{number} #{key} pagination is incomplete" if connection.dig("pageInfo", "hasNextPage") - raise "Issue ##{number} #{key} relationship identity is incomplete" unless connection.fetch("nodes").all?(&complete) - unless Integer(connection.fetch("totalCount")) == connection.fetch("nodes").length - raise "Issue ##{number} #{key} count does not match returned nodes" - end - end - end - - def normalize_relationships(node) - { - "number" => Integer(node.fetch("number")), - "parent" => node["parent"], - "sub_issues" => node.dig("subIssues", "nodes"), - "blocked_by" => node.dig("blockedBy", "nodes"), - "blocking" => node.dig("blocking", "nodes"), - "closed_by_pull_requests" => node.dig("closedByPullRequestsReferences", "nodes") - } - end -end - -def issue_number_from_url(url) - match = url.to_s.match(%r{/issues/([0-9]+)\z}) - match && Integer(match[1]) -end - -def group_issue_objects(objects, known_numbers, field) - objects.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |object, grouped| - number = object.dig("issue", "number") || issue_number_from_url(object["issue_url"]) - raise "#{field} object #{object["id"].inspect} has no Issue number" unless number - raise "#{field} object references unknown Issue ##{number}" unless known_numbers.include?(number) - - grouped[number] << object - end -end - -def read_graph(github, repo) - labels = github.paginate("repos/#{repo}/labels") - entries = github.paginate("repos/#{repo}/issues?state=all&sort=created&direction=asc") - numbers = entries.map { |entry| Integer(entry.fetch("number")) } - raise "duplicate Issue/PR number in repository listing" unless numbers.uniq.length == numbers.length - - comments = github.paginate("repos/#{repo}/issues/comments?sort=created&direction=asc") - events = github.paginate("repos/#{repo}/issues/events") - comments_by_number = group_issue_objects(comments, numbers, "comment") - events_by_number = group_issue_objects(events, numbers, "event") - repository, relationships = github.relationships - default_branch = repository.fetch("default_branch") - default_branch_sha = repository.fetch("default_branch_sha") - issue_numbers = entries.reject { |entry| entry.key?("pull_request") }.map { |entry| Integer(entry.fetch("number")) }.sort - unless relationships.keys.sort == issue_numbers - raise "GraphQL issue relationship set does not match REST issues" - end - - items = entries.sort_by { |entry| Integer(entry.fetch("number")) }.map do |entry| - number = Integer(entry.fetch("number")) - metadata, = github.get("repos/#{repo}/issues/#{number}") - raise "Issue API returned mismatched number for ##{number}" unless Integer(metadata.fetch("number")) == number - unless Integer(metadata.fetch("comments")) == comments_by_number[number].length - raise "incomplete comments for Issue/PR ##{number}" - end - - item = { - "number" => number, - "kind" => entry.key?("pull_request") ? "pull_request" : "issue", - "issue" => metadata, - "comments" => comments_by_number[number], - "events" => events_by_number[number] - } - item["relationships"] = relationships.fetch(number) unless entry.key?("pull_request") - item["pull_request"] = capture_pull_request(github, repo, number) if entry.key?("pull_request") - item - end - - graph = { - "schema" => PREFLIGHT_SCHEMA, - "repository" => repository, - "label_catalog" => labels, - "items" => items - } - counts = { - "issues" => issue_numbers.length, - "pull_requests" => entries.length - issue_numbers.length, - "labels" => labels.length, - "issue_comments" => comments.length, - "issue_events" => events.length - } - [graph, counts, default_branch, default_branch_sha, relationships] -end - -def capture_graph(repo) - github = GitHubReadOnly.new(repo) - graph, counts, default_branch, default_branch_sha, relationships = read_graph(github, repo) - primary_pages = github.pages.dup - stable_repository, stable_relationships = github.with_phase("stability_check") do - result = github.relationships - github.assert_rest_unchanged!(primary_pages) - result - end - raise "capture stability drift: relationships changed during readback" unless Canonical.value([graph["repository"], relationships]) == Canonical.value([stable_repository, stable_relationships]) - page_key = ->(page) { [page["protocol"], page["request"]] } - raise "capture stability drift: endpoint set changed during readback" unless primary_pages.map(&page_key).sort == github.pages.drop(primary_pages.length).map(&page_key).sort - [graph, counts, default_branch, default_branch_sha, github.pages] -end - -def capture_pull_request(github, repo, number) - metadata, = github.get("repos/#{repo}/pulls/#{number}") - raise "pull request API returned mismatched number for ##{number}" unless Integer(metadata.fetch("number")) == number - - commits = github.paginate("repos/#{repo}/pulls/#{number}/commits") - files = github.paginate("repos/#{repo}/pulls/#{number}/files") - review_comments = github.paginate("repos/#{repo}/pulls/#{number}/comments") - if commits.length != Integer(metadata.fetch("commits")) - raise "incomplete commits for PR ##{number}: expected #{metadata.fetch("commits")}, fetched #{commits.length}" - end - if files.length != Integer(metadata.fetch("changed_files")) - raise "incomplete files for PR ##{number}: expected #{metadata.fetch("changed_files")}, fetched #{files.length}" - end - if review_comments.length != Integer(metadata.fetch("review_comments")) - raise "incomplete review comments for PR ##{number}" - end - - head_sha = metadata.dig("head", "sha") - raise "PR ##{number} returned invalid head SHA #{head_sha.inspect}" unless head_sha.to_s.match?(/\A[0-9a-f]{40}\z/i) - - { - "metadata" => metadata, - "commits" => commits, - "files" => files, - "reviews" => github.paginate("repos/#{repo}/pulls/#{number}/reviews"), - "review_comments" => review_comments, - "check_runs" => github.paginate("repos/#{repo}/commits/#{head_sha}/check-runs?filter=all", collection_key: "check_runs"), - "statuses" => github.paginate("repos/#{repo}/commits/#{head_sha}/statuses") - } -end - -def write_bundle(output, graph, manifest) - raise "output already exists: #{output}" if File.exist?(output) || File.symlink?(output) - - parent = File.dirname(File.expand_path(output)) - raise "output parent is not a directory: #{parent}" unless File.directory?(parent) - - temporary = Dir.mktmpdir(".issue-lifecycle-preflight-", parent) - begin - preflight_json = Canonical.dump(graph) - preflight_sha = Digest::SHA256.hexdigest(preflight_json) - manifest["preflight"] = { "file" => "preflight.json", "sha256" => preflight_sha } - manifest_json = Canonical.dump(manifest) - - File.write(File.join(temporary, "preflight.json"), preflight_json, mode: "wb") - File.write(File.join(temporary, "preflight.json.sha256"), "#{preflight_sha} preflight.json\n", mode: "wb") - File.write(File.join(temporary, "manifest.json"), manifest_json, mode: "wb") - File.write( - File.join(temporary, "manifest.json.sha256"), - "#{Digest::SHA256.hexdigest(manifest_json)} manifest.json\n", - mode: "wb" - ) - File.rename(temporary, output) - ensure - FileUtils.remove_entry(temporary) if File.exist?(temporary) - end -end - -def parse_canonical_json(contents, name) - object = JSON.parse(contents) - raise "#{name} is not canonical JSON" unless contents.b == Canonical.dump(object).b - - object -rescue JSON::ParserError => error - raise "#{name} is invalid JSON: #{error.message}" -end - -def read_canonical_document(path) - contents = File.binread(path) - [parse_canonical_json(contents, File.basename(path)), contents, Digest::SHA256.hexdigest(contents)] -end - -def verify_sidecar(bundle, filename) - sidecar = "#{filename}.sha256" - sidecar_contents = File.binread(File.join(bundle, sidecar)) - match = sidecar_contents.match(/\A([0-9a-f]{64}) #{Regexp.escape(filename)}\n\z/) - raise "#{sidecar} is invalid" unless match - - contents = File.binread(File.join(bundle, filename)) - actual = Digest::SHA256.hexdigest(contents) - raise "#{filename} digest mismatch" unless actual == match[1] - - [contents, actual] -end - -def collection_scope(request, repo) - path, query = request.split("?", 2) - relative = path[%r{\Arepos/#{Regexp.escape(repo)}/(.+)\z}, 1] || path[%r{\Arepositories/[1-9][0-9]*/(.+)\z}, 1] - return unless relative - - filters = URI.decode_www_form(query.to_s).reject { |key, _| %w[after before page].include?(key) }.sort - [relative, filters] -rescue ArgumentError - nil -end - -def verify_page_inventory!(pages, graphql_media_type, graph, repo, link_provenance:) - raise "manifest page inventory is empty" unless pages.is_a?(Array) && !pages.empty? - - pages.each do |page| - unless page.is_a?(Hash) && page.keys.sort == PAGE_KEYS && - %w[capture stability_check].include?(page["phase"]) && - %w[graphql rest].include?(page["protocol"]) && - !page["request"].to_s.empty? && page["body_sha256"].to_s.match?(/\A[0-9a-f]{64}\z/) && - (page["link"].nil? || page["link"].is_a?(String)) - raise "manifest page inventory entry is invalid" - end - expected_status = page["phase"] == "stability_check" && page["protocol"] == "rest" ? 304 : 200..299 - raise "manifest page status is invalid" unless expected_status === page["status"] - end - - endpoint_set = lambda do |phase| - pages.select { |page| page["phase"] == phase }.map { |page| [page["protocol"], page["request"]] } - end - captured = endpoint_set.call("capture") - stable = endpoint_set.call("stability_check") - unless captured.any? && captured.uniq.length == captured.length && stable.uniq.length == stable.length && captured.sort == stable.sort - raise "manifest page inventory phases do not match" - end - captured_pages = pages.select { |page| page["phase"] == "capture" }.to_h { |page| [[page["protocol"], page["request"]], page] } - pages.select { |page| page["phase"] == "stability_check" }.each do |page| - original = captured_pages.fetch([page["protocol"], page["request"]]) - raise "manifest page body identity changed" unless page["body_sha256"] == original["body_sha256"] - next unless page["protocol"] == "rest" - - if original["etag"].to_s.empty? || page["etag"] != original["etag"] - raise "manifest REST ETag provenance is invalid" - end - end - - graphql_media_types = pages.select { |page| page["protocol"] == "graphql" }.map { |page| page["media_type"] } - unless graphql_media_types.any? && graphql_media_types.all? { |media_type| !media_type.to_s.empty? } && graphql_media_types.uniq == [graphql_media_type] - raise "manifest GraphQL media type does not match page inventory" - end - rest_pages = pages.select { |page| page["phase"] == "capture" && page["protocol"] == "rest" } - unless rest_pages.any? && rest_pages.all? { |page| page["api_version"] == GitHubReadOnly::API_VERSION && !page["media_type"].to_s.empty? } - raise "manifest REST provenance is incomplete" - end - - collections = [] - paginated = lambda do |path, count, known_total = false| - page_count = known_total ? [1, (count + 99) / 100].max : count / 100 + 1 - start = GitHubReadOnly.page_path(path, 1) - collections << [path, start, page_count, collection_scope(start, repo)] - end - items = graph.fetch("items") - expected_rest = [] - paginated.call("repos/#{repo}/labels", graph.fetch("label_catalog").length) - paginated.call("repos/#{repo}/issues?state=all&sort=created&direction=asc", items.length) - paginated.call("repos/#{repo}/issues/comments?sort=created&direction=asc", items.sum { |item| item.fetch("comments").length }) - paginated.call("repos/#{repo}/issues/events", items.sum { |item| item.fetch("events").length }) - items.each do |item| - number = item.fetch("number") - expected_rest << "repos/#{repo}/issues/#{number}" - next unless item.fetch("kind") == "pull_request" - - pull_request = item.fetch("pull_request") - head_sha = pull_request.dig("metadata", "head", "sha") - expected_rest << "repos/#{repo}/pulls/#{number}" - %w[commits files reviews].each do |field| - paginated.call("repos/#{repo}/pulls/#{number}/#{field}", pull_request.fetch(field).length) - end - paginated.call("repos/#{repo}/pulls/#{number}/comments", pull_request.fetch("review_comments").length) - paginated.call("repos/#{repo}/commits/#{head_sha}/check-runs?filter=all", pull_request.dig("check_runs", "total_count"), true) - paginated.call("repos/#{repo}/commits/#{head_sha}/statuses", pull_request.fetch("statuses").length) - end - rest_by_request = rest_pages.to_h { |page| [page.fetch("request"), page] } - collection_requests = collections.flat_map do |path, start, page_count, scope| - if link_provenance - current = start - (0...page_count).map do |index| - page = rest_by_request.fetch(current) { raise "manifest REST pagination chain is incomplete" } - relations = GitHubReadOnly.link_relations(page["link"]) - raise "manifest REST Link scope is invalid" unless relations.values.all? { |target| collection_scope(target, repo) == scope } - - if index + 1 == page_count - raise "manifest REST pagination chain has an extra page" if relations["next"] - else - current = relations["next"] || GitHubReadOnly.page_path(path, index + 2) - end - page.fetch("request") - end - else - raise "legacy manifest cannot verify multi-page REST provenance" if page_count > 1 - requests = rest_pages.each_with_object([]) do |page, result| - result << page.fetch("request") if collection_scope(page.fetch("request"), repo) == scope - end - raise "legacy manifest REST page inventory does not match preflight" unless requests == [start] - requests - end - end - raise "manifest REST page inventory overlaps collections" unless collection_requests.uniq.length == collection_requests.length - expected_rest.concat(collection_requests) - actual_rest = rest_pages.map { |page| page.fetch("request") } - raise "manifest REST page inventory does not match preflight" unless actual_rest.sort == expected_rest.sort - - if link_provenance - rest_pages.reject { |page| collection_requests.include?(page.fetch("request")) }.each do |page| - raise "manifest detail page unexpectedly has Link provenance" if page["link"] - end - end - - graphql_requests = pages.select { |page| page["phase"] == "capture" && page["protocol"] == "graphql" }.map { |page| page.fetch("request") } - issue_count = items.count { |item| item.fetch("kind") == "issue" } - graphql_page_count = [1, (issue_count + 49) / 50].max - graphql_prefix = "IssueLifecycleRelationships:" - unless graphql_requests.length == graphql_page_count && graphql_requests.count("#{graphql_prefix}START") == 1 && - graphql_requests.all? { |request| request.start_with?(graphql_prefix) && request.length > graphql_prefix.length } - raise "manifest GraphQL page inventory does not match preflight" - end -end - -def relationship_reference?(reference, kind) - return false unless reference.is_a?(Hash) && reference.keys.sort == %w[number repository url] && - reference["number"].is_a?(Integer) && reference["number"].positive? && reference["repository"].is_a?(Hash) && - reference["repository"].keys == ["nameWithOwner"] - repo = reference.dig("repository", "nameWithOwner") - url = URI(reference["url"].to_s) - repo.to_s.match?(%r{\A[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\z}) && url.is_a?(URI::HTTPS) && url.host == "github.com" && - url.port == 443 && !url.userinfo && !url.query && !url.fragment && url.path == "/#{repo}/#{kind}/#{reference["number"]}" -rescue URI::InvalidURIError - false -end - -def unique_identity_fields?(entries, *fields) - return false unless entries.is_a?(Array) && entries.all? { |entry| entry.is_a?(Hash) } - - fields.all? do |field| - identities = entries.map { |entry| entry[field] } - identities.all? { |identity| identity && !identity.to_s.empty? } && identities.uniq.length == identities.length - end -end - -def api_url_path?(url, expected_path) - uri = URI(url.to_s) - uri.is_a?(URI::HTTPS) && uri.host == "api.github.com" && uri.port == 443 && !uri.userinfo && !uri.query && !uri.fragment && uri.path == expected_path -rescue URI::InvalidURIError - false -end - -def issue_object_scope?(object, repo, number) - url = object["issue_url"] || object.dig("issue", "url") - api_url_path?(url, "/repos/#{repo}/issues/#{number}") -end - -def pull_request_object_scope?(object, repo, number) - url = object["pull_request_url"] || object.dig("_links", "pull_request", "href") - api_url_path?(url, "/repos/#{repo}/pulls/#{number}") -end - -def validate_graph_and_count(graph, repo) - unless graph.is_a?(Hash) && graph.keys.sort == %w[items label_catalog repository schema] && graph["schema"] == PREFLIGHT_SCHEMA - raise "preflight schema is invalid" - end - repository = graph.fetch("repository") - unless repository.is_a?(Hash) && repository.keys.sort == %w[default_branch default_branch_sha full_name id] && - repository["full_name"] == repo && !repository["id"].to_s.empty? && - !repository["default_branch"].to_s.empty? && repository["default_branch_sha"].to_s.match?(/\A[0-9a-f]{40}\z/i) - raise "preflight repository identity is invalid" - end - - labels = graph.fetch("label_catalog") - items = graph.fetch("items") - raise "preflight collections are invalid" unless labels.is_a?(Array) && items.is_a?(Array) - unless labels.all? { |label| label.is_a?(Hash) && label["id"].is_a?(Integer) && !label["name"].to_s.empty? } && - labels.map { |label| label["id"] }.uniq.length == labels.length && - labels.map { |label| label["name"] }.uniq.length == labels.length - raise "preflight label catalog is invalid" - end - label_catalog = labels.to_h { |label| [label.fetch("id"), label.fetch("name")] } - - numbers = [] - issue_count = 0 - pull_request_count = 0 - comment_count = 0 - event_count = 0 - comment_ids = [] - event_ids = [] - items.each do |item| - raise "preflight item is invalid" unless item.is_a?(Hash) && item["number"].is_a?(Integer) && item["number"].positive? - - number = item["number"] - metadata = item["issue"] - comments = item["comments"] - events = item["events"] - unless metadata.is_a?(Hash) && metadata["number"] == number && metadata.key?("body") && - api_url_path?(metadata["url"], "/repos/#{repo}/issues/#{number}") && - %w[open closed].include?(metadata["state"]) && metadata["labels"].is_a?(Array) && - metadata["assignees"].is_a?(Array) && metadata["comments"] == comments&.length && - unique_identity_fields?(metadata["labels"], "id", "name") && - metadata["labels"].all? { |label| label_catalog[label["id"]] == label["name"] } && - unique_identity_fields?(metadata["assignees"], "id", "login") && - unique_identity_fields?(comments, "id") && comments.all? { |comment| issue_object_scope?(comment, repo, number) } && - unique_identity_fields?(events, "id") && events.all? { |event| issue_object_scope?(event, repo, number) } - raise "preflight item ##{number} metadata is invalid" - end - case item["kind"] - when "issue" - relationships = item["relationships"] - relationship_lists = { "blocked_by" => "issues", "blocking" => "issues", "closed_by_pull_requests" => "pull", "sub_issues" => "issues" } - unless item.keys.sort == %w[comments events issue kind number relationships] && - relationships.is_a?(Hash) && - relationships.keys.sort == %w[blocked_by blocking closed_by_pull_requests number parent sub_issues] && - relationships["number"] == number && - (!relationships["parent"] || relationship_reference?(relationships["parent"], "issues")) && - relationship_lists.all? do |key, kind| - relationships[key].is_a?(Array) && relationships[key].all? { |reference| relationship_reference?(reference, kind) } - end - raise "preflight Issue ##{number} schema is invalid" - end - - issue_count += 1 - when "pull_request" - pull_request = item["pull_request"] - unless item.keys.sort == %w[comments events issue kind number pull_request] && pull_request.is_a?(Hash) && - pull_request.keys.sort == %w[check_runs commits files metadata review_comments reviews statuses] && - pull_request["metadata"].is_a?(Hash) && pull_request.dig("metadata", "number") == number && - api_url_path?(pull_request.dig("metadata", "url"), "/repos/#{repo}/pulls/#{number}") && - pull_request.dig("metadata", "head", "sha").to_s.match?(/\A[0-9a-f]{40}\z/i) && - pull_request.dig("metadata", "base", "sha").to_s.match?(/\A[0-9a-f]{40}\z/i) && - unique_identity_fields?(pull_request["commits"], "sha") && pull_request["commits"].all? { |commit| commit["sha"].match?(/\A[0-9a-f]{40}\z/i) } && - pull_request["commits"].any? { |commit| commit["sha"] == pull_request.dig("metadata", "head", "sha") } && - unique_identity_fields?(pull_request["files"], "filename") && - unique_identity_fields?(pull_request["reviews"], "id") && pull_request["reviews"].all? { |review| pull_request_object_scope?(review, repo, number) } && - unique_identity_fields?(pull_request["review_comments"], "id") && pull_request["review_comments"].all? { |comment| pull_request_object_scope?(comment, repo, number) } && - unique_identity_fields?(pull_request["statuses"], "id") && - pull_request["commits"].length == pull_request.dig("metadata", "commits") && - pull_request["files"].length == pull_request.dig("metadata", "changed_files") && - pull_request["review_comments"].length == pull_request.dig("metadata", "review_comments") && - pull_request["check_runs"].is_a?(Hash) && pull_request["check_runs"].keys.sort == %w[check_runs total_count] && - unique_identity_fields?(pull_request.dig("check_runs", "check_runs"), "id") && - pull_request.dig("check_runs", "check_runs").all? { |run| run["head_sha"] == pull_request.dig("metadata", "head", "sha") } && - pull_request.dig("check_runs", "total_count") == pull_request.dig("check_runs", "check_runs").length - raise "preflight PR ##{number} schema is invalid" - end - - pull_request_count += 1 - else - raise "preflight item ##{number} kind is invalid" - end - numbers << number - comment_ids.concat(comments.map { |comment| comment.fetch("id") }) - event_ids.concat(events.map { |event| event.fetch("id") }) - comment_count += comments.length - event_count += events.length - end - raise "preflight contains duplicate item numbers" unless numbers.uniq.length == numbers.length - raise "preflight contains duplicate comment or event identities" unless comment_ids.uniq.length == comment_ids.length && event_ids.uniq.length == event_ids.length - - { - "issues" => issue_count, - "pull_requests" => pull_request_count, - "labels" => labels.length, - "issue_comments" => comment_count, - "issue_events" => event_count - } -end - -def verify_bundle(bundle, repo, controller) - unless File.directory?(bundle) && !File.symlink?(bundle) && Dir.children(bundle).sort == BUNDLE_FILES - raise "bundle file set is invalid" - end - BUNDLE_FILES.each do |filename| - path = File.join(bundle, filename) - raise "bundle entry #{filename} is not a regular file" unless File.file?(path) && !File.symlink?(path) - end - - preflight_contents, preflight_sha = verify_sidecar(bundle, "preflight.json") - manifest_contents, manifest_sha = verify_sidecar(bundle, "manifest.json") - graph = parse_canonical_json(preflight_contents, "preflight.json") - manifest = parse_canonical_json(manifest_contents, "manifest.json") - expected_manifest_keys = %w[api captured_at controller counts default_branch default_branch_sha exclusions pages preflight repository schema target] - unless manifest.is_a?(Hash) && manifest.keys.sort == expected_manifest_keys && manifest["schema"] == MANIFEST_SCHEMA - raise "manifest schema is invalid" - end - unless manifest["repository"] == repo && manifest["controller"] == controller && - manifest["target"] == "GitHub Issue and pull request metadata in #{repo}" && manifest["exclusions"] == EVIDENCE_EXCLUSIONS - raise "manifest scope is invalid" - end - begin - captured_at = manifest.fetch("captured_at") - raise ArgumentError unless captured_at.is_a?(String) && captured_at.end_with?("Z") && Time.iso8601(captured_at).utc_offset.zero? - rescue ArgumentError - raise "manifest capture time is not UTC" - end - - api = manifest.fetch("api") - unless api.is_a?(Hash) && api.keys.sort == %w[graphql_media_type rest_accept rest_version] && - api["rest_version"] == GitHubReadOnly::API_VERSION && api["rest_accept"] == GitHubReadOnly::REST_ACCEPT && - !api["graphql_media_type"].to_s.empty? - raise "manifest API provenance is invalid" - end - pages = manifest.fetch("pages") - link_provenance = pages.is_a?(Array) && pages.all? { |page| page.is_a?(Hash) && page.key?("link") } - if pages.is_a?(Array) - has_link = pages.any? { |page| page.is_a?(Hash) && page.key?("link") } - raise "manifest page Link provenance is inconsistent" if has_link && !link_provenance - - manifest["pages"] = pages.map { |page| page.is_a?(Hash) && !page.key?("link") ? page.merge("link" => nil) : page } - end - counts = validate_graph_and_count(graph, repo) - verify_page_inventory!(manifest.fetch("pages"), api.fetch("graphql_media_type"), graph, repo, link_provenance: link_provenance) - raise "manifest counts do not match preflight" unless manifest["counts"] == counts - - repository = graph.fetch("repository") - unless manifest["default_branch"] == repository["default_branch"] && - manifest["default_branch_sha"] == repository["default_branch_sha"] && - manifest["preflight"] == { "file" => "preflight.json", "sha256" => preflight_sha } - raise "manifest preflight identity is invalid" - end - [graph, manifest, link_provenance, { "manifest_sha256" => manifest_sha, "preflight_sha256" => preflight_sha }] -rescue Errno::EACCES, Errno::ENOENT => error - raise "bundle read failed: #{error.message}" -end - -def issue_plan_state(item) - metadata = item.fetch("issue") - relationships = item.fetch("relationships") - Canonical.value( - "assignees" => metadata.fetch("assignees").map { |assignee| assignee.fetch("login") }, - "blocked_by" => relationships.fetch("blocked_by").map { |reference| reference.fetch("number") }, - "body" => metadata["body"], - "labels" => metadata.fetch("labels").map { |label| label.fetch("name") }, - "parent" => relationships["parent"] && relationships.dig("parent", "number"), - "state" => { "reason" => metadata["state_reason"], "value" => metadata.fetch("state") } - ) -end - -def github_login?(login) - login.is_a?(String) && login.match?(/\A(?=.{1,39}\z)[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\z/) -end - -def valid_utc_timestamp?(value) - value.is_a?(String) && value.end_with?("Z") && Time.iso8601(value).utc_offset.zero? -rescue ArgumentError - false -end - -def validate_plan_relationship_scope!(items, repo, prefix) - items.each do |number, item| - relationships = item.fetch("relationships") - references = [relationships["parent"], *relationships.fetch("sub_issues"), - *relationships.fetch("blocked_by"), *relationships.fetch("blocking")].compact - unless references.all? { |reference| reference.dig("repository", "nameWithOwner") == repo } - raise "#{prefix} Issue ##{number} relationship scope is unsupported" - end - end -end - -def validate_issue_metadata_derivations!(item, states, repo, prefix) - number = item.fetch("number") - metadata = item.fetch("issue") - relationships = item.fetch("relationships") - state = states.fetch(number).dig("state", "value") - - if metadata.key?("assignee") - assignee = metadata["assignee"] - valid_assignee = assignee.nil? ? metadata.fetch("assignees").empty? : - assignee.is_a?(Hash) && metadata.fetch("assignees").any? { |entry| Canonical.value(entry) == Canonical.value(assignee) } - raise "#{prefix} Issue ##{number} assignee summary is inconsistent" unless valid_assignee - end - - if metadata.key?("parent_issue_url") - parent = states.fetch(number)["parent"] - expected_parent_url = parent && "https://api.github.com/repos/#{repo}/issues/#{parent}" - raise "#{prefix} Issue ##{number} parent summary is inconsistent" unless metadata["parent_issue_url"] == expected_parent_url - end - - if metadata.key?("sub_issues_summary") - sub_issues = relationships.fetch("sub_issues").map { |reference| reference.fetch("number") } - completed = sub_issues.count { |related| states.fetch(related).dig("state", "value") == "closed" } - expected = { - "completed" => completed, - "percent_completed" => sub_issues.empty? ? 0 : completed * 100 / sub_issues.length, - "total" => sub_issues.length - } - raise "#{prefix} Issue ##{number} sub-issues summary is inconsistent" unless metadata["sub_issues_summary"] == expected - end - - if metadata.key?("issue_dependencies_summary") - blocked_by = relationships.fetch("blocked_by").map { |reference| reference.fetch("number") } - blocking = relationships.fetch("blocking").map { |reference| reference.fetch("number") } - expected = { - "blocked_by" => blocked_by.count { |related| states.fetch(related).dig("state", "value") == "open" }, - "blocking" => blocking.count { |related| states.fetch(related).dig("state", "value") == "open" }, - "total_blocked_by" => blocked_by.length, - "total_blocking" => blocking.length - } - raise "#{prefix} Issue ##{number} dependency summary is inconsistent" unless metadata["issue_dependencies_summary"] == expected - end - - if metadata.key?("closed_at") - valid_closed_at = state == "open" ? metadata["closed_at"].nil? : valid_utc_timestamp?(metadata["closed_at"]) - raise "#{prefix} Issue ##{number} closed_at is inconsistent" unless valid_closed_at - end - if metadata.key?("closed_by") - closed_by = metadata["closed_by"] - valid_closed_by = state == "open" ? closed_by.nil? : closed_by.nil? || closed_by.is_a?(Hash) && !closed_by["login"].to_s.empty? - raise "#{prefix} Issue ##{number} closed_by is inconsistent" unless valid_closed_by - end - if metadata.key?("updated_at") && !valid_utc_timestamp?(metadata["updated_at"]) - raise "#{prefix} Issue ##{number} updated_at is invalid" - end -end - -def validate_plan_state!(state, number, issue_numbers, label_names, target:) - unless state.is_a?(Hash) && state.keys.sort == PLAN_STATE_KEYS && (state["body"].nil? || state["body"].is_a?(String)) - raise "forward plan Issue ##{number} state schema is invalid" - end - - labels = state["labels"] - assignees = state["assignees"] - blockers = state["blocked_by"] - parent = state["parent"] - issue_state = state["state"] - unless labels.is_a?(Array) && labels.all? { |label| label.is_a?(String) && !label.empty? } && - labels.uniq.length == labels.length && (labels - label_names).empty? && - assignees.is_a?(Array) && assignees.all? { |login| github_login?(login) } && - assignees.uniq.length == assignees.length && blockers.is_a?(Array) && - blockers.all? { |blocker| issue_numbers.include?(blocker) && blocker != number } && blockers.uniq.length == blockers.length && - (parent.nil? || issue_numbers.include?(parent) && parent != number) && - issue_state.is_a?(Hash) && issue_state.keys.sort == %w[reason value] - raise "forward plan Issue ##{number} state identity is invalid" - end - - value = issue_state["value"] - reason = issue_state["reason"] - valid_reason = value == "open" ? [nil, "reopened"].include?(reason) : - value == "closed" && [nil, "completed", "duplicate", "not_planned"].include?(reason) - valid_reason &&= !target || value != "closed" || !reason.nil? - raise "forward plan Issue ##{number} state transition is invalid" unless valid_reason -end - -def validate_plan_edges!(states) - edges = { - "parent" => states.transform_values { |state| [state["parent"]].compact }, - "blocked_by" => states.transform_values { |state| state.fetch("blocked_by") } - } - edges.each do |field, graph| - visited = {} - visiting = {} - visit = lambda do |number| - raise "forward plan #{field} relationships contain a cycle" if visiting[number] - return if visited[number] - - visiting[number] = true - graph.fetch(number).each { |related| visit.call(related) } - visiting.delete(number) - visited[number] = true - end - graph.each_key { |number| visit.call(number) } - end -end - -def expected_derived_relationships(states) - sub_issues = Hash.new { |hash, key| hash[key] = [] } - blocking = Hash.new { |hash, key| hash[key] = [] } - states.each do |number, state| - sub_issues[state["parent"]] << number if state["parent"] - state.fetch("blocked_by").each { |blocker| blocking[blocker] << number } - end - [sub_issues, blocking] -end - -def validate_derived_relationships!(items, states, prefix) - expected_sub_issues, expected_blocking = expected_derived_relationships(states) - items.each do |number, item| - actual_sub_issues = item.dig("relationships", "sub_issues").map { |reference| reference.fetch("number") } - actual_blocking = item.dig("relationships", "blocking").map { |reference| reference.fetch("number") } - unless actual_sub_issues.sort == expected_sub_issues[number].sort && actual_blocking.sort == expected_blocking[number].sort - raise "#{prefix} Issue ##{number} derived relationship drift" - end - end -end - -def reverse_plan(graph, manifest, manifest_sha, forward_path, repo, controller) - forward, _, forward_sha = read_canonical_document(forward_path) - expected_keys = %w[controller default_branch default_branch_sha operations preflight_manifest_sha256 preflight_sha256 repository schema target] - unless forward.is_a?(Hash) && forward.keys.sort == expected_keys && forward["schema"] == FORWARD_PLAN_SCHEMA && - forward["repository"] == repo && forward["controller"] == controller && forward["target"] == manifest["target"] && - forward["preflight_manifest_sha256"] == manifest_sha && - forward["preflight_sha256"] == manifest.dig("preflight", "sha256") && - forward["default_branch"] == manifest["default_branch"] && forward["default_branch_sha"] == manifest["default_branch_sha"] - raise "forward plan identity is invalid" - end - - issues = graph.fetch("items").select { |item| item["kind"] == "issue" }.to_h { |item| [item.fetch("number"), item] } - issue_numbers = issues.keys - label_names = graph.fetch("label_catalog").map { |label| label.fetch("name") } - validate_plan_relationship_scope!(issues, repo, "preflight") - states = issues.transform_values { |item| issue_plan_state(item) } - validate_plan_edges!(states) - validate_derived_relationships!(issues, states, "preflight") - issues.each_value { |item| validate_issue_metadata_derivations!(item, states, repo, "preflight") } - targets = states.dup - operations = forward["operations"] - raise "forward plan operations are invalid" unless operations.is_a?(Array) && !operations.empty? - - seen = {} - reverse_operations = operations.map do |operation| - unless operation.is_a?(Hash) && operation.keys.sort == %w[number precondition target] && operation["number"].is_a?(Integer) - raise "forward plan operation schema is invalid" - end - number = operation["number"] - raise "forward plan references unknown Issue ##{number}" unless issues.key?(number) - raise "forward plan contains duplicate Issue ##{number}" if seen[number] - - seen[number] = true - validate_plan_state!(operation["precondition"], number, issue_numbers, label_names, target: false) - validate_plan_state!(operation["target"], number, issue_numbers, label_names, target: true) - unless Canonical.value(operation["precondition"]) == states.fetch(number) - raise "forward plan Issue ##{number} precondition does not match preflight" - end - if Canonical.value(operation["target"]) == states.fetch(number) - raise "forward plan Issue ##{number} operation is a no-op" - end - - targets[number] = Canonical.value(operation["target"]) - { "number" => number, "precondition" => operation["target"], "target" => operation["precondition"] } - end - validate_plan_edges!(targets) - - reverse = { - "controller" => controller, - "default_branch" => manifest.fetch("default_branch"), - "default_branch_sha" => manifest.fetch("default_branch_sha"), - "forward_plan_sha256" => forward_sha, - "operations" => reverse_operations, - "preflight_manifest_sha256" => manifest_sha, - "preflight_sha256" => manifest.dig("preflight", "sha256"), - "repository" => repo, - "schema" => REVERSE_PLAN_SCHEMA, - "target" => manifest.fetch("target") - } - [reverse, forward, forward_sha] -end - -def appended_provenance_ids(before, after, field, number) - unless after.length >= before.length && Canonical.value(after.first(before.length), field) == Canonical.value(before, field) - raise "post-state Issue ##{number} #{field} provenance is not append-only" - end - after.drop(before.length).map { |entry| entry.fetch("id") } -end - -def verify_post_state!(before_graph, after_graph, forward) - unless after_graph.fetch("repository") == before_graph.fetch("repository") && - Canonical.value(after_graph.fetch("label_catalog")) == Canonical.value(before_graph.fetch("label_catalog")) - raise "post-state repository or label catalog drift" - end - - before_items = before_graph.fetch("items").to_h { |item| [item.fetch("number"), item] } - after_items = after_graph.fetch("items").to_h { |item| [item.fetch("number"), item] } - raise "post-state Issue/PR inventory drift" unless after_items.keys.sort == before_items.keys.sort - - operations = forward.fetch("operations").to_h { |operation| [operation.fetch("number"), operation] } - expected_states = before_items.select { |_, item| item["kind"] == "issue" }.transform_values { |item| issue_plan_state(item) } - operations.each { |number, operation| expected_states[number] = Canonical.value(operation.fetch("target")) } - after_issues = after_items.select { |_, item| item["kind"] == "issue" } - repo = before_graph.dig("repository", "full_name") - validate_plan_relationship_scope!(after_issues, repo, "post-state") - validate_derived_relationships!(after_issues, expected_states, "post-state") - after_issues.each_value { |item| validate_issue_metadata_derivations!(item, expected_states, repo, "post-state") } - - operation_receipts = [] - before_items.each do |number, before| - after = after_items.fetch(number) - raise "post-state item ##{number} kind drift" unless after["kind"] == before["kind"] - if before["kind"] == "pull_request" - raise "post-state PR ##{number} metadata drift" unless Canonical.value(after) == Canonical.value(before) - next - end - - unless issue_plan_state(after) == expected_states.fetch(number) - raise "post-state Issue ##{number} does not match the forward plan" - end - before_metadata = before.fetch("issue").reject { |key, _| DERIVED_ISSUE_METADATA_KEYS.include?(key) } - after_metadata = after.fetch("issue").reject { |key, _| DERIVED_ISSUE_METADATA_KEYS.include?(key) } - unless Canonical.value(after_metadata) == Canonical.value(before_metadata) - raise "post-state Issue ##{number} unsupported metadata drift" - end - before_issue = before.fetch("issue") - after_issue = after.fetch("issue") - unless DERIVED_ISSUE_METADATA_KEYS.all? { |key| before_issue.key?(key) == after_issue.key?(key) } - raise "post-state Issue ##{number} derived metadata schema drift" - end - if before_issue["state"] == after_issue["state"] && - Canonical.value(before_issue.values_at("closed_at", "closed_by")) != Canonical.value(after_issue.values_at("closed_at", "closed_by")) - raise "post-state Issue ##{number} closure metadata drift" - end - before_without_updated_at = before.merge("issue" => before_issue.reject { |key, _| key == "updated_at" }) - after_without_updated_at = after.merge("issue" => after_issue.reject { |key, _| key == "updated_at" }) - if before_issue["updated_at"] != after_issue["updated_at"] - observable_change = Canonical.value(before_without_updated_at) != Canonical.value(after_without_updated_at) - timestamps_advance = valid_utc_timestamp?(before_issue["updated_at"]) && valid_utc_timestamp?(after_issue["updated_at"]) && - Time.iso8601(after_issue["updated_at"]) >= Time.iso8601(before_issue["updated_at"]) - raise "post-state Issue ##{number} updated_at drift" unless observable_change && timestamps_advance - end - unless Canonical.value(after.dig("relationships", "closed_by_pull_requests")) == - Canonical.value(before.dig("relationships", "closed_by_pull_requests")) - raise "post-state Issue ##{number} closing-reference drift" - end - comment_ids = appended_provenance_ids(before.fetch("comments"), after.fetch("comments"), "comments", number) - event_ids = appended_provenance_ids(before.fetch("events"), after.fetch("events"), "events", number) - operation = operations[number] - if operation.nil? - raise "post-state Issue ##{number} has unplanned provenance" unless comment_ids.empty? && event_ids.empty? - next - end - operation_receipts << { - "comment_ids" => comment_ids, - "event_ids" => event_ids, - "number" => number, - "precondition_sha256" => Digest::SHA256.hexdigest(Canonical.dump(operation.fetch("precondition"))), - "result" => "passed", - "target_sha256" => Digest::SHA256.hexdigest(Canonical.dump(operation.fetch("target"))) - } - end - operation_receipts -end - -def verify_receipt!(receipt, before_identity, after_identity, before_manifest, after_manifest, forward_sha, reverse_sha, operation_receipts, repo, controller) - expected_keys = %w[api controller counts default_branch default_branch_sha forward_plan_sha256 operations pages postflight_manifest_sha256 postflight_sha256 preflight_manifest_sha256 preflight_sha256 repository reverse_plan_sha256 schema target] - unless receipt.is_a?(Hash) && receipt.keys.sort == expected_keys && receipt["schema"] == RECEIPT_SCHEMA && - receipt["repository"] == repo && receipt["controller"] == controller && receipt["target"] == before_manifest["target"] && - receipt["preflight_sha256"] == before_manifest.dig("preflight", "sha256") && - receipt["postflight_sha256"] == after_manifest.dig("preflight", "sha256") && - receipt["preflight_manifest_sha256"] == before_identity["manifest_sha256"] && - receipt["postflight_manifest_sha256"] == after_identity["manifest_sha256"] && - receipt["forward_plan_sha256"] == forward_sha && receipt["reverse_plan_sha256"] == reverse_sha && - receipt["default_branch"] == after_manifest["default_branch"] && receipt["default_branch_sha"] == after_manifest["default_branch_sha"] && - receipt["api"] == after_manifest["api"] && receipt["pages"] == after_manifest["pages"] && - receipt["counts"] == after_manifest["counts"] && receipt["operations"] == Canonical.value(operation_receipts) - raise "restoration receipt identity is invalid" - end -end - -def verify_live_bundle!(graph, manifest, repo, link_provenance, prefix) - live_graph, live_counts, live_branch, live_branch_sha, live_pages = capture_graph(repo) - verify_page_inventory!(live_pages, manifest.dig("api", "graphql_media_type"), live_graph, repo, link_provenance: true) - raise "#{prefix}: Issue/PR graph changed" unless Canonical.value(live_graph) == Canonical.value(graph) - raise "#{prefix}: counts changed" unless live_counts == manifest["counts"] - unless live_branch == manifest["default_branch"] && live_branch_sha == manifest["default_branch_sha"] - raise "#{prefix}: default branch changed" - end - comparison_pages = link_provenance ? live_pages : live_pages.map { |page| page.merge("link" => nil) } - unless Canonical.value(comparison_pages) == Canonical.value(manifest["pages"]) - raise "#{prefix}: page/header inventory changed" - end -end - -def capture(options) - repo, controller, output = options.values_at(:repo, :controller, :output) - raise "--repo OWNER/REPO is required" if repo.to_s.empty? - raise "--controller NAME is required" if controller.to_s.strip.empty? - raise "--output DIR is required" if output.to_s.empty? - - graph, counts, default_branch, default_branch_sha, pages = capture_graph(repo) - graphql_media_types = pages.select { |page| page["protocol"] == "graphql" }.map { |page| page["media_type"] } - unless graphql_media_types.any? && graphql_media_types.all? { |media_type| !media_type.to_s.empty? } && graphql_media_types.uniq.length == 1 - raise "capture stability drift: GraphQL media type missing or inconsistent: #{graphql_media_types.uniq.inspect}" - end - manifest = { - "schema" => MANIFEST_SCHEMA, - "repository" => repo, - "captured_at" => Time.now.utc.iso8601(6), - "controller" => controller, - "target" => "GitHub Issue and pull request metadata in #{repo}", - "exclusions" => EVIDENCE_EXCLUSIONS, - "default_branch" => default_branch, - "default_branch_sha" => default_branch_sha, - "api" => { - "rest_version" => GitHubReadOnly::API_VERSION, - "rest_accept" => GitHubReadOnly::REST_ACCEPT, - "graphql_media_type" => graphql_media_types.first - }, - "pages" => pages, - "counts" => counts - } - write_bundle(output, graph, manifest) - puts "captured #{repo} at #{default_branch_sha} -> #{output}" -end - -def verify(options) - repo, controller, bundle = options.values_at(:repo, :controller, :bundle) - raise "--repo OWNER/REPO is required" if repo.to_s.empty? - raise "--controller NAME is required" if controller.to_s.strip.empty? - raise "--bundle DIR is required" if bundle.to_s.empty? - - graph, manifest, link_provenance = verify_bundle(bundle, repo, controller) - verify_live_bundle!(graph, manifest, repo, link_provenance, "live verification drift") if options[:live] - suffix = options[:live] ? " with live readback" : "" - puts "verified #{repo} at #{manifest.fetch("default_branch_sha")}#{suffix} <- #{bundle}" -end - -def plan_restore(options) - repo, controller, bundle, forward_path = options.values_at(:repo, :controller, :bundle, :forward_plan) - raise "--repo OWNER/REPO is required" if repo.to_s.empty? - raise "--controller NAME is required" if controller.to_s.strip.empty? - raise "--bundle DIR is required" if bundle.to_s.empty? - raise "--forward-plan FILE is required" if forward_path.to_s.empty? - - post_paths = options.values_at(:reverse_plan, :receipt, :post_bundle) - if post_paths.any? && !post_paths.all? { |path| !path.to_s.empty? } - raise "--reverse-plan, --receipt, and --post-bundle are required together" - end - - graph, manifest, link_provenance, identity = verify_bundle(bundle, repo, controller) - plan, forward, forward_sha = reverse_plan(graph, manifest, identity.fetch("manifest_sha256"), forward_path, repo, controller) - unless post_paths.any? - verify_live_bundle!(graph, manifest, repo, link_provenance, "pre-mutation live drift") - print Canonical.dump(plan) - return - end - - reverse_path, receipt_path, post_bundle = post_paths - saved_plan, reverse_contents, reverse_sha = read_canonical_document(reverse_path) - raise "saved reverse plan does not match the exact derived inverse" unless reverse_contents.b == Canonical.dump(plan).b && saved_plan == plan - - post_graph, post_manifest, post_link_provenance, post_identity = verify_bundle(post_bundle, repo, controller) - unless post_manifest["default_branch"] == manifest["default_branch"] && post_manifest["default_branch_sha"] == manifest["default_branch_sha"] - raise "post-state default branch drift" - end - operation_receipts = verify_post_state!(graph, post_graph, forward) - receipt, = read_canonical_document(receipt_path) - verify_receipt!(receipt, identity, post_identity, manifest, post_manifest, forward_sha, reverse_sha, operation_receipts, repo, controller) - verify_live_bundle!(post_graph, post_manifest, repo, post_link_provenance, "restoration live drift") - print reverse_contents -end - -command = ARGV.shift -options = {} -parser = OptionParser.new do |flags| - flags.banner = "Usage: issue-lifecycle-preflight.rb (capture|verify|plan-restore) --repo OWNER/REPO --controller NAME" - flags.on("--repo OWNER/REPO", "Repository to read") { |value| options[:repo] = value } - flags.on("--controller NAME", "Named evidence controller") { |value| options[:controller] = value } - flags.on("--output DIR", "New evidence bundle directory") { |value| options[:output] = value } - flags.on("--bundle DIR", "Existing evidence bundle directory") { |value| options[:bundle] = value } - flags.on("--forward-plan FILE", "Canonical approved forward plan") { |value| options[:forward_plan] = value } - flags.on("--reverse-plan FILE", "Exact saved reverse plan") { |value| options[:reverse_plan] = value } - flags.on("--receipt FILE", "Canonical post-state receipt") { |value| options[:receipt] = value } - flags.on("--post-bundle DIR", "Verified post-state evidence bundle") { |value| options[:post_bundle] = value } - flags.on("--live", "Independently compare the bundle with live GitHub reads") { options[:live] = true } -end - -begin - parser.parse! - raise "unexpected arguments: #{ARGV.join(" ")}" unless ARGV.empty? - case command - when "capture" - raise "--bundle is only valid for verify" if options[:bundle] - raise "--forward-plan is only valid for plan-restore" if options[:forward_plan] - raise "restoration inputs are only valid for plan-restore" if options.values_at(:reverse_plan, :receipt, :post_bundle).any? - raise "--live is only valid for verify" if options[:live] - - capture(options) - when "verify" - raise "--output is only valid for capture" if options[:output] - raise "--forward-plan is only valid for plan-restore" if options[:forward_plan] - raise "restoration inputs are only valid for plan-restore" if options.values_at(:reverse_plan, :receipt, :post_bundle).any? - - verify(options) - when "plan-restore" - raise "--output is only valid for capture" if options[:output] - raise "--live is implicit for plan-restore" if options[:live] - - plan_restore(options) - else - raise "unsupported operation #{command.inspect}; expected capture, verify, or plan-restore" - end -rescue StandardError => error - warn "ERROR issue lifecycle preflight: #{error.message}" - exit 2 -end diff --git a/.github/scripts/issue-lifecycle-status-reconcile.rb b/.github/scripts/issue-lifecycle-status-reconcile.rb deleted file mode 100755 index 11e7c3ff3..000000000 --- a/.github/scripts/issue-lifecycle-status-reconcile.rb +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "json" -require "open3" -require "optparse" -require "rbconfig" - -CONTEXT = "Issue Lifecycle" -STATUS_BY_EXIT = { - 0 => ["success", "Issue lifecycle audit passed"], - 1 => ["failure", "Issue lifecycle policy violations found"], - 2 => ["error", "Issue lifecycle audit errored"] -}.freeze - -class GitHubStatuses - API_VERSION = "2026-03-10" - - def initialize(repo) - raise "invalid repository #{repo.inspect}; expected OWNER/REPO" unless repo.to_s.match?(/\A[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\z/) - - @repo = repo - end - - def open_pull_requests - paginate("repos/#{@repo}/pulls?state=open&per_page=100") - end - - def create_status(sha, payload) - run( - "gh", "api", "--method", "POST", *headers, - "--input", "-", "--silent", "repos/#{@repo}/statuses/#{sha}", - stdin_data: JSON.generate(payload) - ) - end - - private - - def paginate(path) - pages = JSON.parse(run("gh", "api", "--method", "GET", *headers, "--paginate", "--slurp", path)) - raise "GitHub API GET #{path} did not return pages of arrays" unless pages.is_a?(Array) && pages.all? { |page| page.is_a?(Array) } - - pages.flatten(1) - end - - def headers - [ - "-H", "Accept: application/vnd.github+json", - "-H", "X-GitHub-Api-Version: #{API_VERSION}" - ] - end - - def run(*command, stdin_data: "") - stdout, stderr, status = Open3.capture3(*command, stdin_data: stdin_data) - raise "#{command.first(3).join(' ')} failed: #{stderr.strip}" unless status.success? - - stdout - end -end - -def audit(repo, pr_number, summary) - command = [ - RbConfig.ruby, File.join(__dir__, "issue-lifecycle-audit.rb"), - "--repo", repo, "--pr", pr_number.to_s - ] - command.concat(["--summary", summary]) if summary - stdout, stderr, status = Open3.capture3(*command) - $stdout.write(stdout) - $stderr.write(stderr) - STATUS_BY_EXIT.key?(status.exitstatus) ? status.exitstatus : 2 -end - -def append_summary(path, rows) - return unless path - - File.open(path, "a") do |summary| - summary.puts "\n# Issue Lifecycle Status Reconciliation\n\n" - if rows.empty? - summary.puts "No open pull requests found." - next - end - - summary.puts "| Pull request | Head | State | Status |" - summary.puts "| --- | --- | --- | --- |" - rows.each do |number, sha, state, action| - summary.puts "| ##{number} | `#{sha[0, 12]}` | #{state} | #{action} |" - end - end -end - -options = {} -OptionParser.new do |parser| - parser.banner = "Usage: issue-lifecycle-status-reconcile.rb [--repo OWNER/REPO] [--summary FILE]" - parser.on("--repo OWNER/REPO", "Reconcile one repository") { |value| options[:repo] = value } - parser.on("--summary FILE", "Append Markdown output to FILE") { |value| options[:summary] = value } -end.parse! - -begin - raise "unexpected arguments: #{ARGV.join(' ')}" unless ARGV.empty? - - repo = options[:repo] || ENV["GITHUB_REPOSITORY"] - raise "--repo OWNER/REPO is required outside GitHub Actions" unless repo - - github = GitHubStatuses.new(repo) - rows = [] - audits_by_sha = {} - result = 0 - # ponytail: #480 intentionally keeps a per-PR merged-auditor call; add a shared snapshot only after measured API/runtime limits justify the protocol change. - github.open_pull_requests.each do |pull_request| - number = Integer(pull_request.fetch("number")) - sha = pull_request.dig("head", "sha").to_s - raise "PR ##{number} has invalid head SHA #{sha.inspect}" unless sha.match?(/\A[0-9a-f]{40}\z/i) - - audit_exit = audit(repo, number, options[:summary]) - state = STATUS_BY_EXIT.fetch(audit_exit).first - aggregate = audits_by_sha[sha] ||= { exit: 0 } - aggregate[:exit] = [aggregate[:exit], audit_exit].max - rows << [number, sha, state, "pending"] - result = [result, audit_exit].max - end - - audits_by_sha.each do |sha, aggregate| - state, description = STATUS_BY_EXIT.fetch(aggregate[:exit]) - payload = { "context" => CONTEXT, "state" => state, "description" => description } - github.create_status(sha, payload) - end - rows.each { |row| row[3] = "published" } - - append_summary(options[:summary], rows) - exit result -rescue StandardError => error - warn "ERROR issue lifecycle status reconciliation: #{error.message}" - begin - File.open(options[:summary], "a") do |summary| - summary.puts "\n# Issue Lifecycle Status Reconciliation\n\n- Result: **ERROR**\n- #{error.message}" - end if options[:summary] - rescue StandardError - nil - end - exit 2 -end diff --git a/.github/scripts/select-rust-ci-scope.sh b/.github/scripts/select-rust-ci-scope.sh index 8f006e318..dd3f00aff 100755 --- a/.github/scripts/select-rust-ci-scope.sh +++ b/.github/scripts/select-rust-ci-scope.sh @@ -285,11 +285,9 @@ for path in "${paths[@]}"; do select_all_security_jobs continue ;; - .github/workflows/issue-lifecycle.yml|\ .github/workflows/claude.yml|.github/workflows/claude-code-review.yml|\ .github/ISSUE_TEMPLATE/*|.github/pull_request_template.md|\ - docs/agents/issue-tracker.md|docs/agents/triage-labels.md|\ - .github/scripts/issue-lifecycle-*|.github/scripts/test-issue-lifecycle-*) + docs/agents/issue-tracker.md|docs/agents/triage-labels.md) [[ $event == pull_request ]] && select_job ploy/commit-hygiene select_job ploy/workflow-lint continue diff --git a/.github/scripts/test-issue-lifecycle-audit.sh b/.github/scripts/test-issue-lifecycle-audit.sh deleted file mode 100755 index 5dbe5a6f0..000000000 --- a/.github/scripts/test-issue-lifecycle-audit.sh +++ /dev/null @@ -1,321 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(git rev-parse --show-toplevel)" -auditor="$repo_root/.github/scripts/issue-lifecycle-audit.rb" -tmp_dir="$(mktemp -d)" -trap 'rm -rf "$tmp_dir"' EXIT - -ruby -rjson - "$tmp_dir/cases.json" <<'RUBY' -def issue_body(parent = "None", blockers = "None", extra = "") - "## Parent\n\n#{parent}\n\n## Blocked by\n\n#{blockers}\n#{extra}" -end - -def issue(number, labels, body = issue_body, assignees = [], parent = nil, blocked_by = []) - { "number" => number, "title" => "Fixture issue #{number}", "body" => body, - "labels" => labels, "assignees" => assignees, "parent" => parent, - "blocked_by" => blocked_by } -end - -def pull_request(number, relationship, base = "main", commits = [], extra = "", title = "Fixture PR #{number}") - { "number" => number, "base" => base, - "title" => title, - "body" => "## Issue relationship\n\n#{relationship}\n\n## Focused validation\n\nFixture proof.\n#{extra}\n", - "commits" => commits } -end - -def data(issues, pull_requests = [], automatic_close = true, closed_runtime = []) - { "repo" => "example/repo", "default_branch" => "main", "automatic_linked_issue_closing" => automatic_close, - "issues" => issues, "pull_requests" => pull_requests, "closed_runtime_issues" => closed_runtime } -end - -base = issue(10, %w[enhancement ready-for-agent], issue_body, ["agent"]) -runtime_control = "\n## Runtime control\n\nTarget: repository setting\nCandidate: commit abc with config digest def\nController: release-owner\nStop rule: stop on failed preflight\nRollback: restore the previous setting\n" -runtime_without_candidate = runtime_control.lines.reject { |line| line.start_with?("Candidate:") }.join -issue_form_runtime = <<~MARKDOWN - -### Exact target identity - -repository setting - -### Candidate and configuration identity - -commit abc with config digest def - -### Named controller - -release-owner - -### Rollback identity and procedure - -restore the previous setting - -### Stop rules - -stop on failed preflight -MARKDOWN -incomplete_commits = pull_request(32, "Refs #10", "main", [ - { "sha" => "first", "message" => "Safe fetched commit" } -]) -incomplete_commits["expected_commit_count"] = 251 -cases = { - "valid_refs" => data([base], [pull_request(20, "Refs #10", "feature-stack")]), - "valid_closes" => data([ - issue(11, %w[enhancement ready-for-agent], issue_body, ["agent"]) - ], [pull_request(22, "Closes #11", "main", [{ "sha" => "fix", "message" => "Fixes #11" }])]), - "valid_none" => data([], [pull_request(23, "\nNone")], false), - "valid_needs_info" => data([issue(13, %w[bug needs-info])]), - "valid_wontfix" => data([issue(14, %w[enhancement wontfix])]), - "authorized_runtime_ready" => data([ - issue(12, %w[enhancement ready-for-agent runtime], - issue_body("None", "#99", runtime_control), [], nil, - [{ "number" => 99, "state" => "closed" }]) - ]), - "authorized_runtime_ready_issue_form" => data([ - issue(15, %w[enhancement ready-for-agent runtime], - issue_body("None", "#99", issue_form_runtime), [], nil, - [{ "number" => 99, "state" => "closed" }]) - ]), - "missing_category" => data([issue(30, %w[ready-for-human])]), - "conflicting_category" => data([issue(31, %w[bug enhancement ready-for-human])]), - "missing_state" => data([issue(32, %w[enhancement])]), - "conflicting_state" => data([issue(33, %w[enhancement needs-triage ready-for-human])]), - "literal_escaped_newline" => data([ - issue(34, %w[enhancement ready-for-human], "## Parent\\n\\nNone\\n\\n## Blocked by\\n\\nNone\\n\\n## Details\\n\\nliteral \\n escape") - ]), - "short_literal_escaped_newline" => data([issue(61, %w[enhancement ready-for-human], "Summary\\nDetails")]), - "valid_literal_escaped_newline" => data([ - issue(35, %w[enhancement ready-for-human], issue_body + "\n\n```json\n{\"pattern\":\"line\\\\nnext\"}\n```\n") - ]), - "mixed_literal_escaped_newline" => data([ - issue(60, %w[enhancement ready-for-human], "Normal preface.\n\n## Parent\\n\\nNone\\n\\n## Blocked by\\n\\nNone") - ]), - "tracking_agent_queue" => data([issue(35, %w[enhancement ready-for-agent tracking])]), - "runtime_missing_control" => data([ - issue(36, %w[enhancement ready-for-agent runtime], issue_body("None", "None", runtime_without_candidate)) - ]), - "runtime_open_blocker" => data([ - issue(37, %w[enhancement ready-for-agent runtime], - issue_body("None", "#99", runtime_control), [], nil, - [{ "number" => 99, "state" => "open" }]) - ]), - "active_missing_owner" => data([ - issue(40, %w[enhancement ready-for-agent]) - ], [pull_request(140, "Refs #40")]).merge("audited_issue_numbers" => []), - "active_multiple_owners" => data([ - issue(41, %w[enhancement ready-for-agent], issue_body, %w[first second]) - ], [pull_request(141, "Refs #41")]).merge("audited_issue_numbers" => []), - "parent_mismatch" => data([ - issue(42, %w[enhancement ready-for-human], issue_body("#456"), [], 455) - ]), - "blocker_mismatch" => data([ - issue(43, %w[enhancement ready-for-human], issue_body("None", "#78"), [], nil, - [{ "number" => 77, "state" => "open" }]) - ]), - "invalid_multiple_relationships" => data([], [ - pull_request(21, "Refs #10\nCloses #10\nNone") - ]), - "negated_pr_closing" => data([base], [ - pull_request(24, "Refs #10", "main", [], "This does not close #10.") - ]), - "non_default_closes" => data([base], [pull_request(25, "Closes #10", "stack")]), - "runtime_closes" => data([ - issue(50, %w[enhancement ready-for-human runtime], issue_body, ["operator"]) - ], [pull_request(26, "Closes #50")]), - "tracking_closes" => data([ - issue(51, %w[enhancement ready-for-human tracking], issue_body, ["maintainer"]) - ], [pull_request(27, "Closes #51")]), - "pr_fix_with_refs" => data([base], [ - pull_request(28, "Refs #10", "main", [], "Fixes #10") - ]), - "qualified_pr_fix_with_refs" => data([base], [ - pull_request(36, "Refs #10", "main", [], "Fixes proerror77/monday#10") - ]), - "qualified_negated_pr_closing" => data([base], [ - pull_request(39, "Refs #10", "main", [], "This does not close proerror77/monday#10.") - ]), - "modified_negated_pr_closing" => data([base], [ - pull_request(43, "Closes #10", "main", [], "", "This does not fully close #10.") - ]), - "pr_title_fix_with_refs" => data([base], [ - pull_request(34, "Refs #10", "main", [], "", "Fixes #10 in title") - ]), - "qualified_pr_title_fix_with_refs" => data([base], [ - pull_request(37, "Refs #10", "main", [], "", "Fixes proerror77/monday#10 in title") - ]), - "commit_fix_with_refs" => data([base], [ - pull_request(29, "Refs #10", "main", [ - { "sha" => "safe", "message" => "Safe first commit" }, - { "sha" => "fix-ref", "message" => "Fixes #10" } - ]) - ]), - "qualified_commit_fix_with_refs" => data([base], [ - pull_request(38, "Refs #10", "main", [ - { "sha" => "fix-ref", "message" => "Fixes proerror77/monday#10" } - ]) - ]), - "qualified_commit_with_closes" => data([base], [ - pull_request(40, "Closes #10", "main", [ - { "sha" => "qualified", "message" => "Fixes other/repo#10" } - ]) - ]), - "valid_qualified_commit_with_closes" => data([base], [ - pull_request(41, "Closes #10", "main", [ - { "sha" => "qualified", "message" => "Fixes example/repo#10" } - ]) - ]), - "commit_negated_resolve" => data([base], [ - pull_request(30, "Closes #10", "main", [ - { "sha" => "resolve", "message" => "This does not resolve #10" } - ]) - ]), - "commit_never_closes" => data([base], [ - pull_request(33, "Closes #10", "main", [ - { "sha" => "never", "message" => "This never closes #10" } - ]) - ]), - "commit_other_issue" => data([ - base, issue(52, %w[bug ready-for-human]) - ], [pull_request(31, "Closes #10", "main", [ - { "sha" => "closed", "message" => "Closed #52" } - ])]), - "commit_list_incomplete" => data([base], [incomplete_commits]), - "double_close_same_issue" => data([base], [ - pull_request(71, "Closes #10"), - pull_request(72, "Closes #10") - ]), - "closed_runtime_no_evidence" => data([], [], true, [ - { "number" => 90, "title" => "Closed runtime", "body" => "no evidence", - "labels" => [{ "name" => "bug" }, { "name" => "runtime" }], "comments" => [] } - ]) -} -File.write(ARGV.fetch(0), JSON.generate("cases" => cases)) -RUBY - -run_pass() { - local name="$1" - local expected="${2-}" - local output - output="$(ruby "$auditor" --fixture "$tmp_dir/cases.json" --case "$name")" - grep -Fq "PASS $name" <<<"$output" - if [[ -n "$expected" ]]; then - grep -Fq "$expected" <<<"$output" - fi -} - -run_fail() { - local name="$1" - local expected="$2" - local output audit_exit summary - summary="$tmp_dir/$name-summary.md" - set +e - output="$(ruby "$auditor" --fixture "$tmp_dir/cases.json" --case "$name" --summary "$summary" 2>&1)" - audit_exit=$? - set -e - test "$audit_exit" -eq 1 - grep -Fq "$expected" <<<"$output" - grep -Fq "$expected" "$summary" -} - -run_pass valid_refs -run_pass valid_closes -run_pass valid_none "automatic-linked-issue-closing: disabled (fixture)" -run_pass valid_needs_info -run_pass valid_wontfix -run_pass authorized_runtime_ready -run_pass authorized_runtime_ready_issue_form -run_pass valid_literal_escaped_newline -run_pass valid_qualified_commit_with_closes - -while IFS='|' read -r name expected; do - run_fail "$name" "$expected" -done <<'CASES' -missing_category|Issue #30: expected exactly one category label -conflicting_category|Issue #31: expected exactly one category label -missing_state|Issue #32: expected exactly one triage state label -conflicting_state|Issue #33: expected exactly one triage state label -literal_escaped_newline|Issue #34: body contains a literal escaped newline -short_literal_escaped_newline|Issue #61: body contains a literal escaped newline -mixed_literal_escaped_newline|Issue #60: body contains a literal escaped newline -tracking_agent_queue|Issue #35: tracking issues cannot use ready-for-agent -runtime_missing_control|Issue #36: runtime ready-for-agent is missing Runtime control: Candidate -runtime_open_blocker|Issue #37: runtime ready-for-agent has open native blocker #99 -double_close_same_issue|Issue #10 is claimed closed by multiple open pull requests -closed_runtime_no_evidence|Issue #90 (runtime, closed): missing completion/closure evidence -active_missing_owner|Issue #40: active implementation requires exactly one assignee -active_multiple_owners|Issue #41: active implementation requires exactly one assignee -parent_mismatch|Issue #42: Parent summary references #456; native parent is #455 -blocker_mismatch|Issue #43: Blocked by summary references #78; native blocked-by is #77 -invalid_multiple_relationships|PR #21: expected exactly one visible issue relationship -negated_pr_closing|PR #24 body: negated closing phrase is forbidden -non_default_closes|PR #25: Closes #10 targets stack, not default branch main -runtime_closes|PR #26: runtime issue #50 cannot be closed by a pull request -tracking_closes|PR #27: tracking issue #51 cannot be closed by a pull request -pr_fix_with_refs|PR #28 body: closing keyword Fixes #10 requires visible Closes #10 -qualified_pr_fix_with_refs|PR #36 body: closing keyword Fixes proerror77/monday#10 requires visible Closes #10 -qualified_negated_pr_closing|PR #39 body: negated closing phrase is forbidden -modified_negated_pr_closing|PR #43 title: negated closing phrase is forbidden -pr_title_fix_with_refs|PR #34 title: closing keyword Fixes #10 requires visible Closes #10 -qualified_pr_title_fix_with_refs|PR #37 title: closing keyword Fixes proerror77/monday#10 requires visible Closes #10 -commit_fix_with_refs|PR #29 commit fix-ref: closing keyword Fixes #10 requires visible Closes #10 -qualified_commit_fix_with_refs|PR #38 commit fix-ref: closing keyword Fixes proerror77/monday#10 requires visible Closes #10 -qualified_commit_with_closes|PR #40 commit qualified: closing keyword Fixes other/repo#10 conflicts with visible Closes #10 -commit_negated_resolve|PR #30 commit resolve: negated closing phrase is forbidden -commit_never_closes|PR #33 commit never: negated closing phrase is forbidden -commit_other_issue|PR #31 commit closed: closing keyword Closed #52 conflicts with visible Closes #10 -commit_list_incomplete|PR #32: fetched 1 of 251 commit messages; audit cannot prove every commit safe -CASES - -set +e -parse_output="$(ruby "$auditor" --pr nope 2>&1)" -parse_exit=$? -summary_output="$(ruby "$auditor" --fixture "$tmp_dir/cases.json" --case valid_refs --summary "$tmp_dir/missing/summary.md" 2>&1)" -summary_exit=$? -set -e -test "$parse_exit" -eq 2 -grep -Fq "ERROR issue lifecycle audit" <<<"$parse_output" -test "$summary_exit" -eq 2 -grep -Fq "ERROR issue lifecycle audit" <<<"$summary_output" -if grep -Fq "PASS valid_refs" <<<"$summary_output"; then exit 1; fi - -live_stub_dir="$tmp_dir/live-stub" -mkdir -p "$live_stub_dir" -cat > "$live_stub_dir/gh" <<'EOF' -#!/usr/bin/env bash -path="${*: -1}" -case "$path" in - repos/example/repo) - echo '{"default_branch":"main"}' - ;; - repos/example/repo/issues\?state=open\&per_page=100\&page=1) - echo '[{"number":10,"title":"Fixture issue 10","body":"## Parent\n\nNone\n\n## Blocked by\n\nNone\n","labels":[{"name":"ready-for-agent"}],"assignees":[{"login":"agent"}],"issue_dependencies_summary":{"total_blocked_by":0}},{"number":88,"pull_request":{}}]' - ;; - repos/example/repo/pulls/88) - echo '{"number":88,"base":{"ref":"main"},"title":"Fixture PR 88","body":"## Issue relationship\n\nRefs #10\n\n## Focused validation\n\nFixture proof.\n","commits":1}' - ;; - repos/example/repo/pulls/88/commits\?per_page=100\&page=1) - echo '[{"sha":"safe","commit":{"message":"Safe commit"}}]' - ;; - repos/example/repo/issues/10) - echo '{"number":10,"title":"Fixture issue 10","body":"## Parent\n\nNone\n\n## Blocked by\n\nNone\n","labels":[{"name":"ready-for-agent"}],"assignees":[{"login":"agent"}],"issue_dependencies_summary":{"total_blocked_by":0}}' - ;; - repos/example/repo/issues/10/dependencies/blocked_by\?per_page=100\&page=1) - echo '[]' - ;; - *) - echo "unexpected gh api path: $path" >&2 - exit 1 - ;; -esac -EOF -chmod +x "$live_stub_dir/gh" - -set +e -live_output="$(PATH="$live_stub_dir:$PATH" ruby "$auditor" --repo example/repo --pr 88)" -live_exit=$? -set -e -test "$live_exit" -eq 1 -grep -Fq "FAIL PR #88" <<<"$live_output" -grep -Fq "Issue #10: expected exactly one category label" <<<"$live_output" - -echo "issue lifecycle audit fixtures: ok" diff --git a/.github/scripts/test-issue-lifecycle-contract.sh b/.github/scripts/test-issue-lifecycle-contract.sh deleted file mode 100755 index e9eb31fb5..000000000 --- a/.github/scripts/test-issue-lifecycle-contract.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(git rev-parse --show-toplevel)" - -ruby -ryaml -e ' - required = %w[prd engineering-change bug-report runtime-rollout] - categories = %w[bug enhancement] - states = %w[needs-triage needs-info ready-for-agent ready-for-human wontfix] - expected_labels = { - "prd" => %w[enhancement needs-triage tracking], - "engineering-change" => %w[enhancement needs-triage], - "bug-report" => %w[bug needs-triage], - "runtime-rollout" => %w[enhancement needs-triage runtime] - } - expected_ids = { - "prd" => %w[problem solution stories decisions testing out_of_scope], - "engineering-change" => %w[contract acceptance dependencies out_of_scope rollout], - "bug-report" => %w[current_behavior reproduction expected_behavior boundary], - "runtime-rollout" => %w[target controller candidate rollback stop_rules success out_of_scope] - } - required.each do |name| - file = File.join(ARGV.fetch(0), ".github/ISSUE_TEMPLATE/#{name}.yml") - form = YAML.load_file(file) - abort "#{file}: missing name" unless form["name"].is_a?(String) && form["name"].length > 3 - abort "#{file}: missing description" unless form["description"].is_a?(String) - abort "#{file}: missing body" unless form["body"].is_a?(Array) && !form["body"].empty? - ids = form["body"].map { |field| field["id"] }.compact - abort "#{file}: duplicate field id" unless ids.uniq == ids - labels = form.fetch("labels", []) - abort "#{file}: expected one category" unless (labels & categories).length == 1 - abort "#{file}: expected one state" unless (labels & states).length == 1 - abort "#{file}: incorrect labels" unless labels.sort == expected_labels.fetch(name).sort - abort "#{file}: missing contract fields" unless ids.sort == expected_ids.fetch(name).sort - form["body"].each do |field| - next if field["type"] == "markdown" - abort "#{file}: missing field id" unless field["id"] - abort "#{file}: missing field label" unless field.dig("attributes", "label") - abort "#{file}: field must be required" unless field.dig("validations", "required") == true - end - end - - config = YAML.load_file(File.join(ARGV.fetch(0), ".github/ISSUE_TEMPLATE/config.yml")) - abort "blank issues must be disabled" unless config["blank_issues_enabled"] == false - - def visible_relationships(template) - section = template.match(/^## Issue relationship\n(?.*?)(?=^## )/m) - abort "missing issue relationship section" unless section - section[:body].gsub(//m, "").lines.map(&:strip).reject(&:empty?) - end - - template = File.read(File.join(ARGV.fetch(0), ".github/pull_request_template.md")) - section = template.match(/^## Issue relationship\n(?.*?)(?=^## )/m) - visible = visible_relationships(template) - abort "unsafe default issue relationship: #{visible.inspect}" unless visible == ["None"] - abort "missing Refs example" unless section[:body].include?("Refs #123") - abort "missing Closes example" unless section[:body].include?("Closes #123") - - unsafe = "## Issue relationship\n\nRefs #1\nCloses #1\nNone\n\n## Next\n" - abort "unsafe relationship counterexample passed" if visible_relationships(unsafe) == ["None"] -' "$repo_root" - -grep -Fq 'native parent' "$repo_root/docs/agents/issue-tracker.md" -grep -Fq 'gh issue create --parent' "$repo_root/docs/agents/issue-tracker.md" -grep -Fq 'named controller' "$repo_root/docs/agents/issue-tracker.md" -# Backticks are literal Markdown. -# shellcheck disable=SC2016 -grep -Fq 'close`, `fix`, and `resolve`' "$repo_root/docs/agents/issue-tracker.md" -test -f "$repo_root/docs/agents/triage-labels.md" -# Backticks are literal Markdown. -# shellcheck disable=SC2016 -grep -Fq '`tracking`' "$repo_root/docs/agents/triage-labels.md" -# Backticks are literal Markdown. -# shellcheck disable=SC2016 -grep -Fq '`runtime`' "$repo_root/docs/agents/triage-labels.md" - -echo "issue lifecycle contract: ok" diff --git a/.github/scripts/test-issue-lifecycle-preflight.sh b/.github/scripts/test-issue-lifecycle-preflight.sh deleted file mode 100755 index 781dda686..000000000 --- a/.github/scripts/test-issue-lifecycle-preflight.sh +++ /dev/null @@ -1,805 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(git rev-parse --show-toplevel)" -preflight="$repo_root/.github/scripts/issue-lifecycle-preflight.rb" -tmp_dir="$(mktemp -d)" -trap 'rm -rf "$tmp_dir"' EXIT -test -f "$preflight" - -fake_bin="$tmp_dir/bin" -mkdir -p "$fake_bin" "$tmp_dir/state" -cat >"$fake_bin/gh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -method=GET -path= -query= -if_none_match= -args=("$@") -for ((index = 0; index < ${#args[@]}; index++)); do - case "${args[$index]}" in - --method|-X) method="${args[$((index + 1))]}" ;; - graphql) path=graphql; method=POST ;; - If-None-Match:*) if_none_match="${args[$index]#If-None-Match: }" ;; - query=*) query="${args[$index]#query=}" ;; - repos/*|repositories/*) path="${args[$index]}" ;; - esac -done -operation="${query%%[[:space:]]*}" -printf '%s\t%s\t%s\n' "$method" "$path" "$operation" >>"$TEST_API_LOG" -if [[ "$path" == graphql && "$operation" == mutation ]]; then - echo "GraphQL mutation is forbidden" >&2 - exit 1 -fi - -bump() { - local file="$TEST_FAKE_STATE/$1" count=0 - [[ ! -f "$file" ]] || count="$(<"$file")" - printf '%s' "$((count + 1))" | tee "$file" -} - -respond() { - local body="$1" etag="${2:-fixture}" media_type="${3:-github.v3; format=json}" - if [[ -n "$if_none_match" && "$if_none_match" == "W/\"$etag\"" ]]; then - printf 'HTTP/2.0 304 Not Modified\r\nEtag: W/"%s"\r\n\r\n' "$etag"; exit 1 - fi - if [[ "$path" == graphql && "${FIXTURE_MODE:-normal}" != missing-relationship ]]; then - body="${body//\{\"number\":3\}/\{\"number\":3,\"url\":\"https:\/\/github.com\/example\/repo\/issues\/3\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" - body="${body//\{\"number\":1\}/\{\"number\":1,\"url\":\"https:\/\/github.com\/example\/repo\/issues\/1\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" - body="${body//\"number\":3,\"parent\":null/\"number\":3,\"parent\":\{\"number\":1,\"url\":\"https:\/\/github.com\/example\/repo\/issues\/1\",\"repository\":\{\"nameWithOwner\":\"example\/repo\"\}\}}" - fi - local media_header= link_header= - [[ -z "$media_type" ]] || media_header="X-GitHub-Media-Type: ${media_type}"$'\r\n' - if [[ "${FIXTURE_MODE:-normal}" == live-link-drift && "$path" == repos/example/repo/labels* ]]; then - link_header=$'Link: