diff --git a/.github/scripts/issue-lifecycle-audit.rb b/.github/scripts/issue-lifecycle-audit.rb index 71b7b891c..d82416b1c 100755 --- a/.github/scripts/issue-lifecycle-audit.rb +++ b/.github/scripts/issue-lifecycle-audit.rb @@ -307,6 +307,61 @@ def audit_pull_request(pull_request, issues, default_branch, repo) 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 @@ -321,6 +376,23 @@ def load_live(repo, pr_number) 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? @@ -352,7 +424,8 @@ def load_live(repo, pr_number) "automatic_linked_issue_closing" => nil, "issues" => known.values, "audited_issue_numbers" => audited_issue_numbers, - "pull_requests" => pull_requests + "pull_requests" => pull_requests, + "closed_runtime_issues" => closed_runtime_entries } end @@ -426,6 +499,9 @@ def render(data, label, violations, fixture) 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 diff --git a/.github/scripts/test-issue-lifecycle-audit.sh b/.github/scripts/test-issue-lifecycle-audit.sh index e17a2f0b9..5dbe5a6f0 100755 --- a/.github/scripts/test-issue-lifecycle-audit.sh +++ b/.github/scripts/test-issue-lifecycle-audit.sh @@ -24,9 +24,9 @@ def pull_request(number, relationship, base = "main", commits = [], extra = "", "commits" => commits } end -def data(issues, pull_requests = [], automatic_close = true) +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 } + "issues" => issues, "pull_requests" => pull_requests, "closed_runtime_issues" => closed_runtime } end base = issue(10, %w[enhancement ready-for-agent], issue_body, ["agent"]) @@ -179,7 +179,15 @@ cases = { ], [pull_request(31, "Closes #10", "main", [ { "sha" => "closed", "message" => "Closed #52" } ])]), - "commit_list_incomplete" => data([base], [incomplete_commits]) + "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 @@ -232,6 +240,8 @@ 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