Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion .github/scripts/issue-lifecycle-audit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
proerror77 marked this conversation as resolved.
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
Comment thread
proerror77 marked this conversation as resolved.

def automatic_close_description(value, fixture)
return "enabled (fixture)" if fixture && value == true
return "disabled (fixture)" if fixture && value == false
Expand All @@ -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
Comment thread
proerror77 marked this conversation as resolved.
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?
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions .github/scripts/test-issue-lifecycle-audit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading