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: ; rel="prev"\r\n' - fi - printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nEtag: W/"%s"\r\nLast-Modified: Sat, 02 Aug 2026 00:00:00 GMT\r\nX-GitHub-Api-Version-Selected: 2026-03-10\r\n%s%s\r\n%s\n' "$etag" "$media_header" "$link_header" "$body" -} - -respond_with_next() { - if [[ -n "$if_none_match" && "$if_none_match" == 'W/"fixture"' ]]; then - printf 'HTTP/2.0 304 Not Modified\r\nEtag: W/"fixture"\r\n\r\n'; exit 1 - fi - printf 'HTTP/2.0 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nEtag: W/"fixture"\r\nLast-Modified: Sat, 02 Aug 2026 00:00:00 GMT\r\nLink: <%s>; rel="next"\r\nX-GitHub-Api-Version-Selected: 2026-03-10\r\nX-GitHub-Media-Type: github.v3; format=json\r\n\r\n%s\n' "$1" "$2" -} - -labels='[{"id":10,"node_id":"LA_enhancement","name":"enhancement","color":"a2eeef"},{"id":11,"node_id":"LA_ready","name":"ready-for-agent","color":"0e8a16"}]' -alice='{"id":19,"node_id":"U_alice","login":"alice"}' -assignees='[{"id":20,"node_id":"U_bob","login":"bob"},{"id":19,"node_id":"U_alice","login":"alice"}]' -if [[ "${FIXTURE_MODE:-normal}" == reordered ]]; then - labels='[{"color":"0e8a16","name":"ready-for-agent","node_id":"LA_ready","id":11},{"name":"enhancement","id":10,"color":"a2eeef","node_id":"LA_enhancement"}]' - assignees='[{"login":"alice","node_id":"U_alice","id":19},{"node_id":"U_bob","id":20,"login":"bob"}]' -elif [[ "${FIXTURE_MODE:-normal}" == post-state* ]]; then - assignees='[{"id":19,"node_id":"U_alice","login":"alice"}]' -fi - -case "$path" in - repos/example/repo/labels*) - if [[ "${FIXTURE_MODE:-normal}" == canonical-link ]]; then - body="$(ruby -rjson -e 'puts JSON.generate([{"id" => 10, "name" => "enhancement"}, {"id" => 11, "name" => "ready-for-agent"}] + (100..197).map { |id| {"id" => id, "name" => "label-#{id}"} })')" - respond_with_next 'https://api.github.com/repositories/123456/labels?per_page=100&page=2&after=cursor' "$body" - elif [[ "${FIXTURE_MODE:-normal}" == missing-link ]]; then - if [[ "$path" == *"&page=1"* ]]; then - respond "$(ruby -rjson -e 'puts JSON.generate(100.times.map { |i| {"id" => i, "name" => "label-#{i}"} })')" - else - call="$(bump labels2)"; body='[]'; etag=fixture - [[ "$call" -le 1 ]] || { body='[{"id":100,"name":"concurrent-label"}]'; etag=changed; } - respond "$body" "$etag" - fi - elif [[ "${FIXTURE_MODE:-normal}" == incomplete ]]; then - if [[ "$path" == *"&page=1"* ]]; then - respond_with_next 'https://api.github.com/repos/example/repo/labels?per_page=100&page=2' "$labels" - else - echo "simulated missing pagination page" >&2 - exit 1 - fi - else - respond "$labels" - fi - ;; - repositories/123456/labels*) - respond '[{"id":198,"name":"label-198"}]' - ;; - repos/example/repo/issues\?state=all*) - respond '[{"id":101,"number":1},{"id":103,"number":3},{"id":102,"number":2,"pull_request":{"url":"https://api.github.com/repos/example/repo/pulls/2"}}]' - ;; - repos/example/repo/issues/comments*) - call="$(bump comments)" - comments='[{"id":1001,"issue_url":"https://api.github.com/repos/example/repo/issues/1","body":"evidence"},{"id":1002,"issue_url":"https://api.github.com/repos/example/repo/issues/2","body":"review context"}]' - if [[ "${FIXTURE_MODE:-normal}" == live-drift || "${FIXTURE_MODE:-normal}" == capture-race && "$call" -gt 1 ]]; then - comments='[{"id":1001,"issue_url":"https://api.github.com/repos/example/repo/issues/1","body":"evidence changed concurrently"},{"id":1002,"issue_url":"https://api.github.com/repos/example/repo/issues/2","body":"review context"}]' - elif [[ "${FIXTURE_MODE:-normal}" == post-state-provenance ]]; then - comments='[{"id":1001,"issue_url":"https://api.github.com/repos/example/repo/issues/1","body":"evidence"},{"id":1002,"issue_url":"https://api.github.com/repos/example/repo/issues/2","body":"review context"},{"body":"completion evidence","id":1003,"issue_url":"https://api.github.com/repos/example/repo/issues/1"}]' - fi - etag=fixture - [[ "$call" -le 1 || "${FIXTURE_MODE:-normal}" != header-race && "${FIXTURE_MODE:-normal}" != capture-race ]] || etag=changed - respond "$comments" "$etag" - ;; - repos/example/repo/issues/events*) - events='[{"id":2001,"event":"labeled","issue":{"number":1,"url":"https://api.github.com/repos/example/repo/issues/1"}},{"id":2002,"event":"cross-referenced","issue":{"number":2,"url":"https://api.github.com/repos/example/repo/issues/2"}}]' - [[ "${FIXTURE_MODE:-normal}" != post-state-provenance ]] || events='[{"id":2001,"event":"labeled","issue":{"number":1,"url":"https://api.github.com/repos/example/repo/issues/1"}},{"id":2002,"event":"cross-referenced","issue":{"number":2,"url":"https://api.github.com/repos/example/repo/issues/2"}},{"event":"closed","id":2003,"issue":{"number":1,"url":"https://api.github.com/repos/example/repo/issues/1"}}]' - respond "$events" - ;; - repos/example/repo/issues/1) - issue_body='Issue one 研究'; issue_state=open; state_reason=null; issue_comments=1 - closed_at=null; closed_by=null; updated_at='"2026-08-02T00:00:00Z"' - dependencies='{"blocked_by":0,"blocking":0,"total_blocked_by":1,"total_blocking":0}' - if [[ "${FIXTURE_MODE:-normal}" == post-state* ]]; then - issue_body='Issue one repaired'; issue_state=closed; state_reason='"completed"' - closed_at='"2026-08-02T01:00:00Z"'; closed_by="$alice"; updated_at='"2026-08-02T01:00:00Z"' - dependencies='{"blocked_by":0,"blocking":0,"total_blocked_by":0,"total_blocking":0}' - [[ "${FIXTURE_MODE:-normal}" != post-state-provenance ]] || issue_comments=2 - [[ "${FIXTURE_MODE:-normal}" != post-state-provenance ]] || updated_at='"2026-08-02T02:00:00Z"' - fi - respond "{\"id\":101,\"node_id\":\"I_one\",\"url\":\"https://api.github.com/repos/example/repo/issues/1\",\"number\":1,\"comments\":$issue_comments,\"state\":\"$issue_state\",\"state_reason\":$state_reason,\"body\":\"$issue_body\",\"labels\":$labels,\"assignee\":$alice,\"assignees\":$assignees,\"closed_at\":$closed_at,\"closed_by\":$closed_by,\"parent_issue_url\":null,\"sub_issues_summary\":{\"completed\":1,\"percent_completed\":100,\"total\":1},\"issue_dependencies_summary\":$dependencies,\"updated_at\":$updated_at}" - ;; - repos/example/repo/issues/3) - call="$(bump issue3)" - [[ "$call" -le 1 || -n "$if_none_match" ]] || { echo "stability GET missing If-None-Match" >&2; exit 1; } - body=Blocker etag=fixture updated_at='"2026-08-01T00:00:00Z"' - dependencies='{"blocked_by":0,"blocking":1,"total_blocked_by":0,"total_blocking":1}' - if [[ "${FIXTURE_MODE:-normal}" == post-state* ]]; then - updated_at='"2026-08-02T01:00:00Z"' - dependencies='{"blocked_by":0,"blocking":0,"total_blocked_by":0,"total_blocking":0}' - fi - [[ "${FIXTURE_MODE:-normal}" != issue-detail-race || "$call" -le 1 ]] || { body='Blocker changed concurrently'; etag=changed; } - respond "{\"id\":103,\"node_id\":\"I_three\",\"url\":\"https://api.github.com/repos/example/repo/issues/3\",\"number\":3,\"comments\":0,\"state\":\"closed\",\"state_reason\":\"completed\",\"body\":\"$body\",\"labels\":[],\"assignee\":null,\"assignees\":[],\"closed_at\":\"2026-08-01T00:00:00Z\",\"closed_by\":$alice,\"parent_issue_url\":\"https://api.github.com/repos/example/repo/issues/1\",\"sub_issues_summary\":{\"completed\":0,\"percent_completed\":0,\"total\":0},\"issue_dependencies_summary\":$dependencies,\"updated_at\":$updated_at}" "$etag" - ;; - repos/example/repo/issues/2) - respond "{\"id\":102,\"node_id\":\"PR_two\",\"url\":\"https://api.github.com/repos/example/repo/issues/2\",\"number\":2,\"comments\":1,\"state\":\"open\",\"body\":\"Pull request conversation\",\"labels\":$labels,\"assignees\":[],\"pull_request\":{\"url\":\"https://api.github.com/repos/example/repo/pulls/2\"},\"updated_at\":\"2026-08-02T00:00:00Z\"}" - ;; - repos/example/repo/pulls/2) - call="$(bump pull2)" - state=open title=Stable - if [[ "${FIXTURE_MODE:-normal}" == closed-pr-race ]]; then - state=closed; [[ "$call" -le 1 ]] || { title='Changed concurrently'; etag=changed; } - fi - respond "{\"id\":202,\"node_id\":\"PR_two\",\"url\":\"https://api.github.com/repos/example/repo/pulls/2\",\"number\":2,\"state\":\"$state\",\"title\":\"$title\",\"head\":{\"ref\":\"feature\",\"sha\":\"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"},\"base\":{\"ref\":\"main\",\"sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"},\"commits\":1,\"changed_files\":2,\"review_comments\":1}" "${etag:-fixture}" - ;; - repos/example/repo/pulls/2/commits*) respond '[{"sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","commit":{"message":"Refs #1"}}]' ;; - repos/example/repo/pulls/2/files*) respond '[{"sha":"cccccccccccccccccccccccccccccccccccccccc","filename":"safe-copy.txt","status":"added"},{"sha":"cccccccccccccccccccccccccccccccccccccccc","filename":"safe.txt","status":"added"}]' ;; - repos/example/repo/pulls/2/reviews*) respond '[{"id":3001,"node_id":"PRR_review","state":"APPROVED","_links":{"pull_request":{"href":"https://api.github.com/repos/example/repo/pulls/2"}}}]' ;; - repos/example/repo/pulls/2/comments*) respond '[{"id":4001,"node_id":"PRRC_comment","pull_request_url":"https://api.github.com/repos/example/repo/pulls/2","body":"looks good"}]' ;; - repos/example/repo/commits/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/check-runs*) - respond '{"total_count":1,"check_runs":[{"id":5001,"node_id":"CR_check","head_sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","name":"CI","status":"completed","conclusion":"success"}]}' - ;; - repos/example/repo/commits/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb/statuses*) - respond '[{"id":6001,"node_id":"SC_status","context":"legacy","state":"success"}]' - ;; - graphql) - test "$(grep -o 'number url repository { nameWithOwner }' <<<"$query" | wc -l | tr -d ' ')" -eq 5 - call="$(bump graphql)" - media_type='github.v4; format=json' - [[ "${FIXTURE_MODE:-normal}" != graphql-media-race || "$call" -le 1 ]] || media_type='github.v4; format=json; drift=1' - [[ "${FIXTURE_MODE:-normal}" != graphql-media-missing || "$call" -le 1 ]] || media_type= - if [[ "${FIXTURE_MODE:-normal}" == post-state* ]]; then - respond '{"data":{"repository":{"id":"R_repo","nameWithOwner":"example/repo","defaultBranchRef":{"name":"main","target":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"issues":{"totalCount":2,"pageInfo":{"endCursor":null,"hasNextPage":false},"nodes":[{"number":1,"parent":null,"subIssues":{"nodes":[{"number":3}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[{"repository":{"nameWithOwner":"example/repo"},"url":"https://github.com/example/repo/pull/2","number":2}],"totalCount":1,"pageInfo":{"hasNextPage":false}}},{"number":3,"parent":{"number":1},"subIssues":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}}}]}}}}' fixture "$media_type" - elif [[ "${FIXTURE_MODE:-normal}" == reordered ]]; then - respond '{"data":{"repository":{"id":"R_repo","nameWithOwner":"example/repo","defaultBranchRef":{"name":"main","target":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"issues":{"totalCount":2,"nodes":[{"number":3,"parent":null,"subIssues":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blockedBy":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"blocking":{"totalCount":1,"nodes":[{"number":1}],"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}}},{"number":1,"parent":null,"subIssues":{"totalCount":1,"nodes":[{"number":3}],"pageInfo":{"hasNextPage":false}},"blockedBy":{"totalCount":1,"nodes":[{"number":3}],"pageInfo":{"hasNextPage":false}},"blocking":{"totalCount":0,"nodes":[],"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"totalCount":1,"nodes":[{"number":2,"url":"https://github.com/example/repo/pull/2","repository":{"nameWithOwner":"example/repo"}}],"pageInfo":{"hasNextPage":false}}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}' fixture "$media_type" - else - respond '{"data":{"repository":{"id":"R_repo","nameWithOwner":"example/repo","defaultBranchRef":{"name":"main","target":{"oid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}},"issues":{"totalCount":2,"pageInfo":{"endCursor":null,"hasNextPage":false},"nodes":[{"number":1,"parent":null,"subIssues":{"nodes":[{"number":3}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[{"number":3}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[{"repository":{"nameWithOwner":"example/repo"},"url":"https://github.com/example/repo/pull/2","number":2}],"totalCount":1,"pageInfo":{"hasNextPage":false}}},{"number":3,"parent":null,"subIssues":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blockedBy":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}},"blocking":{"nodes":[{"number":1}],"totalCount":1,"pageInfo":{"hasNextPage":false}},"closedByPullRequestsReferences":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false}}}]}}}}' fixture "$media_type" - fi - ;; - *) echo "unexpected GitHub API path: $path" >&2; exit 1 ;; -esac -EOF -chmod +x "$fake_bin/gh" - -api_log="$tmp_dir/api.log" -export TEST_API_LOG="$api_log" TEST_FAKE_STATE="$tmp_dir/state" -capture() { - local mode="$1" output="$2" - rm -f "$TEST_FAKE_STATE"/* - FIXTURE_MODE="$mode" PATH="$fake_bin:$PATH" ruby "$preflight" capture \ - --repo example/repo --controller "Codex /root" --output "$output" -} - -verify() { - local bundle="$1" - shift - PATH="$fake_bin:$PATH" ruby "$preflight" verify \ - --repo example/repo --controller "Codex /root" --bundle "$bundle" "$@" -} - -verify_live() { - local mode="$1" bundle="$2" - rm -f "$TEST_FAKE_STATE"/* - FIXTURE_MODE="$mode" PATH="$fake_bin:$PATH" ruby "$preflight" verify \ - --repo example/repo --controller "Codex /root" --bundle "$bundle" --live -} - -plan_restore() { - local mode="$1" bundle="$2" forward_plan="$3" - rm -f "$TEST_FAKE_STATE"/* - FIXTURE_MODE="$mode" PATH="$fake_bin:$PATH" ruby "$preflight" plan-restore \ - --repo example/repo --controller "Codex /root" --bundle "$bundle" --forward-plan "$forward_plan" -} - -plan_restore_dry() { - local mode="$1" bundle="$2" forward_plan="$3" reverse_plan="$4" receipt="$5" post_bundle="$6" - rm -f "$TEST_FAKE_STATE"/* - FIXTURE_MODE="$mode" PATH="$fake_bin:$PATH" ruby "$preflight" plan-restore \ - --repo example/repo --controller "Codex /root" --bundle "$bundle" --forward-plan "$forward_plan" \ - --reverse-plan "$reverse_plan" --receipt "$receipt" --post-bundle "$post_bundle" -} - -plan_restore_fails() { - local mode="$1" expected="$2" output exit_code - shift 2 - rm -f "$TEST_FAKE_STATE"/* - set +e - output="$(FIXTURE_MODE="$mode" PATH="$fake_bin:$PATH" ruby "$preflight" plan-restore \ - --repo example/repo --controller "Codex /root" "$@" 2>"$tmp_dir/plan-error.log")" - exit_code=$? - set -e - test "$exit_code" -eq 2 - test -z "$output" - if ! grep -Fq "ERROR issue lifecycle preflight: $expected" "$tmp_dir/plan-error.log"; then - echo "plan-restore did not report the expected error: $(<"$tmp_dir/plan-error.log")" >&2 - exit 1 - fi -} - -copy_bundle() { - cp -R "$tmp_dir/bundle-a" "$tmp_dir/$1" -} - -rehash() { - local bundle="$1" filename="$2" - (cd "$bundle" && sha256sum "$filename" >"$filename.sha256") -} - -resign_preflight() { - local bundle="$1" - rehash "$bundle" preflight.json - ruby -rdigest -rjson -e ' - bundle = ARGV.fetch(0) - path = File.join(bundle, "manifest.json") - object = JSON.parse(File.binread(path)) - object.fetch("preflight")["sha256"] = Digest::SHA256.file(File.join(bundle, "preflight.json")).hexdigest - File.binwrite(path, JSON.generate(object) + "\n") - ' "$bundle" - rehash "$bundle" manifest.json -} - -set_json_field() { - local path="$1" key="$2" value="$3" - ruby -rjson -e ' - path, key, value = ARGV - object = JSON.parse(File.binread(path)) - object[key] = JSON.parse(value) - File.binwrite(path, JSON.generate(object) + "\n") - ' "$path" "$key" "$value" -} -remove_link_provenance() { ruby -rjson -e 'path = ARGV.fetch(0); object = JSON.parse(File.binread(path)); object.fetch("pages").each { |page| page.delete("link") }; File.binwrite(path, JSON.generate(object) + "\n")' "$1"; } -verify_fails() { - local name="$1" bundle="$2" expected="$3" - shift 3 - set +e - output="$(verify "$bundle" "$@" 2>&1)" - exit_code=$? - set -e - test "$exit_code" -eq 2 - if ! grep -Fq "ERROR issue lifecycle preflight:" <<<"$output" || ! grep -Fq "$expected" <<<"$output"; then - echo "$name did not report the expected verifier error: $output" >&2 - exit 1 - fi -} -verify_live_fails() { - local mode="$1" bundle="$2" - local before output exit_code expected - case "$mode" in - live-drift) expected="live verification drift: Issue/PR graph changed" ;; - live-link-drift) expected="live verification drift: page/header inventory changed" ;; - incomplete) expected="simulated missing pagination page" ;; - graphql-media-*) expected="manifest GraphQL media type does not match page inventory" ;; - *) expected="capture stability drift:" ;; - esac - before="$(find "$bundle" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" - set +e - output="$(verify_live "$mode" "$bundle" 2>&1)" - exit_code=$? - set -e - test "$exit_code" -eq 2 - grep -Fq "ERROR issue lifecycle preflight:" <<<"$output" && grep -Fq "$expected" <<<"$output" - test "$(find "$bundle" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" = "$before" -} -: >"$api_log" -capture normal "$tmp_dir/bundle-a" -capture reordered "$tmp_dir/bundle-b" -capture live-link-drift "$tmp_dir/bundle-link-header" -capture canonical-link "$tmp_dir/bundle-canonical-link" -verify "$tmp_dir/bundle-canonical-link" -bundle_before="$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" -api_calls_before="$(wc -l <"$api_log" | tr -d ' ')" -verify "$tmp_dir/bundle-a" -test "$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" = "$bundle_before" -test "$(wc -l <"$api_log" | tr -d ' ')" = "$api_calls_before" -expected_files=$'manifest.json\nmanifest.json.sha256\npreflight.json\npreflight.json.sha256' -actual_files="$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -print | sed 's|.*/||' | sort)" -test "$actual_files" = "$expected_files" -( - cd "$tmp_dir/bundle-a" - sha256sum --check --strict preflight.json.sha256 manifest.json.sha256 >/dev/null -) -test "$(awk '{print $1}' "$tmp_dir/bundle-a/preflight.json.sha256")" = \ - "$(awk '{print $1}' "$tmp_dir/bundle-b/preflight.json.sha256")" - -ruby -rjson -rdigest - "$tmp_dir/bundle-a" "$tmp_dir/forward-plan.json" <<'RUBY' -bundle, output = ARGV -preflight_sha = File.read(File.join(bundle, "preflight.json.sha256")).split.first -before = { - "assignees" => %w[alice bob], - "blocked_by" => [3], - "body" => "Issue one 研究", - "labels" => %w[enhancement ready-for-agent], - "parent" => nil, - "state" => {"reason" => nil, "value" => "open"} -} -after = before.merge( - "assignees" => ["alice"], - "blocked_by" => [], - "body" => "Issue one repaired", - "state" => {"reason" => "completed", "value" => "closed"} -) -plan = { - "controller" => "Codex /root", - "default_branch" => "main", - "default_branch_sha" => "a" * 40, - "operations" => [{"number" => 1, "precondition" => before, "target" => after}], - "preflight_manifest_sha256" => Digest::SHA256.file(File.join(bundle, "manifest.json")).hexdigest, - "preflight_sha256" => preflight_sha, - "repository" => "example/repo", - "schema" => "monday.issue_lifecycle_forward_plan.v1", - "target" => "GitHub Issue and pull request metadata in example/repo" -} -File.binwrite(output, JSON.generate(plan) + "\n") -RUBY - -plan_tree_before="$(find "$tmp_dir/bundle-a" -type f -exec sha256sum {} \; | sort; sha256sum "$tmp_dir/forward-plan.json")" -reverse_plan="$(plan_restore normal "$tmp_dir/bundle-a" "$tmp_dir/forward-plan.json")" -test "$(find "$tmp_dir/bundle-a" -type f -exec sha256sum {} \; | sort; sha256sum "$tmp_dir/forward-plan.json")" = "$plan_tree_before" -ruby -rjson -rdigest - "$tmp_dir/forward-plan.json" "$reverse_plan" <<'RUBY' -forward_path, reverse_json = ARGV -forward = JSON.parse(File.binread(forward_path)) -reverse = JSON.parse(reverse_json) -operation = forward.fetch("operations").fetch(0) -expected = { - "controller" => forward.fetch("controller"), - "default_branch" => forward.fetch("default_branch"), - "default_branch_sha" => forward.fetch("default_branch_sha"), - "forward_plan_sha256" => Digest::SHA256.file(forward_path).hexdigest, - "operations" => [{"number" => 1, "precondition" => operation.fetch("target"), "target" => operation.fetch("precondition")}], - "preflight_manifest_sha256" => forward.fetch("preflight_manifest_sha256"), - "preflight_sha256" => forward.fetch("preflight_sha256"), - "repository" => forward.fetch("repository"), - "schema" => "monday.issue_lifecycle_reverse_plan.v1", - "target" => forward.fetch("target") -} -abort "wrong reverse plan" unless reverse == expected -abort "reverse plan is not canonical" unless reverse_json + "\n" == JSON.generate(reverse) + "\n" -RUBY -printf '%s\n' "$reverse_plan" >"$tmp_dir/reverse-plan.json" -cp "$tmp_dir/forward-plan.json" "$tmp_dir/forward-duplicate-reason.json" -ruby -rjson -e 'path = ARGV.fetch(0); plan = JSON.parse(File.binread(path)); plan.dig("operations", 0, "target", "state")["reason"] = "duplicate"; File.binwrite(path, JSON.generate(plan) + "\n")' "$tmp_dir/forward-duplicate-reason.json" -test -n "$(plan_restore normal "$tmp_dir/bundle-a" "$tmp_dir/forward-duplicate-reason.json")" -capture post-state "$tmp_dir/post-bundle" -ruby -rjson -rdigest - "$tmp_dir/bundle-a" "$tmp_dir/forward-plan.json" "$tmp_dir/reverse-plan.json" "$tmp_dir/post-bundle" "$tmp_dir/receipt.json" <<'RUBY' -bundle, forward_path, reverse_path, post_bundle, output = ARGV -forward = JSON.parse(File.binread(forward_path)) -operation = forward.fetch("operations").fetch(0) -post_manifest = JSON.parse(File.binread(File.join(post_bundle, "manifest.json"))) -digest = ->(object) { Digest::SHA256.hexdigest(JSON.generate(object) + "\n") } -receipt = { - "api" => post_manifest.fetch("api"), - "controller" => forward.fetch("controller"), - "counts" => post_manifest.fetch("counts"), - "default_branch" => post_manifest.fetch("default_branch"), - "default_branch_sha" => post_manifest.fetch("default_branch_sha"), - "forward_plan_sha256" => Digest::SHA256.file(forward_path).hexdigest, - "operations" => [{ - "comment_ids" => [], - "event_ids" => [], - "number" => operation.fetch("number"), - "precondition_sha256" => digest.call(operation.fetch("precondition")), - "result" => "passed", - "target_sha256" => digest.call(operation.fetch("target")) - }], - "pages" => post_manifest.fetch("pages"), - "postflight_manifest_sha256" => Digest::SHA256.file(File.join(post_bundle, "manifest.json")).hexdigest, - "postflight_sha256" => post_manifest.dig("preflight", "sha256"), - "preflight_manifest_sha256" => Digest::SHA256.file(File.join(bundle, "manifest.json")).hexdigest, - "preflight_sha256" => forward.fetch("preflight_sha256"), - "repository" => forward.fetch("repository"), - "reverse_plan_sha256" => Digest::SHA256.file(reverse_path).hexdigest, - "schema" => "monday.issue_lifecycle_receipt.v1", - "target" => forward.fetch("target") -} -File.binwrite(output, JSON.generate(receipt) + "\n") -RUBY -dry_inputs_before="$(find "$tmp_dir/bundle-a" "$tmp_dir/post-bundle" -type f -exec sha256sum {} \; | sort; sha256sum "$tmp_dir/forward-plan.json" "$tmp_dir/reverse-plan.json" "$tmp_dir/receipt.json")" -dry_reverse="$(plan_restore_dry post-state "$tmp_dir/bundle-a" "$tmp_dir/forward-plan.json" "$tmp_dir/reverse-plan.json" "$tmp_dir/receipt.json" "$tmp_dir/post-bundle")" -test "$dry_reverse" = "$reverse_plan" -test "$(find "$tmp_dir/bundle-a" "$tmp_dir/post-bundle" -type f -exec sha256sum {} \; | sort; sha256sum "$tmp_dir/forward-plan.json" "$tmp_dir/reverse-plan.json" "$tmp_dir/receipt.json")" = "$dry_inputs_before" - -capture post-state-provenance "$tmp_dir/post-provenance" -ruby -rjson -rdigest - "$tmp_dir/receipt.json" "$tmp_dir/post-provenance" "$tmp_dir/receipt-provenance.json" <<'RUBY' -source, post_bundle, output = ARGV -receipt = JSON.parse(File.binread(source)) -manifest = JSON.parse(File.binread(File.join(post_bundle, "manifest.json"))) -receipt["counts"] = manifest.fetch("counts") -receipt.fetch("operations").first["comment_ids"] = [1003] -receipt.fetch("operations").first["event_ids"] = [2003] -receipt["pages"] = manifest.fetch("pages") -receipt["postflight_manifest_sha256"] = Digest::SHA256.file(File.join(post_bundle, "manifest.json")).hexdigest -receipt["postflight_sha256"] = manifest.dig("preflight", "sha256") -File.binwrite(output, JSON.generate(receipt) + "\n") -RUBY -test "$(plan_restore_dry post-state-provenance "$tmp_dir/bundle-a" "$tmp_dir/forward-plan.json" "$tmp_dir/reverse-plan.json" "$tmp_dir/receipt-provenance.json" "$tmp_dir/post-provenance")" = "$reverse_plan" - -for mutation in stale-precondition unsupported-field no-op duplicate unknown-issue relationship-cycle unknown-label trailing-hyphen-login consecutive-hyphen-login numeric-login stale-manifest-identity stale-identity; do - cp "$tmp_dir/forward-plan.json" "$tmp_dir/forward-$mutation.json" - ruby -rjson -e ' - path, mutation = ARGV - plan = JSON.parse(File.binread(path)) - operation = plan.fetch("operations").first - case mutation - when "stale-precondition" then operation.fetch("precondition")["body"] = "stale" - when "unsupported-field" then operation.fetch("target")["title"] = "not supported" - when "no-op" then operation["target"] = operation.fetch("precondition") - when "duplicate" then plan.fetch("operations") << JSON.parse(JSON.generate(operation)) - when "unknown-issue" then operation["number"] = 2 - when "relationship-cycle" then operation.fetch("target")["parent"] = 3 - when "unknown-label" then operation.fetch("target")["labels"] = ["missing-label"] - when "trailing-hyphen-login" then operation.fetch("target")["assignees"] = ["alice-"] - when "consecutive-hyphen-login" then operation.fetch("target")["assignees"] = ["alice--bob"] - when "numeric-login" then operation.fetch("target")["assignees"] = [123] - when "stale-manifest-identity" then plan["preflight_manifest_sha256"] = "0" * 64 - when "stale-identity" then plan["default_branch_sha"] = "0" * 40 - end - File.binwrite(path, JSON.generate(plan) + "\n") - ' "$tmp_dir/forward-$mutation.json" "$mutation" - case "$mutation" in - stale-precondition) expected='forward plan Issue #1 precondition does not match preflight' ;; - unsupported-field) expected='forward plan Issue #1 state schema is invalid' ;; - no-op) expected='forward plan Issue #1 operation is a no-op' ;; - duplicate) expected='forward plan contains duplicate Issue #1' ;; - unknown-issue) expected='forward plan references unknown Issue #2' ;; - relationship-cycle) expected='forward plan parent relationships contain a cycle' ;; - unknown-label|trailing-hyphen-login|consecutive-hyphen-login|numeric-login) expected='forward plan Issue #1 state identity is invalid' ;; - stale-manifest-identity|stale-identity) expected='forward plan identity is invalid' ;; - esac - plan_restore_fails normal "$expected" --bundle "$tmp_dir/bundle-a" --forward-plan "$tmp_dir/forward-$mutation.json" -done -copy_bundle cross-repo -ruby -rjson -e ' - path = ARGV.fetch(0) - graph = JSON.parse(File.binread(path)) - reference = graph.fetch("items").find { |item| item["number"] == 1 }.dig("relationships", "blocked_by", 0) - reference.fetch("repository")["nameWithOwner"] = "other/repo" - reference["url"] = "https://github.com/other/repo/issues/3" - File.binwrite(path, JSON.generate(graph) + "\n") -' "$tmp_dir/cross-repo/preflight.json" -resign_preflight "$tmp_dir/cross-repo" -cp "$tmp_dir/forward-plan.json" "$tmp_dir/forward-cross-repo.json" -ruby -rjson -rdigest -e ' - path, bundle = ARGV - plan = JSON.parse(File.binread(path)) - plan["preflight_manifest_sha256"] = Digest::SHA256.file(File.join(bundle, "manifest.json")).hexdigest - plan["preflight_sha256"] = File.read(File.join(bundle, "preflight.json.sha256")).split.first - File.binwrite(path, JSON.generate(plan) + "\n") -' "$tmp_dir/forward-cross-repo.json" "$tmp_dir/cross-repo" -plan_restore_fails normal 'preflight Issue #1 relationship scope is unsupported' \ - --bundle "$tmp_dir/cross-repo" --forward-plan "$tmp_dir/forward-cross-repo.json" -plan_restore_fails live-drift 'pre-mutation live drift: Issue/PR graph changed' \ - --bundle "$tmp_dir/bundle-a" --forward-plan "$tmp_dir/forward-plan.json" - -cp "$tmp_dir/reverse-plan.json" "$tmp_dir/reverse-tampered.json" -ruby -rjson -e 'path = ARGV.fetch(0); plan = JSON.parse(File.binread(path)); plan.fetch("operations").first.fetch("target")["body"] = "tampered"; File.binwrite(path, JSON.generate(plan) + "\n")' "$tmp_dir/reverse-tampered.json" -plan_restore_fails post-state 'saved reverse plan does not match the exact derived inverse' \ - --bundle "$tmp_dir/bundle-a" --forward-plan "$tmp_dir/forward-plan.json" --reverse-plan "$tmp_dir/reverse-tampered.json" \ - --receipt "$tmp_dir/receipt.json" --post-bundle "$tmp_dir/post-bundle" - -cp "$tmp_dir/receipt.json" "$tmp_dir/receipt-tampered.json" -ruby -rjson -e 'path = ARGV.fetch(0); receipt = JSON.parse(File.binread(path)); receipt.fetch("operations").first["result"] = "failed"; File.binwrite(path, JSON.generate(receipt) + "\n")' "$tmp_dir/receipt-tampered.json" -plan_restore_fails post-state 'restoration receipt identity is invalid' \ - --bundle "$tmp_dir/bundle-a" --forward-plan "$tmp_dir/forward-plan.json" --reverse-plan "$tmp_dir/reverse-plan.json" \ - --receipt "$tmp_dir/receipt-tampered.json" --post-bundle "$tmp_dir/post-bundle" - -for mutation in unsupported-metadata pr-metadata closing-reference derived-relationship assignee-summary parent-summary sub-issues-summary dependencies-summary closure-metadata updated-at comment-rewrite; do - cp -R "$tmp_dir/post-bundle" "$tmp_dir/post-$mutation" - ruby -rjson -e ' - path, mutation = ARGV - graph = JSON.parse(File.binread(path)) - issue = graph.fetch("items").find { |item| item["number"] == 1 } - case mutation - when "unsupported-metadata" then issue.fetch("issue")["node_id"] = "I_concurrent" - when "pr-metadata" then graph.fetch("items").find { |item| item["kind"] == "pull_request" }.dig("pull_request", "metadata")["title"] = "concurrent" - when "closing-reference" then issue.fetch("relationships")["closed_by_pull_requests"] = [] - when "derived-relationship" then issue.fetch("relationships")["sub_issues"] = [] - when "assignee-summary" then graph.fetch("items").find { |item| item["number"] == 3 }.fetch("issue")["assignee"] = {"id" => 99, "login" => "mallory"} - when "parent-summary" then graph.fetch("items").find { |item| item["number"] == 3 }.fetch("issue")["parent_issue_url"] = "https://api.github.com/repos/example/repo/issues/99" - when "sub-issues-summary" then issue.fetch("issue").fetch("sub_issues_summary")["total"] = 99 - when "dependencies-summary" then graph.fetch("items").find { |item| item["number"] == 3 }.fetch("issue").fetch("issue_dependencies_summary")["total_blocking"] = 99 - when "closure-metadata" then graph.fetch("items").find { |item| item["number"] == 3 }.fetch("issue")["closed_at"] = "2026-08-02T03:00:00Z" - when "updated-at" then issue.fetch("issue")["updated_at"] = "not-a-timestamp" - when "comment-rewrite" then issue.fetch("comments").first["body"] = "rewritten" - end - File.binwrite(path, JSON.generate(graph) + "\n") - ' "$tmp_dir/post-$mutation/preflight.json" "$mutation" - resign_preflight "$tmp_dir/post-$mutation" - case "$mutation" in - unsupported-metadata) expected='post-state Issue #1 unsupported metadata drift' ;; - pr-metadata) expected='post-state PR #2 metadata drift' ;; - closing-reference) expected='post-state Issue #1 closing-reference drift' ;; - derived-relationship) expected='post-state Issue #1 derived relationship drift' ;; - assignee-summary) expected='post-state Issue #3 assignee summary is inconsistent' ;; - parent-summary) expected='post-state Issue #3 parent summary is inconsistent' ;; - sub-issues-summary) expected='post-state Issue #1 sub-issues summary is inconsistent' ;; - dependencies-summary) expected='post-state Issue #3 dependency summary is inconsistent' ;; - closure-metadata) expected='post-state Issue #3 closure metadata drift' ;; - updated-at) expected='post-state Issue #1 updated_at is invalid' ;; - comment-rewrite) expected='post-state Issue #1 comments provenance is not append-only' ;; - esac - plan_restore_fails post-state "$expected" \ - --bundle "$tmp_dir/bundle-a" --forward-plan "$tmp_dir/forward-plan.json" --reverse-plan "$tmp_dir/reverse-plan.json" \ - --receipt "$tmp_dir/receipt.json" --post-bundle "$tmp_dir/post-$mutation" -done -plan_restore_fails post-state 'post-state Issue #3 derived relationship drift' \ - --bundle "$tmp_dir/bundle-a" --forward-plan "$tmp_dir/forward-plan.json" --reverse-plan "$tmp_dir/reverse-plan.json" \ - --receipt "$tmp_dir/receipt.json" --post-bundle "$tmp_dir/bundle-a" -plan_restore_fails normal 'restoration live drift: Issue/PR graph changed' \ - --bundle "$tmp_dir/bundle-a" --forward-plan "$tmp_dir/forward-plan.json" --reverse-plan "$tmp_dir/reverse-plan.json" \ - --receipt "$tmp_dir/receipt.json" --post-bundle "$tmp_dir/post-bundle" - -set +e -apply_stdout="$(PATH="$fake_bin:$PATH" ruby "$preflight" apply --repo example/repo --controller "Codex /root" 2>"$tmp_dir/apply-error.log")" -apply_exit=$? -set -e -test "$apply_exit" -eq 2 && test -z "$apply_stdout" -grep -Fq 'unsupported operation "apply"' "$tmp_dir/apply-error.log" - -ruby -rjson - "$tmp_dir/bundle-a" <<'RUBY' -bundle = ARGV.fetch(0) -graph = JSON.parse(File.read(File.join(bundle, "preflight.json"))) -manifest = JSON.parse(File.read(File.join(bundle, "manifest.json"))) -abort "wrong graph schema" unless graph.fetch("schema") == "monday.issue_lifecycle_preflight.v1" -abort "missing graph" unless graph.fetch("label_catalog").length == 2 && graph.fetch("items").map { |item| item.fetch("number") } == [1, 2, 3] -issue = graph.fetch("items").find { |item| item["number"] == 1 } -pull = graph.fetch("items").find { |item| item["number"] == 2 } -blocker = graph.fetch("items").find { |item| item["number"] == 3 } -abort "incomplete Issue metadata" unless issue.dig("issue", "body") == "Issue one 研究" && %w[body labels assignees state].all? { |field| issue.fetch("issue").key?(field) } && issue["comments"].length == 1 && issue["events"].length == 1 && issue.dig("relationships", "blocked_by").map { |entry| entry["number"] } == [3] -abort "ambiguous blocker" unless issue.dig("relationships", "blocked_by", 0, "repository", "nameWithOwner") == "example/repo" -abort "ambiguous parent" unless blocker.dig("relationships", "parent", "repository", "nameWithOwner") == "example/repo" -abort "missing close ref" unless issue.dig("relationships", "closed_by_pull_requests").map { |entry| entry["number"] } == [2] -abort "missing PR evidence" unless pull.dig("pull_request", "metadata", "head", "sha") == "b" * 40 && pull.dig("pull_request", "metadata", "base", "sha") == "a" * 40 && %w[commits files reviews review_comments statuses].all? { |key| pull.dig("pull_request", key).is_a?(Array) } && pull.dig("pull_request", "check_runs", "total_count") == 1 -abort "missing same-blob files" unless pull.dig("pull_request", "files").map { |file| file["filename"] } == %w[safe-copy.txt safe.txt] -abort "wrong manifest" unless manifest.fetch("schema") == "monday.issue_lifecycle_manifest.v1" && manifest.dig("api", "rest_version") == "2026-03-10" && manifest.fetch("target").include?("example/repo") && manifest.fetch("exclusions").include?("GitHub metadata mutation") -abort "wrong GraphQL media type" unless manifest.dig("api", "graphql_media_type") == "github.v4; format=json" -abort "wrong controller" unless manifest["controller"] == "Codex /root" -abort "wrong main" unless manifest["default_branch_sha"] == "a" * 40 -abort "missing conditional stability pages" unless manifest.fetch("pages").any? { |page| page["phase"] == "stability_check" && page["protocol"] == "rest" } && manifest.fetch("pages").select { |page| page["phase"] == "stability_check" && page["protocol"] == "rest" }.all? { |page| page["status"] == 304 } -abort "wrong counts" unless manifest.fetch("counts") == {"issues" => 2, "pull_requests" => 1, "labels" => 2, "issue_comments" => 2, "issue_events" => 2} -RUBY - -ruby -rjson - "$tmp_dir/bundle-link-header/manifest.json" <<'RUBY' -pages = JSON.parse(File.read(ARGV.fetch(0))).fetch("pages").select { |page| page["protocol"] == "rest" && page["request"].include?("/labels?") } -abort "capture Link was not recorded" unless pages.find { |page| page["phase"] == "capture" }.fetch("link").include?('rel="prev"') -abort "304 stability incorrectly copied capture Link" unless pages.find { |page| page["phase"] == "stability_check" }.fetch("link").nil? -RUBY - -local_api_calls_before="$(wc -l <"$api_log" | tr -d ' ')" - -copy_bundle verify-v1-pages -remove_link_provenance "$tmp_dir/verify-v1-pages/manifest.json" -rehash "$tmp_dir/verify-v1-pages" manifest.json -verify "$tmp_dir/verify-v1-pages" -cp -R "$tmp_dir/bundle-canonical-link" "$tmp_dir/verify-v1-multipage" -remove_link_provenance "$tmp_dir/verify-v1-multipage/manifest.json" -rehash "$tmp_dir/verify-v1-multipage" manifest.json -verify_fails v1-multipage "$tmp_dir/verify-v1-multipage" "legacy manifest cannot verify multi-page REST provenance" - -copy_bundle verify-missing -rm "$tmp_dir/verify-missing/manifest.json.sha256" -verify_fails missing "$tmp_dir/verify-missing" "bundle file set is invalid" - -copy_bundle verify-extra -touch "$tmp_dir/verify-extra/unexpected" -verify_fails extra "$tmp_dir/verify-extra" "bundle file set is invalid" - -copy_bundle verify-symlink -rm "$tmp_dir/verify-symlink/preflight.json" -ln -s "$tmp_dir/bundle-a/preflight.json" "$tmp_dir/verify-symlink/preflight.json" -verify_fails symlink "$tmp_dir/verify-symlink" "bundle entry preflight.json is not a regular file" - -copy_bundle verify-tampered -printf ' ' >>"$tmp_dir/verify-tampered/preflight.json" -verify_fails tampered "$tmp_dir/verify-tampered" "preflight.json digest mismatch" - -copy_bundle verify-digest -printf '%064d preflight.json\n' 0 >"$tmp_dir/verify-digest/preflight.json.sha256" -verify_fails digest "$tmp_dir/verify-digest" "preflight.json digest mismatch" - -copy_bundle verify-noncanonical -ruby -rjson -e 'path = ARGV.fetch(0); File.binwrite(path, JSON.pretty_generate(JSON.parse(File.binread(path))) + "\n")' "$tmp_dir/verify-noncanonical/preflight.json" -rehash "$tmp_dir/verify-noncanonical" preflight.json -verify_fails noncanonical "$tmp_dir/verify-noncanonical" "preflight.json is not canonical JSON" - -copy_bundle verify-schema -set_json_field "$tmp_dir/verify-schema/manifest.json" schema '"monday.issue_lifecycle_manifest.v2"' -rehash "$tmp_dir/verify-schema" manifest.json -verify_fails schema "$tmp_dir/verify-schema" "manifest schema is invalid" - -verify_fails scope "$tmp_dir/bundle-a" "manifest scope is invalid" --repo other/repo - -copy_bundle verify-api -set_json_field "$tmp_dir/verify-api/manifest.json" api '{"rest_version":"wrong"}' -rehash "$tmp_dir/verify-api" manifest.json -verify_fails api "$tmp_dir/verify-api" "manifest API provenance is invalid" - -copy_bundle verify-pages -set_json_field "$tmp_dir/verify-pages/manifest.json" pages '[]' -rehash "$tmp_dir/verify-pages" manifest.json -verify_fails pages "$tmp_dir/verify-pages" "manifest page inventory is empty" - -copy_bundle verify-counts -set_json_field "$tmp_dir/verify-counts/manifest.json" counts '{"issues":999}' -rehash "$tmp_dir/verify-counts" manifest.json -verify_fails counts "$tmp_dir/verify-counts" "manifest counts do not match preflight" - -copy_bundle verify-branch -set_json_field "$tmp_dir/verify-branch/manifest.json" default_branch_sha '"cccccccccccccccccccccccccccccccccccccccc"' -rehash "$tmp_dir/verify-branch" manifest.json -verify_fails branch "$tmp_dir/verify-branch" "manifest preflight identity is invalid" - -copy_bundle verify-item-schema -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - object.fetch("items").first["unexpected"] = true - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-item-schema/preflight.json" -resign_preflight "$tmp_dir/verify-item-schema" -verify_fails item-schema "$tmp_dir/verify-item-schema" "preflight Issue #1 schema is invalid" - -copy_bundle verify-pr-schema -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - object.fetch("items").find { |item| item["kind"] == "pull_request" }.fetch("pull_request").delete("commits") - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-pr-schema/preflight.json" -resign_preflight "$tmp_dir/verify-pr-schema" -verify_fails pr-schema "$tmp_dir/verify-pr-schema" "preflight PR #2 schema is invalid" - -copy_bundle verify-relationship-schema -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - object.fetch("items").find { |item| item["kind"] == "issue" }.fetch("relationships").delete("blocked_by") - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-relationship-schema/preflight.json" -resign_preflight "$tmp_dir/verify-relationship-schema" -verify_fails relationship-schema "$tmp_dir/verify-relationship-schema" "preflight Issue #1 schema is invalid" - -copy_bundle verify-rest-etag -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - object.fetch("pages").select { |page| page["protocol"] == "rest" }.each { |page| page["etag"] = nil } - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-rest-etag/manifest.json" -rehash "$tmp_dir/verify-rest-etag" manifest.json -verify_fails rest-etag "$tmp_dir/verify-rest-etag" "manifest REST ETag provenance is invalid" - -copy_bundle verify-page-omission -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - object.fetch("pages").reject! { |page| page["request"] == "repos/example/repo/issues/1" } - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-page-omission/manifest.json" -rehash "$tmp_dir/verify-page-omission" manifest.json -verify_fails page-omission "$tmp_dir/verify-page-omission" "manifest REST page inventory does not match preflight" - -copy_bundle verify-link-target -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - page = object.fetch("pages").find { |entry| entry["phase"] == "capture" && entry["request"].include?("/labels?") } - page["link"] = %q(; rel="next") - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-link-target/manifest.json" -rehash "$tmp_dir/verify-link-target" manifest.json -verify_fails link-target "$tmp_dir/verify-link-target" "manifest REST pagination chain has an extra page" - -cp -R "$tmp_dir/bundle-canonical-link" "$tmp_dir/verify-page-size" -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - object.fetch("pages").each do |page| - page["request"].sub!("per_page=100", "per_page=1") if page["request"].include?("repositories/123456/labels") - page["link"].sub!("per_page=100", "per_page=1") if page["link"] - end - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-page-size/manifest.json" -rehash "$tmp_dir/verify-page-size" manifest.json -verify_fails page-size "$tmp_dir/verify-page-size" "manifest REST Link scope is invalid" - -copy_bundle verify-entry-identity -ruby -rjson -e ' - path = ARGV.fetch(0) - object = JSON.parse(File.binread(path)) - object.fetch("items").first.fetch("comments").first.clear - File.binwrite(path, JSON.generate(object) + "\n") -' "$tmp_dir/verify-entry-identity/preflight.json" -resign_preflight "$tmp_dir/verify-entry-identity" -verify_fails entry-identity "$tmp_dir/verify-entry-identity" "preflight item #1 metadata is invalid" - -for field in comment comment-host relationship-host relationship-repo relationship-kind relationship-number label commit review review-comment check-run status; do - expected="preflight PR #2 schema is invalid" - [[ "$field" != comment && "$field" != comment-host && "$field" != label ]] || expected="preflight item #1 metadata is invalid" - [[ "$field" != relationship-* ]] || expected="preflight Issue #1 schema is invalid" - copy_bundle "verify-scope-$field" - ruby -rjson -e ' - path, field = ARGV - object = JSON.parse(File.binread(path)) - case field - when "comment" then object.fetch("items").first.fetch("comments").first["issue_url"].sub!("example/repo", "other/repo") - when "comment-host" then object.fetch("items").first.fetch("comments").first["issue_url"].sub!("api.github.com", "evil.example") - when "relationship-host" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first["url"].sub!("github.com", "evil.example") - when "relationship-repo" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first.dig("repository")["nameWithOwner"] = "other/repo" - when "relationship-kind" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first["url"].sub!("/issues/", "/pull/") - when "relationship-number" then object.fetch("items").first.fetch("relationships").fetch("blocked_by").first["url"].sub!("/3", "/999") - when "label" then labels = object.fetch("items").first.dig("issue", "labels"); labels.first.merge!("id" => 999, "name" => "outside-catalog"); labels.sort_by! { |label| label["id"] } - else - pull = object.fetch("items").find { |item| item["kind"] == "pull_request" }.fetch("pull_request") - case field - when "commit" then pull.fetch("commits").first["sha"] = "d" * 40 - when "review" then pull.fetch("reviews").first.dig("_links", "pull_request")["href"].sub!("example/repo", "other/repo") - when "review-comment" then pull.fetch("review_comments").first["pull_request_url"].sub!("example/repo", "other/repo") - when "check-run" then pull.dig("check_runs", "check_runs").first["head_sha"] = "d" * 40 - when "status" then pull.fetch("statuses").first.delete("id") - end - end - File.binwrite(path, JSON.generate(object) + "\n") - ' "$tmp_dir/verify-scope-$field/preflight.json" "$field" - resign_preflight "$tmp_dir/verify-scope-$field" - verify_fails "scope-$field" "$tmp_dir/verify-scope-$field" "$expected" -done - -test "$(wc -l <"$api_log" | tr -d ' ')" = "$local_api_calls_before" - -live_bundle_before="$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" -live_api_calls_before="$(wc -l <"$api_log" | tr -d ' ')" -verify_live normal "$tmp_dir/bundle-a" -test "$(wc -l <"$api_log" | tr -d ' ')" -gt "$live_api_calls_before" -test "$(find "$tmp_dir/bundle-a" -mindepth 1 -maxdepth 1 -type f -exec sha256sum {} \; | sort)" = "$live_bundle_before" -for mode in live-drift live-link-drift incomplete missing-link capture-race header-race issue-detail-race closed-pr-race graphql-media-race graphql-media-missing; do - verify_live_fails "$mode" "$tmp_dir/bundle-a" -done - -for mode in incomplete missing-link missing-relationship capture-race header-race issue-detail-race closed-pr-race graphql-media-race graphql-media-missing; do - set +e - output="$(capture "$mode" "$tmp_dir/$mode" 2>&1)" - exit_code=$? - set -e - test "$exit_code" -eq 2 - test ! -e "$tmp_dir/$mode" - if [[ "$mode" == incomplete ]]; then - grep -Fq "simulated missing pagination page" <<<"$output" - elif [[ "$mode" == missing-relationship ]]; then - grep -Fq "relationship identity is incomplete" <<<"$output" - else - grep -Fq "capture stability drift" <<<"$output" - fi -done - -if awk -F '\t' '$1 != "GET" && !($1 == "POST" && $2 == "graphql" && $3 == "query") { found = 1 } END { exit found ? 0 : 1 }' "$api_log"; then - echo "capture used a mutating GitHub API route" >&2 - exit 1 -fi - -echo "issue lifecycle preflight capture: ok" diff --git a/.github/scripts/test-issue-lifecycle-reconcile-workflow.rb b/.github/scripts/test-issue-lifecycle-reconcile-workflow.rb deleted file mode 100644 index 7a4d00496..000000000 --- a/.github/scripts/test-issue-lifecycle-reconcile-workflow.rb +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "yaml" - -repo_root = File.expand_path("../..", __dir__) -workflow_path = ARGV.fetch(0, File.join(repo_root, ".github/workflows/issue-lifecycle-reconcile.yml")) -source = File.read(workflow_path) -workflow = YAML.safe_load(source, permitted_classes: [], permitted_symbols: [], aliases: false) -events = workflow.fetch("on") - -raise "incorrect workflow events" unless events.keys.sort == %w[issues pull_request_target schedule workflow_dispatch] -raise "issue activity is filtered" unless events.fetch("issues").nil? -raise "incomplete pull request events" unless events.fetch("pull_request_target").fetch("types").sort == - %w[edited opened ready_for_review reopened synchronize] -raise "scheduled reconciliation missing" if events.fetch("schedule").empty? -raise "reconciliation runs are not serialized" unless workflow.fetch("concurrency") == { - "group" => "issue-lifecycle-status-reconciliation", "cancel-in-progress" => true -} - -read_permissions = { "contents" => "read", "issues" => "read", "pull-requests" => "read" } -raise "top-level permissions are not read-only" unless workflow.fetch("permissions") == read_permissions - -jobs = workflow.fetch("jobs") -raise "expected one status-writer job" unless jobs.length == 1 -job = jobs.values.first -raise "job collides with commit status context" if job.fetch("name") == "Issue Lifecycle" -raise "status permission is not isolated to the writer" unless job.fetch("permissions") == - read_permissions.merge("statuses" => "write") -raise "GitHub token is not wired" unless job.fetch("env").fetch("GH_TOKEN") == "${{ github.token }}" - -steps = job.fetch("steps") -remote_actions = steps.map { |step| step["uses"] }.compact.reject { |uses| uses.start_with?("./") } -checkout = steps.find { |step| step["uses"]&.start_with?("actions/checkout@") } -raise "checkout action missing" unless checkout -raise "third-party action is not pinned" unless remote_actions.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) } -raise "checkout does not use the trusted default branch" unless checkout.dig("with", "ref") == - "${{ github.event.repository.default_branch }}" -raise "pull-request head code is referenced" if source.include?("github.event.pull_request.head") - -runnable = steps.select { |step| step["run"] } -raise "expected one reconciliation command" unless runnable.length == 1 -run = runnable.first.fetch("run") -raise "reconciler is not invoked" unless run.include?("ruby .github/scripts/issue-lifecycle-status-reconcile.rb") -raise "workflow summary is not wired" unless run.include?('--summary "$GITHUB_STEP_SUMMARY"') -raise "workflow duplicates auditor policy" if run.include?("issue-lifecycle-audit.rb") - -puts "issue lifecycle reconciliation workflow: ok" diff --git a/.github/scripts/test-issue-lifecycle-status-reconcile.sh b/.github/scripts/test-issue-lifecycle-status-reconcile.sh deleted file mode 100755 index 24865f810..000000000 --- a/.github/scripts/test-issue-lifecycle-status-reconcile.sh +++ /dev/null @@ -1,253 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -repo_root="$(git rev-parse --show-toplevel)" -reconciler="$repo_root/.github/scripts/issue-lifecycle-status-reconcile.rb" -tmp_dir="$(mktemp -d)" -trap 'rm -rf "$tmp_dir"' EXIT - -if [[ ! -f "$reconciler" ]]; then - echo "missing reconciler: $reconciler" >&2 - exit 1 -fi - -fake_bin="$tmp_dir/bin" -mkdir -p "$fake_bin" - -cat > "$fake_bin/gh" <<'EOF' -#!/usr/bin/env bash -set -euo pipefail - -method=GET -paginate=0 -slurp=0 -path="" -while (($#)); do - case "$1" in - api|--silent) - shift - ;; - --method|-X) - method="$2" - shift 2 - ;; - -H) - shift 2 - ;; - --paginate) - paginate=1 - shift - ;; - --slurp) - slurp=1 - shift - ;; - --input) - shift 2 - ;; - *) - path="$1" - shift - ;; - esac -done - -payload="" -if [[ "$method" == POST ]]; then - payload="$(command cat)" -fi -printf '%s\t%s\t%s\t%s\t%s\n' "$method" "$path" "$paginate" "$slurp" "$payload" >> "$TEST_API_LOG" - -sha_a=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa -sha_b=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb -sha_c=cccccccccccccccccccccccccccccccccccccccc -sha_d=dddddddddddddddddddddddddddddddddddddddd -sha_shared=eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee - -case "$path" in - repos/example/repo/pulls\?state=open\&per_page=100) - if [[ "$FIXTURE_MODE" == shared ]]; then - echo "[[{\"number\":201,\"head\":{\"sha\":\"$sha_shared\"}},{\"number\":202,\"head\":{\"sha\":\"$sha_shared\"}}]]" - elif [[ "$FIXTURE_MODE" == mapping ]]; then - echo "[[{\"number\":101,\"head\":{\"sha\":\"$sha_a\"}}],[{\"number\":102,\"head\":{\"sha\":\"$sha_b\"}},{\"number\":103,\"head\":{\"sha\":\"$sha_c\"}}]]" - else - echo "[[{\"number\":101,\"head\":{\"sha\":\"$sha_d\"}}]]" - fi - ;; - repos/example/repo) - echo '{"default_branch":"main"}' - ;; - repos/example/repo/issues\?state=open\&per_page=100\&page=1) - if [[ "$FIXTURE_MODE" == shared ]]; then - echo '[{"number":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":201,"pull_request":{}},{"number":202,"pull_request":{}}]' - elif [[ "$FIXTURE_MODE" == mapping ]]; then - echo '[{"number":10,"body":"## Parent\n\nNone\n\n## Blocked by\n\nNone\n","labels":[{"name":"enhancement"},{"name":"ready-for-agent"}],"assignees":[{"login":"agent"}],"issue_dependencies_summary":{"total_blocked_by":0}},{"number":20,"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":30,"body":"## Parent\n\nNone\n\n## Blocked by\n\nNone\n","labels":[{"name":"enhancement"},{"name":"ready-for-agent"}],"assignees":[{"login":"agent"}],"issue_dependencies_summary":{"total_blocked_by":0}},{"number":101,"pull_request":{}},{"number":102,"pull_request":{}},{"number":103,"pull_request":{}}]' - elif [[ "$(command cat "$TEST_METADATA_STATE")" == valid ]]; then - echo '[{"number":10,"body":"## Parent\n\nNone\n\n## Blocked by\n\nNone\n","labels":[{"name":"enhancement"},{"name":"ready-for-agent"}],"assignees":[{"login":"agent"}],"issue_dependencies_summary":{"total_blocked_by":0}},{"number":101,"pull_request":{}}]' - else - echo '[{"number":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":101,"pull_request":{}}]' - fi - ;; - repos/example/repo/pulls/101) - echo '{"number":101,"base":{"ref":"main"},"title":"Fixture PR 101","body":"## Issue relationship\n\nRefs #10\n\n## Focused validation\n\nFixture proof.\n","commits":1}' - ;; - repos/example/repo/pulls/201) - echo '{"number":201,"base":{"ref":"main"},"title":"Fixture PR 201","body":"## Issue relationship\n\nRefs #10\n\n## Focused validation\n\nFixture proof.\n","commits":1}' - ;; - repos/example/repo/pulls/202) - echo 'fixture auditor error' >&2 - exit 1 - ;; - repos/example/repo/pulls/102) - echo '{"number":102,"base":{"ref":"main"},"title":"Fixture PR 102","body":"## Issue relationship\n\nRefs #20\n\n## Focused validation\n\nFixture proof.\n","commits":1}' - ;; - repos/example/repo/pulls/103) - echo 'fixture auditor error' >&2 - exit 1 - ;; - repos/example/repo/pulls/101/commits\?per_page=100\&page=1|repos/example/repo/pulls/102/commits\?per_page=100\&page=1|repos/example/repo/pulls/201/commits\?per_page=100\&page=1) - echo '[{"sha":"safe","commit":{"message":"Safe commit"}}]' - ;; - repos/example/repo/commits/*/statuses\?per_page=100) - if [[ "$FIXTURE_MODE" == dedupe ]]; then - echo '[[{"context":"Issue Lifecycle","state":"success","description":"Issue lifecycle audit passed"},{"context":"Other","state":"failure"},{"context":"Issue Lifecycle","state":"failure","description":"Issue lifecycle policy violations found"}]]' - elif [[ "$FIXTURE_MODE" == transition ]]; then - state="$(command cat "$TEST_STATUS_STATE")" - if [[ "$state" == success ]]; then - description='Issue lifecycle audit passed' - else - description='Issue lifecycle policy violations found' - fi - printf '[[{"context":"Issue Lifecycle","state":"%s","description":"%s"}]]\n' "$state" "$description" - else - echo '[[]]' - fi - ;; - repos/example/repo/statuses/*) - if [[ "$method" != POST ]]; then - echo "expected POST for $path" >&2 - exit 1 - fi - if [[ "$FIXTURE_MODE" == transition ]]; then - ruby -rjson -e 'print JSON.parse(ARGV.fetch(0)).fetch("state")' "$payload" > "$TEST_STATUS_STATE" - fi - echo "$payload" - ;; - *) - echo "unexpected gh api path: $path" >&2 - exit 1 - ;; -esac -EOF -chmod +x "$fake_bin/gh" - -api_log="$tmp_dir/api.log" -metadata_state="$tmp_dir/metadata-state" -status_state="$tmp_dir/status-state" -export TEST_API_LOG="$api_log" TEST_METADATA_STATE="$metadata_state" TEST_STATUS_STATE="$status_state" -printf 'valid' > "$metadata_state" -printf 'success' > "$status_state" - -run_reconciler() { - local mode="$1" - local output="$2" - local summary="$3" - set +e - FIXTURE_MODE="$mode" PATH="$fake_bin:$PATH" ruby "$reconciler" \ - --repo example/repo --summary "$summary" > "$output" 2>&1 - RUN_EXIT=$? - set -e -} - -: > "$api_log" -run_reconciler mapping "$tmp_dir/mapping.out" "$tmp_dir/mapping-summary.md" -if [[ "$RUN_EXIT" -ne 2 ]]; then - echo "expected mapping run to exit 2, got $RUN_EXIT" >&2 - command cat "$tmp_dir/mapping.out" >&2 - exit 1 -fi -grep -Fq $'GET\trepos/example/repo/pulls?state=open&per_page=100\t1\t1\t' "$api_log" -test "$(grep -c $'^POST\trepos/example/repo/statuses/' "$api_log")" -eq 3 -ruby -rjson - "$api_log" <<'RUBY' -expected = { - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" => ["success", "Issue lifecycle audit passed"], - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" => ["failure", "Issue lifecycle policy violations found"], - "cccccccccccccccccccccccccccccccccccccccc" => ["error", "Issue lifecycle audit errored"] -} -posts = File.readlines(ARGV.fetch(0), chomp: true).map do |line| - method, path, _paginate, _slurp, payload = line.split("\t", 5) - next unless method == "POST" - [path.split("/").last, JSON.parse(payload)] -end.compact.to_h -abort "unexpected status heads: #{posts.keys.sort.inspect}" unless posts.keys.sort == expected.keys.sort -posts.each do |sha, payload| - state, description = expected.fetch(sha) - abort "wrong status payload for #{sha}: #{payload.inspect}" unless payload == { - "context" => "Issue Lifecycle", "state" => state, "description" => description - } -end -RUBY -grep -Fq "| #101 | \`aaaaaaaaaaaa\` | success | published |" "$tmp_dir/mapping-summary.md" -grep -Fq "| #102 | \`bbbbbbbbbbbb\` | failure | published |" "$tmp_dir/mapping-summary.md" -grep -Fq "| #103 | \`cccccccccccc\` | error | published |" "$tmp_dir/mapping-summary.md" - -: > "$api_log" -run_reconciler shared "$tmp_dir/shared.out" "$tmp_dir/shared-summary.md" -if [[ "$RUN_EXIT" -ne 2 ]]; then - echo "expected shared-head run to exit 2, got $RUN_EXIT" >&2 - command cat "$tmp_dir/shared.out" >&2 - exit 1 -fi -grep -Fq "Issue #10: expected exactly one category label" "$tmp_dir/shared.out" -grep -Fq "ERROR issue lifecycle audit" "$tmp_dir/shared.out" -if grep -q $'^GET\trepos/example/repo/commits/' "$api_log"; then - echo "status lookup GET was issued before publishing" >&2 - exit 1 -fi -test "$(grep -c $'^POST\trepos/example/repo/statuses/' "$api_log")" -eq 1 -ruby -rjson - "$api_log" <<'RUBY' -posts = File.readlines(ARGV.fetch(0), chomp: true).map do |line| - method, path, _paginate, _slurp, payload = line.split("\t", 5) - [path.split("/").last, JSON.parse(payload)] if method == "POST" -end.compact -abort "expected one aggregate status, got #{posts.inspect}" unless posts.length == 1 -sha, payload = posts.fetch(0) -abort "unexpected aggregate head #{sha.inspect}" unless sha == "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" -abort "shared-head status was not worst-case error: #{payload.inspect}" unless payload == { - "context" => "Issue Lifecycle", "state" => "error", "description" => "Issue lifecycle audit errored" -} -RUBY - -: > "$api_log" -run_reconciler dedupe "$tmp_dir/dedupe.out" "$tmp_dir/dedupe-summary.md" -if [[ "$RUN_EXIT" -ne 0 ]]; then - echo "expected dedupe run to exit 0, got $RUN_EXIT" >&2 - command cat "$tmp_dir/dedupe.out" >&2 - exit 1 -fi -test "$(grep -c $'^POST\trepos/example/repo/statuses/' "$api_log")" -eq 1 -if grep -q $'^GET\trepos/example/repo/commits/' "$api_log"; then - echo "status lookup GET was issued before publishing" >&2 - exit 1 -fi -grep -Fq "| #101 | \`dddddddddddd\` | success | published |" "$tmp_dir/dedupe-summary.md" - -: > "$api_log" -printf 'invalid' > "$metadata_state" -printf 'success' > "$status_state" -run_reconciler transition "$tmp_dir/invalid.out" "$tmp_dir/invalid-summary.md" -test "$RUN_EXIT" -eq 1 -test "$(command cat "$status_state")" = failure -printf 'valid' > "$metadata_state" -run_reconciler transition "$tmp_dir/repaired.out" "$tmp_dir/repaired-summary.md" -test "$RUN_EXIT" -eq 0 -test "$(command cat "$status_state")" = success -ruby -rjson - "$api_log" <<'RUBY' -states = File.readlines(ARGV.fetch(0), chomp: true).map do |line| - method, _path, _paginate, _slurp, payload = line.split("\t", 5) - JSON.parse(payload).fetch("state") if method == "POST" -end.compact -abort "expected green -> red -> green writes, got #{states.inspect}" unless states == %w[failure success] -RUBY - -echo "issue lifecycle status reconciliation: ok" diff --git a/.github/scripts/test-issue-lifecycle-workflow.rb b/.github/scripts/test-issue-lifecycle-workflow.rb deleted file mode 100644 index fbac02044..000000000 --- a/.github/scripts/test-issue-lifecycle-workflow.rb +++ /dev/null @@ -1,61 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -require "yaml" - -repo_root = File.expand_path("../..", __dir__) -workflow_path = ARGV.fetch(0, File.join(repo_root, ".github/workflows/issue-lifecycle.yml")) -workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], permitted_symbols: [], aliases: false) -events = workflow.fetch("on") - -raise "incorrect workflow events" unless events.keys.sort == %w[pull_request_target schedule workflow_dispatch] -raise "incomplete pull request events" unless events.fetch("pull_request_target").fetch("types").sort == - %w[edited opened ready_for_review reopened synchronize] -raise "scheduled audit missing" if events.fetch("schedule").empty? -# The audit workflow keeps contents/pull-requests read-only. `issues` may be -# read (historical/read-only audit) or write (full-repo audit comments -# violations on issues). Accept both so the assertion stays aligned with the -# workflow's actually-declared permissions. -permissions = workflow.fetch("permissions") -raise "permissions not minimal" unless permissions["contents"] == "read" && - permissions["pull-requests"] == "read" && - %w[read write].include?(permissions["issues"]) - -jobs = workflow.fetch("jobs") -raise "expected one audit job" unless jobs.length == 1 -job = jobs.values.first -raise "unstable check name" unless job.fetch("name") == "Issue Lifecycle Audit" -raise "GitHub token is not wired" unless job.fetch("env").fetch("GH_TOKEN") == "${{ github.token }}" - -steps = job.fetch("steps") -remote_actions = steps.map { |step| step["uses"] }.compact.reject { |uses| uses.start_with?("./") } -checkout = steps.find { |step| step["uses"]&.start_with?("actions/checkout@") } -raise "checkout action missing" unless checkout -raise "third-party action is not pinned" unless remote_actions.all? { |uses| uses.match?(/@[0-9a-f]{40}\z/) } -raise "checkout does not use the trusted default branch" unless checkout.dig("with", "ref") == - "${{ github.event.repository.default_branch }}" - -contract_step = steps.find { |step| step["name"] == "Verify workflow contract" } -raise "workflow contract check is not enforced" unless contract_step&.fetch("run") == - "ruby .github/scripts/test-issue-lifecycle-workflow.rb" -proposal_step = steps.find { |step| step["name"] == "Verify proposed workflow contract" } -proposal_run = proposal_step&.fetch("run", "") -raise "proposed workflow is not safely validated" unless - proposal_step&.fetch("if") == "github.event_name == 'pull_request_target'" && - proposal_run.include?("gh api") && proposal_run.include?("$PR_HEAD_REPOSITORY") && - proposal_run.include?("$PR_HEAD_SHA") && - proposal_run.include?('ruby .github/scripts/test-issue-lifecycle-workflow.rb "$RUNNER_TEMP/issue-lifecycle.yml"') -raise "unexpected runnable workflow step" unless steps.count { |step| step["run"] } == 4 - -audit_steps = steps.select { |step| step["run"]&.include?("issue-lifecycle-audit.rb") } -raise "expected only PR and repository audits" unless audit_steps.length == 2 -raise "workflow duplicates audit policy" unless audit_steps.all? do |step| - run = step.fetch("run") - run.include?("ruby .github/scripts/issue-lifecycle-audit.rb") && run.include?("--summary \"$GITHUB_STEP_SUMMARY\"") -end -pr_audit = audit_steps.find { |step| step["if"] == "github.event_name == 'pull_request_target'" } -live_audit = audit_steps.find { |step| step["if"] == "github.event_name != 'pull_request_target'" } -raise "PR audit does not target one PR" unless pr_audit&.fetch("run")&.include?("--pr \"$PR_NUMBER\"") -raise "scheduled audit is not repository-wide" unless live_audit && !live_audit.fetch("run").include?("--pr") - -puts "issue lifecycle workflow: ok" diff --git a/.github/scripts/test-select-rust-ci-scope.sh b/.github/scripts/test-select-rust-ci-scope.sh index 8385a1be5..13cd5be71 100755 --- a/.github/scripts/test-select-rust-ci-scope.sh +++ b/.github/scripts/test-select-rust-ci-scope.sh @@ -21,9 +21,7 @@ run_case() { printf '%s\n' package-lock.json >"$tmp_dir/root-node.txt" printf '%s\n' .github/workflows/security.yml >"$tmp_dir/unknown-workflow.txt" -printf '%s\n' .github/workflows/issue-lifecycle.yml >"$tmp_dir/governance-workflow.txt" printf '%s\n' .github/workflows/security-enabled.yml >"$tmp_dir/security-workflow.txt" -printf '%s\n' .github/scripts/issue-lifecycle-audit.rb >"$tmp_dir/governance-script.txt" printf '%s\n' .github/ISSUE_TEMPLATE/engineering-change.yml >"$tmp_dir/governance-template.txt" printf '%s\n' docs/agents/issue-tracker.md >"$tmp_dir/governance-doc.txt" printf '%s\n' Makefile >"$tmp_dir/unknown-root.txt" @@ -85,10 +83,8 @@ job_cases=( 'unknown-docker|pull_request|unknown-docker.txt|ci/rust,ci/deployment-artifacts,ci/polymarket-evidence-compiler-image,ci/rust-hft-engine-fast-lane,ci/node-install,ploy/commit-hygiene,ploy/research-image-binaries,ploy/research-image-smoke,ploy/rust-format,ploy/safety-scans,ploy/audit,ploy/rust-control-plane,ploy/rust-runner-lean,ploy/rust-runner-full,ploy/rust-market-data,ploy/rust-research-heavy,ploy/frontend,ploy/integration-regressions' 'prediction-workflow|pull_request|prediction-workflow.txt|ploy/commit-hygiene,ploy/workflow-lint,ploy/research-image-binaries,ploy/research-image-smoke,ploy/rust-format,ploy/safety-scans,ploy/audit,ploy/rust-control-plane,ploy/rust-runner-lean,ploy/rust-runner-full,ploy/rust-market-data,ploy/rust-research-heavy,ploy/frontend,ploy/integration-regressions' 'root-node|pull_request|root-node.txt|ci/node-install' - 'governance-workflow|pull_request|governance-workflow.txt|ploy/commit-hygiene,ploy/workflow-lint' 'security-workflow|pull_request|security-workflow.txt|ploy/commit-hygiene,ploy/workflow-lint' 'security-workflow-push|push|security-workflow.txt|ploy/workflow-lint' - 'governance-script|pull_request|governance-script.txt|ploy/commit-hygiene,ploy/workflow-lint' 'governance-template|pull_request|governance-template.txt|ploy/commit-hygiene,ploy/workflow-lint' 'governance-doc|pull_request|governance-doc.txt|ploy/commit-hygiene,ploy/workflow-lint' 'unknown-workflow|pull_request|unknown-workflow.txt|ci/rust,ci/deployment-artifacts,ci/polymarket-evidence-compiler-image,ci/rust-hft-engine-fast-lane,ci/node-install,ploy/workflow-lint,ploy/commit-hygiene,ploy/research-image-binaries,ploy/research-image-smoke,ploy/rust-format,ploy/safety-scans,ploy/audit,ploy/rust-control-plane,ploy/rust-runner-lean,ploy/rust-runner-full,ploy/rust-market-data,ploy/rust-research-heavy,ploy/frontend,ploy/integration-regressions' @@ -116,7 +112,6 @@ done all_security_jobs='security/sast-semgrep,security/cargo-audit,security/secret-presence,security/license-check,security/clippy-strict,security/cargo-machete,security/secret-detection' assert_security_jobs "$tmp_dir/docs.out" 'security/secret-detection' -assert_security_jobs "$tmp_dir/governance-workflow.out" 'security/sast-semgrep,security/secret-presence,security/secret-detection' assert_security_jobs "$tmp_dir/security-workflow.out" "$all_security_jobs" assert_security_jobs "$tmp_dir/security-workflow-push.out" "$all_security_jobs,security/container-scan" assert_security_jobs "$tmp_dir/root-node.out" 'security/sast-semgrep,security/secret-presence,security/secret-detection' @@ -249,22 +244,6 @@ grep -Fqx " mapfile -d '' workflow_files < <(" "$ploy_workflow" grep -Fq -- '--diff-filter=ACMR -z' "$ploy_workflow" grep -Fqx ' if ((${#workflow_files[@]} == 0)); then' "$ploy_workflow" grep -Fqx ' "${HOME}/go/bin/actionlint" -color "${workflow_files[@]}"' "$ploy_workflow" -grep -Fqx ' - name: Test issue lifecycle workflow contract' "$ploy_workflow" -grep -A1 -F ' - name: Test issue lifecycle workflow contract' "$ploy_workflow" | grep -Fqx ' working-directory: .' -grep -Fqx ' run: ruby .github/scripts/test-issue-lifecycle-workflow.rb' "$ploy_workflow" -grep -Fqx ' - name: Test issue lifecycle audit' "$ploy_workflow" -grep -A1 -F ' - name: Test issue lifecycle audit' "$ploy_workflow" | grep -Fqx ' working-directory: .' -grep -Fqx ' run: .github/scripts/test-issue-lifecycle-audit.sh' "$ploy_workflow" -grep -Fqx ' - name: Test issue lifecycle templates' "$ploy_workflow" -grep -A1 -F ' - name: Test issue lifecycle templates' "$ploy_workflow" | grep -Fqx ' working-directory: .' -grep -Fqx ' run: .github/scripts/test-issue-lifecycle-contract.sh' "$ploy_workflow" -grep -Fqx ' - name: Test issue lifecycle reconciliation workflow contract' "$ploy_workflow" -grep -A1 -F ' - name: Test issue lifecycle reconciliation workflow contract' "$ploy_workflow" | grep -Fqx ' working-directory: .' -grep -Fqx ' run: ruby .github/scripts/test-issue-lifecycle-reconcile-workflow.rb' "$ploy_workflow" -grep -Fqx ' - name: Test issue lifecycle status reconciliation' "$ploy_workflow" -grep -A1 -F ' - name: Test issue lifecycle status reconciliation' "$ploy_workflow" | grep -Fqx ' working-directory: .' -grep -Fqx ' run: .github/scripts/test-issue-lifecycle-status-reconcile.sh' "$ploy_workflow" - # sccache must use the #559/#566 pattern (sccache-action + per-job local # cache, rustc/sccache-versioned rust-cache keys, continue-on-error fallback) in # EVERY ploy-ci job that compiles Rust on the runner, and the homegrown diff --git a/.github/workflows/issue-lifecycle-reconcile.yml b/.github/workflows/issue-lifecycle-reconcile.yml deleted file mode 100644 index 8cb06efe3..000000000 --- a/.github/workflows/issue-lifecycle-reconcile.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: Issue Lifecycle Status Reconciliation - -"on": - issues: - pull_request_target: - types: [opened, synchronize, reopened, edited, ready_for_review] - schedule: - - cron: "29 3 * * *" - workflow_dispatch: - -concurrency: - group: issue-lifecycle-status-reconciliation - cancel-in-progress: true - -permissions: - contents: read - issues: read - pull-requests: read - -jobs: - reconcile-statuses: - name: Issue Lifecycle Status Reconciler - runs-on: ubuntu-latest - permissions: - contents: read - issues: read - pull-requests: read - statuses: write - env: - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.repository.default_branch }} - - name: Reconcile open pull-request statuses - run: >- - ruby .github/scripts/issue-lifecycle-status-reconcile.rb - --repo "$GITHUB_REPOSITORY" - --summary "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml deleted file mode 100644 index 88f79e2ca..000000000 --- a/.github/workflows/issue-lifecycle.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Issue Lifecycle - -"on": - pull_request_target: - types: [opened, synchronize, reopened, edited, ready_for_review] - schedule: - - cron: "17 3 * * *" - workflow_dispatch: - -permissions: - contents: read - issues: read - pull-requests: read - -jobs: - issue-lifecycle: - name: Issue Lifecycle Audit - runs-on: ubuntu-latest - env: - GH_TOKEN: ${{ github.token }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - ref: ${{ github.event.repository.default_branch }} - - name: Verify workflow contract - run: ruby .github/scripts/test-issue-lifecycle-workflow.rb - - name: Verify proposed workflow contract - if: github.event_name == 'pull_request_target' - env: - PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }} - PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - gh api -H "Accept: application/vnd.github.raw+json" \ - "repos/$PR_HEAD_REPOSITORY/contents/.github/workflows/issue-lifecycle.yml?ref=$PR_HEAD_SHA" \ - > "$RUNNER_TEMP/issue-lifecycle.yml" - ruby .github/scripts/test-issue-lifecycle-workflow.rb "$RUNNER_TEMP/issue-lifecycle.yml" - - name: Audit current pull request - if: github.event_name == 'pull_request_target' - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - run: >- - ruby .github/scripts/issue-lifecycle-audit.rb - --repo "$GITHUB_REPOSITORY" - --pr "$PR_NUMBER" - --summary "$GITHUB_STEP_SUMMARY" - - name: Audit live repository - if: github.event_name != 'pull_request_target' - run: >- - ruby .github/scripts/issue-lifecycle-audit.rb - --repo "$GITHUB_REPOSITORY" - --summary "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ploy-ci.yml b/.github/workflows/ploy-ci.yml index ac9ff0698..cfad94225 100644 --- a/.github/workflows/ploy-ci.yml +++ b/.github/workflows/ploy-ci.yml @@ -161,30 +161,6 @@ jobs: - name: Test ACR publish workflow contract run: ../../.github/scripts/test-acr-publish-workflow.sh - - name: Test issue lifecycle workflow contract - working-directory: . - run: ruby .github/scripts/test-issue-lifecycle-workflow.rb - - - name: Test issue lifecycle audit - working-directory: . - run: .github/scripts/test-issue-lifecycle-audit.sh - - - name: Test issue lifecycle preflight capture - working-directory: . - run: .github/scripts/test-issue-lifecycle-preflight.sh - - - name: Test issue lifecycle templates - working-directory: . - run: .github/scripts/test-issue-lifecycle-contract.sh - - - name: Test issue lifecycle reconciliation workflow contract - working-directory: . - run: ruby .github/scripts/test-issue-lifecycle-reconcile-workflow.rb - - - name: Test issue lifecycle status reconciliation - working-directory: . - run: .github/scripts/test-issue-lifecycle-status-reconcile.sh - research-image-binaries: name: Research image binaries needs: image-smoke-scope diff --git a/AGENTS.md b/AGENTS.md index cea829d29..86ae4119f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Monday Agent Instructions -## Mission and authority +## Authority - Monday is one Rust-first, multi-venue system. Research lives in `rust_hft/alpha-harness`, acquisition in `rust_hft/tools/collector`, prediction @@ -9,80 +9,57 @@ - Research may emit typed candidates and signed deployment envelopes. It may not submit orders, change risk limits, or resume a paused runtime. Live stays disabled until a separately reviewed runtime contract proves every gate. -- Follow the nearest nested `AGENTS.md`; prediction-market work also follows - `rust_hft/prediction-markets/AGENTS.md`. +- Follow the nearest nested `AGENTS.md`. -## Fast workflow +## Working rules -- Work autonomously on clear, reversible tasks. Preserve unrelated user changes; - ask only before destructive, irreversible, or genuinely ambiguous actions. -- Use the lightest workflow that preserves evidence: - - Read-only/status: inspect and answer directly; no PRD, issue, branch, or worktree. - - Small specified change: no PRD or issue; focused failing check, minimum patch, - focused validation, then diff review. - - Defect or runtime drift: use `diagnosing-bugs` to prove the cause first. - - Multi-step or multi-session outcome: use `to-prd`, then `to-issues`. - - Approved issue: use `implement`, which drives `tdd`, then `code-review`. -- Prefer the narrowest applicable Matt Pocock skill. Use another skill only when - it fits better or the user asks for it. +- Follow the user's goal and scope. Work autonomously on clear, reversible tasks; + preserve unrelated changes and ask before destructive, irreversible, or + genuinely ambiguous actions. +- Solve the problem directly. Use a skill, issue, specification, branch, or + worktree only when it reduces uncertainty, coordinates durable work, or + isolates concurrent writes; never create one merely to satisfy a workflow. +- For a defect, prove the root cause with the smallest observable check, fix the + shared cause, and rerun that check. Do not patch symptoms or repeat an unchanged + experiment under a new task or issue. +- Make the smallest end-to-end change that satisfies the current contract. Reuse + existing code, platform features, and installed dependencies before adding + abstractions, infrastructure, configuration, or dependencies. +- Backward compatibility is not a goal. Remove obsolete paths instead of adding + shims or fallbacks; preserve applied migrations and audit history as read-only + records. -## Engineering decisions +## Evidence and safety -- Backward compatibility is not a goal. Remove deprecated paths and their - callers, aliases, configuration, tests, documentation, and deployment - assets. Do not add shims, dual implementations, silent fallbacks, or migration - branches merely to keep obsolete behavior alive. -- Ship the smallest end-to-end implementation that satisfies the current - contract. Add capability only after that path works and has focused evidence. -- Reuse existing modules, standard-library or platform features, and installed - dependencies before writing new infrastructure or adding a dependency. If - they do not satisfy the requirement, prefer a mature, maintained library when - it reduces total complexity or reliability risk. Read the relevant - documentation and types before deciding existing code cannot support the need. -- Add a module, abstraction, configuration option, or indirect layer only when a - current requirement creates a distinct responsibility, authority boundary, or - lifecycle. -- Keep changes on the canonical long-term architecture. Do not introduce a - production path known to require replacement; transitional cutover code must - have an owner, removal condition, and removal issue. -- Before designing a non-trivial solution, inspect how mature products solve the - same class of problem and prefer proven patterns and conventions. Localized - changes do not require external product research. -- Applied database migrations and immutable historical evidence are records, not - active compatibility surfaces. Preserve required audit history, but keep - historical decoding read-only and unable to write, resume, promote, deploy, or - execute. - -## Durable guards from repeated failures - -- Refresh `origin/main` and live GitHub/runtime state before claims or new work. - A local checkout, old green run, or screen view is not current truth. +- Refresh only the source of truth that can affect the next decision. Recheck + branch and live identities before publishing or mutating runtime; local state + and old green runs are not current truth. - Keep Code, CI, merge, release, runtime, and readback as separate states. Claim only the latest state backed by an exact SHA/digest and direct readback. -- One change is one independently testable and rollbackable behavior. Do not mix - Research, Governance, and Runtime. Keep collector deployment, cohort/snapshot, - evaluator/MCTS, and result publication as separate rollout units. +- Keep each change independently testable and rollbackable. Do not mix Research, + Governance, and Runtime. - Never replace missing real data with fixtures, fabricate completeness, weaken a fail-closed gate, or call a successful preparation step terminal evidence. - Publishing an issue, PR, artifact, image, or job is not completion. Read back - its relationships, checks, immutable identity, terminal result, and output. + its immutable identity, checks, terminal result, and output. +- Runtime, deployment, and collector cutovers require one named controller, + exact target and rollback identities, stop rules, automatic cleanup, and direct + readback. A failed attempt may run again only after its cause or relevant input + changed and the new hypothesis is stated. ## Scope and ownership -- One active contract has one writer and one writable branch/worktree; when - published, it has one PR. Reuse a clean, owned worktree only for the same - contract; otherwise create a recorded `codex/` worktree from the base SHA. -- Record `agent-worktree.yml`. Before edits, commits, rebases, pushes, or merges, - re-read branch, `HEAD`, status, and PR head; stop on movement or overlapping ownership. -- Do not delete branches or worktrees without explicit authorization and exact - checks for dirty files, unpushed commits, PR state, and active ownership. -- A PR is one behavior and rollback unit. Use the PR template. At 25 changed files - or 750 non-generated lines, split unless a named reviewer approves an atomic exception. -- GitHub metadata is authoritative. Follow `docs/agents/issue-tracker.md`, issue - templates, lifecycle checks, and `/pm:issue-close`. Runtime/tracking issues - close from their own evidence, never from a PR. -- Runtime, deployment, and collector cutovers require one named controller, - exact target/candidate/configuration/rollback identities, stop rules, and readback. +- One active contract has one writer. Use the current checkout for isolated local + changes when ownership and dirty state are known; use a recorded worktree for + concurrent, published, or multi-session work. +- Re-read branch, `HEAD`, status, and PR head before publishing or merging. Stop + on movement or overlap. Do not delete branches or worktrees without explicit + authorization and safety checks. +- A PR contains one independently reviewable behavior; follow the PR template. +- Use one issue for one behavior or runtime outcome. Record bounded attempts, + failures, cleanup, and evidence on that issue; create another only when the + behavior, target, authority, or independently reviewable change differs. +- Runtime/tracking issues close from their own evidence, never from a PR. ## Focused validation @@ -91,14 +68,4 @@ - From `rust_hft/`, use `cargo test -p --locked` and scoped Clippy. Run `cargo metadata --locked --no-deps` only after workspace-graph changes. - For instruction, workflow, or shell changes, run `git diff --check` plus the - closest contract test. Run `.github/scripts/agent-worktree-preflight.sh` in a - managed worktree. Report unrelated or unavailable checks separately. - -## Repeated workflows become skills - -- Keep this file as a router. After the same multi-step workflow succeeds twice, - package it as `.agents/skills//SKILL.md` instead of adding its procedure here. -- One skill owns one job and states triggers, inputs, steps, stop conditions, - verification, and output. Reuse repository scripts/docs; do not copy them. -- Validate skills manually before scheduling or write access. Runtime skills stay - fail-closed and never broaden user authority. + closest contract test. Report unrelated or unavailable checks separately. diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md index dcaa3a0ca..efc2972c2 100644 --- a/docs/agents/issue-tracker.md +++ b/docs/agents/issue-tracker.md @@ -1,76 +1,36 @@ -# Issue tracker: GitHub +# Issue tracker -Issues and PRDs for this repository live in GitHub Issues at `proerror77/monday`. Use the `gh` CLI for issue operations and infer the repository from the configured remote. +Monday uses GitHub Issues for work that must survive a session, coordinate +multiple owners, or record production authority. Local investigation and small +changes do not need an issue. -## Conventions +## One outcome, one issue -- Create: `gh issue create --title "..." --body-file - --label --label ` -- Read: `gh issue view --comments` -- List: `gh issue list --state open --json number,title,body,labels,comments` -- Comment: `gh issue comment --body "..."` -- Label: `gh issue edit --add-label "..."` -- Close: `gh issue close --comment "..."` +- Keep one issue for one behavior or runtime outcome. +- Put attempts, failures, cleanup receipts, and final evidence on that issue. +- Open another issue only when the behavior, target, authority, or independently + reviewable change differs. An owner handoff stays on the same issue. +- Use GitHub's native parent and blocked-by relationships when they help current + coordination; do not duplicate them as mandatory body sections. -Use `--body-file` for multiline Markdown. Do not pass literal `\\n` escapes in -`--body`. After creation, read back the body, labels, and relationships before -claiming publication succeeded. +## Pull requests -## Issue contract +Use one visible relationship in the PR body: -- Every issue has exactly one category (`bug` or `enhancement`) and exactly one - state from `docs/agents/triage-labels.md`. -- `tracking` marks a PRD or parent issue and excludes it from agent pickup. -- `runtime` marks a deployment, cutover, live mutation, or runtime-evidence - contract. It does not grant authority to perform that mutation. -- Assign exactly one write owner when work starts. The worktree-private - `agent-worktree.yml` remains the source of branch and file ownership. -- Code behavior, artifact publication, runtime adoption, and result publication - are separate issues whenever they can be reviewed, reverted, authorized, or - evidenced independently. +- `Closes #N` when merging to `main` completes the code contract. +- `Refs #N` for partial, tracking, runtime, or stacked work. +- `None` when no issue is needed. -Use GitHub's native parent, sub-issue, and dependency relationships: +Runtime and tracking issues close only from their own terminal evidence, never +from a code merge. -```text -gh issue create --parent --blocked-by -gh issue edit --parent --add-blocked-by -``` +## Runtime outcomes -The `Parent` and `Blocked by` body sections remain human-readable summaries; -the native parent and blocked-by relationships are authoritative. +Before a live mutation, record the exact target, one controller, candidate and +rollback identities, stop rules, and success/readback criteria. The controller +must clean up on every exit path. A failed attempt may run again under the same +issue only after its cause or relevant input changed and the new hypothesis is +stated. -## Pull request relationship - -Every PR declares exactly one relationship in its body: - -- `Refs #N` for partial work, preparatory work, or a stacked PR whose base is - not `main`. -- `Closes #N` only when merging the PR into `main` completes the entire code - contract. -- `None` when no issue applies. - -Do not write negated closing phrases such as `does not close #N`; GitHub still -recognizes the closing keyword. The `close`, `fix`, and `resolve` keyword -families in commit messages follow the same restriction because a commit -reaching the default branch can close an issue. A `tracking` or `runtime` issue -must use `Refs`, never a closing keyword. See GitHub's official -[linking contract](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue). - -## Runtime and parent closure - -A runtime issue closes manually only after its comment history records the -exact target, named controller, source/candidate and configuration identities, -rollback identity, stop rules, terminal result, and cleanup evidence. A merged -PR or healthy process is not a substitute for that evidence. - -When the final direct sub-issue closes, audit the parent outcome and acceptance -criteria. Do not infer parent completion from child state and do not close a -parent automatically. If a different behavior or rollout remains, publish a -new sub-issue rather than extending a completed contract. - -## Pull requests as a triage surface - -External pull requests are not treated as feature requests by the triage workflow. - -## Skill routing - -When a skill says to publish to the issue tracker, create a GitHub issue. When it says to fetch a ticket, use `gh issue view` and include comments and labels. +Use `gh issue view --comments` for the current contract and history. +Use `--body-file` when publishing multiline Markdown. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md index bc7f3bc5f..40d5289d9 100644 --- a/docs/agents/triage-labels.md +++ b/docs/agents/triage-labels.md @@ -1,36 +1,17 @@ -# Triage Labels - -Every triaged issue carries exactly one category and one state. Qualifiers add -execution context but never replace either role. - -## Category roles - -| Skill role | GitHub label | Meaning | -| --- | --- | --- | -| `bug` | `bug` | Existing behavior is broken | -| `enhancement` | `enhancement` | New behavior or an improvement | - -## State roles - -| Skill role | GitHub label | Meaning | -| --- | --- | --- | -| `needs-triage` | `needs-triage` | Maintainer evaluation is required | -| `needs-info` | `needs-info` | Waiting for reporter information | -| `ready-for-agent` | `ready-for-agent` | Fully specified and safe for an autonomous agent | -| `ready-for-human` | `ready-for-human` | Human implementation or judgment is required | -| `wontfix` | `wontfix` | The issue will not be actioned | - -Use the right-hand label verbatim when engineering skills refer to a triage -role. Remove the previous state label when moving an issue; conflicting state -labels are invalid. - -## Qualifiers - -| GitHub label | Meaning | -| --- | --- | -| `tracking` | A PRD or parent tracker; exclude it from agent pickup queries | -| `runtime` | Closure requires live mutation or runtime evidence | - -`ready-for-agent` means the issue is executable now. An issue with missing -authority, target identity, or required input is `needs-info`; an issue whose -next action requires human judgment or control is `ready-for-human`. +# Triage labels + +Labels describe current state; they do not grant authority or block work by +themselves. + +- `bug`: existing behavior is broken. +- `enhancement`: new behavior or an improvement. +- `needs-triage`: the next action is not yet clear. +- `needs-info`: required evidence or input is missing. +- `ready-for-agent`: the next bounded action is executable. +- `ready-for-human`: the next action requires human judgment or control. +- `wontfix`: no action is planned. +- `tracking`: a parent outcome, not an implementation task. +- `runtime`: live mutation or runtime evidence is involved. + +Use only labels that help a current query or decision. Runtime authorization +comes from the recorded controller and exact mutation contract, not a label.