diff --git a/.github/scripts/issue-lifecycle-preflight.rb b/.github/scripts/issue-lifecycle-preflight.rb index 98aaa0fa3..03cf61d59 100755 --- a/.github/scripts/issue-lifecycle-preflight.rb +++ b/.github/scripts/issue-lifecycle-preflight.rb @@ -13,8 +13,13 @@ 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 @@ -491,26 +496,31 @@ def write_bundle(output, graph, manifest) end end -def read_canonical_json(path) - contents = File.binread(path) +def parse_canonical_json(contents, name) object = JSON.parse(contents) - raise "#{File.basename(path)} is not canonical JSON" unless contents.b == Canonical.dump(object).b + raise "#{name} is not canonical JSON" unless contents.b == Canonical.dump(object).b object rescue JSON::ParserError => error - raise "#{File.basename(path)} is invalid JSON: #{error.message}" + 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" - contents = File.binread(File.join(bundle, sidecar)) - match = contents.match(/\A([0-9a-f]{64}) #{Regexp.escape(filename)}\n\z/) + 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 - actual = Digest::SHA256.file(File.join(bundle, filename)).hexdigest + contents = File.binread(File.join(bundle, filename)) + actual = Digest::SHA256.hexdigest(contents) raise "#{filename} digest mismatch" unless actual == match[1] - actual + [contents, actual] end def collection_scope(request, repo) @@ -795,10 +805,10 @@ def verify_bundle(bundle, repo, controller) raise "bundle entry #{filename} is not a regular file" unless File.file?(path) && !File.symlink?(path) end - preflight_sha = verify_sidecar(bundle, "preflight.json") - verify_sidecar(bundle, "manifest.json") - graph = read_canonical_json(File.join(bundle, "preflight.json")) - manifest = read_canonical_json(File.join(bundle, "manifest.json")) + 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" @@ -838,11 +848,346 @@ def verify_bundle(bundle, repo, controller) manifest["preflight"] == { "file" => "preflight.json", "sha256" => preflight_sha } raise "manifest preflight identity is invalid" end - [graph, manifest, link_provenance] + [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? @@ -882,31 +1227,58 @@ def verify(options) raise "--bundle DIR is required" if bundle.to_s.empty? graph, manifest, link_provenance = verify_bundle(bundle, repo, controller) - if options[:live] - 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 "live verification drift: Issue/PR graph changed" unless Canonical.value(live_graph) == Canonical.value(graph) - raise "live verification drift: counts changed" unless live_counts == manifest["counts"] - unless live_branch == manifest["default_branch"] && live_branch_sha == manifest["default_branch_sha"] - raise "live verification drift: 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 "live verification drift: page/header inventory changed" - end - end + 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) --repo OWNER/REPO --controller NAME (--output|--bundle) DIR" + 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 @@ -916,15 +1288,24 @@ def verify(options) 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 or verify" + raise "unsupported operation #{command.inspect}; expected capture, verify, or plan-restore" end rescue StandardError => error warn "ERROR issue lifecycle preflight: #{error.message}" diff --git a/.github/scripts/test-issue-lifecycle-preflight.sh b/.github/scripts/test-issue-lifecycle-preflight.sh index 8e6474f02..781dda686 100755 --- a/.github/scripts/test-issue-lifecycle-preflight.sh +++ b/.github/scripts/test-issue-lifecycle-preflight.sh @@ -66,10 +66,13 @@ respond_with_next() { } 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 @@ -107,23 +110,42 @@ case "$path" in 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*) - respond '[{"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"}}]' + 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) - respond "{\"id\":101,\"node_id\":\"I_one\",\"url\":\"https://api.github.com/repos/example/repo/issues/1\",\"number\":1,\"comments\":1,\"state\":\"open\",\"body\":\"Issue one 研究\",\"labels\":$labels,\"assignees\":$assignees,\"updated_at\":\"2026-08-02T00:00:00Z\"}" + 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 + 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\",\"body\":\"$body\",\"labels\":[],\"assignees\":[],\"updated_at\":\"2026-08-01T00:00:00Z\"}" "$etag" + 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\"}" @@ -152,10 +174,12 @@ case "$path" in 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}" == 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":0,"nodes":[],"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" + 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":[],"totalCount":0,"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" + 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 ;; @@ -186,6 +210,38 @@ verify_live() { --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" } @@ -271,6 +327,240 @@ test "$actual_files" = "$expected_files" 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")))