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
40 changes: 39 additions & 1 deletion runbooks/labels.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Do not delete unknown labels in bulk. If a repository has a local label that is

## Dry-run script

`scripts/labels-dry-run.rb` is read-only. It consumes `lib/labels.yml`, queries GitHub through `gh api`, and reports:
`scripts/labels-dry-run.rb` is read-only by default. It consumes `lib/labels.yml`, queries GitHub through `gh api`, and reports:

- canonical labels that would be created
- canonical labels whose color or description would be updated
Expand All @@ -136,6 +136,44 @@ scripts/labels-dry-run.rb --repo z-shell/zi --repo z-shell/wiki
scripts/labels-dry-run.rb --repo z-shell/zi --json
```

## Apply-mode pilot

Apply mode is intentionally limited while #411 is piloted:

- default mode remains read-only;
- `--apply` previews canonical label create/update operations without mutating anything;
- `--apply --confirm-apply` may only create missing canonical labels and update canonical label metadata;
- it does not delete unknown labels;
- it does not delete legacy labels;
- it does not migrate labels on issues or pull requests;
- org-wide apply is disabled during the pilot;
- confirmed apply requires explicit `--repo` values;
- confirmed apply is limited to the temporary pilot allowlist unless a maintainer explicitly approves `--allow-non-pilot-repo`.

Preview commands:

```sh
# Preview canonical create/update operations for one repo.
scripts/labels-dry-run.rb --repo z-shell/REPO --apply

# Preview in JSON for artifact comparison.
scripts/labels-dry-run.rb --repo z-shell/REPO --apply --json
```

Confirmed apply commands require maintainer approval because they mutate GitHub labels:

```sh
# No-op safety apply on the clean org metadata repo.
scripts/labels-dry-run.rb --repo z-shell/.github --apply --confirm-apply --include-clean

# Approved pilot outside the temporary allowlist.
scripts/labels-dry-run.rb \
--repo z-shell/REPO \
--apply \
--confirm-apply \
--allow-non-pilot-repo
```

## See also

- `.github/lib/labels.yml`
Expand Down
223 changes: 213 additions & 10 deletions scripts/labels-dry-run.rb
Original file line number Diff line number Diff line change
@@ -1,27 +1,39 @@
#!/usr/bin/env ruby
# frozen_string_literal: true

# Dry-run z-shell label synchronization audit.
# Dry-run z-shell label synchronization audit, with a tightly gated apply-mode
# pilot for canonical label create/update operations.
#
# Reads lib/labels.yml and compares it with one or more GitHub repositories.
# This script is intentionally read-only: it uses only GET-style `gh api` calls
# and never creates, updates, deletes, or migrates labels.
# Default behavior is intentionally read-only: it uses only GET-style `gh api`
# calls and never creates, updates, deletes, or migrates labels unless both
# `--apply` and `--confirm-apply` are passed for explicit `--repo` targets.

require "json"
require "open3"
require "optparse"
require "uri"
require "yaml"

ROOT = File.expand_path("..", __dir__)
DEFAULT_LABELS_FILE = File.join(ROOT, "lib", "labels.yml")

# Temporary pilot allowlist. Keep this intentionally tiny until #411 has one
# reviewed create/update-only pilot result. Use --allow-non-pilot-repo only
# after maintainer approval.
PILOT_APPLY_REPOS = [
"z-shell/.github"
].freeze

Options = Struct.new(
:labels_file,
:org,
:repos,
:all_repos,
:json,
:include_clean,
:apply,
:confirm_apply,
:allow_non_pilot_repo,
keyword_init: true
)

Expand All @@ -31,7 +43,10 @@
repos: [],
all_repos: false,
json: false,
include_clean: false
include_clean: false,
apply: false,
confirm_apply: false,
allow_non_pilot_repo: false
)

parser = OptionParser.new do |opts|
Expand Down Expand Up @@ -61,13 +76,31 @@
options.include_clean = true
end

opts.on("--apply", "Preview canonical label create/update operations for explicit repos") do
options.apply = true
end

opts.on("--confirm-apply", "Actually apply --apply canonical label create/update operations") do
options.confirm_apply = true
end

opts.on("--allow-non-pilot-repo", "Allow confirmed apply outside the temporary pilot allowlist") do
options.allow_non_pilot_repo = true
end

opts.on("-h", "--help", "Show this help") do
puts opts
exit 0
end
end

parser.parse!
begin
parser.parse!
rescue OptionParser::ParseError => e
warn parser
warn "\nerror: #{e.message}"
exit 2
end

if options.all_repos && !options.repos.empty?
warn parser
Expand All @@ -81,6 +114,27 @@
exit 2
end

if options.confirm_apply && !options.apply
warn parser
warn "\nerror: --confirm-apply requires --apply"
exit 2
end

if options.apply && options.all_repos
warn parser
warn "\nerror: --apply is only allowed with explicit --repo values during the pilot"
exit 2
end

if options.apply && options.confirm_apply && !options.allow_non_pilot_repo
outside_pilot = options.repos.reject { |repo| PILOT_APPLY_REPOS.include?(repo) }
unless outside_pilot.empty?
warn "error: apply pilot is limited to: #{PILOT_APPLY_REPOS.join(', ')}"
warn "rerun with --allow-non-pilot-repo only after maintainer approval"
exit 2
end
end

def gh_json(*args)
stdout, stderr, status = Open3.capture3("gh", *args)
unless status.success?
Expand All @@ -100,6 +154,38 @@ def gh_paginated_array(path)
stdout.lines.reject { |line| line.strip.empty? }.map { |line| JSON.parse(line) }
end

def gh_api_mutation(*args)
stdout, stderr, status = Open3.capture3("gh", "api", *args)
unless status.success?
raise "gh api #{args.join(' ')} failed: #{stderr.strip.empty? ? stdout.strip : stderr.strip}"
end
stdout.strip.empty? ? {} : JSON.parse(stdout)
end

def label_path_segment(name)
URI.encode_www_form_component(name).gsub("+", "%20")
end

def create_label(owner_repo, label)
gh_api_mutation(
"repos/#{owner_repo}/labels",
"--method", "POST",
"-f", "name=#{label.fetch('name')}",
"-f", "color=#{label.fetch('color')}",
"-f", "description=#{label.fetch('description')}"
)
end

def update_label(owner_repo, label)
gh_api_mutation(
"repos/#{owner_repo}/labels/#{label_path_segment(label.fetch('name'))}",
"--method", "PATCH",
"-f", "new_name=#{label.fetch('name')}",
"-f", "color=#{label.fetch('color')}",
"-f", "description=#{label.fetch('description')}"
)
end

def repo_list(org)
gh_json("repo", "list", org, "--limit", "1000", "--json", "nameWithOwner").map { |repo| repo.fetch("nameWithOwner") }
end
Expand Down Expand Up @@ -191,54 +277,171 @@ def diff_repo(owner_repo, canonical, legacy_migrations)
}
end

def planned_label_operations(result, canonical)
creates = result.fetch("missing").map { |name| canonical.fetch(name) }
updates = result.fetch("updates").map { |item| canonical.fetch(item.fetch("name")) }

{
"would_create" => creates,
"would_update" => updates,
"skipped_legacy" => result.fetch("legacy_present").map { |item| item.fetch("legacy") },
"skipped_unknown" => result.fetch("unknown")
}
end

def apply_label_operations(owner_repo, operations)
result = {
"created" => [],
"updated" => [],
"skipped_legacy" => operations.fetch("skipped_legacy"),
"skipped_unknown" => operations.fetch("skipped_unknown"),
"errors" => []
}

operations.fetch("would_create").each do |label|
begin
create_label(owner_repo, label)
result.fetch("created") << label.fetch("name")
rescue StandardError => e
result.fetch("errors") << {
"operation" => "create",
"label" => label.fetch("name"),
"message" => e.message
}
return result
end
end

operations.fetch("would_update").each do |label|
begin
update_label(owner_repo, label)
result.fetch("updated") << label.fetch("name")
rescue StandardError => e
result.fetch("errors") << {
"operation" => "update",
"label" => label.fetch("name"),
"message" => e.message
}
return result
end
end

result
end

def clean?(result)
result.fetch("summary").values.all?(&:zero?)
end

def print_label_list(title, labels)
return if labels.empty?

puts title
labels.each do |label|
name = label.is_a?(Hash) ? label.fetch("name") : label
puts "- #{name}"
end
puts
end

canonical, legacy_migrations, sync_policy = canonical_label_map(options.labels_file)
repos = options.all_repos ? repo_list(options.org) : options.repos
results = repos.sort.map { |repo| diff_repo(repo, canonical, legacy_migrations) }

if options.apply
results.each do |result|
result["operations"] = planned_label_operations(result, canonical)
end
end

apply_failed = false
if options.apply && options.confirm_apply
results.each do |result|
result["applied"] = apply_label_operations(result.fetch("repo"), result.fetch("operations"))
apply_failed ||= !result.fetch("applied").fetch("errors").empty?
end
end

payload = {
"mode" => options.apply ? (options.confirm_apply ? "apply" : "apply-preview") : "dry-run",
"confirmed" => options.confirm_apply,
"labels_file" => options.labels_file,
"canonical_labels" => canonical.length,
"legacy_migrations" => legacy_migrations.length,
"sync_policy" => sync_policy,
"pilot_apply_repos" => PILOT_APPLY_REPOS,
"repos_scanned" => results.length,
"repos_with_drift" => results.count { |result| !clean?(result) },
"results" => results
}

if options.json
puts JSON.pretty_generate(payload)
exit 0
exit(apply_failed ? 1 : 0)
end

puts "# Label sync dry-run"
heading = options.apply ? "# Label sync apply preview" : "# Label sync dry-run"
heading = "# Label sync apply result" if options.apply && options.confirm_apply
puts heading
puts
puts "Mode: #{payload.fetch('mode')}"
puts "Labels file: `#{options.labels_file}`"
puts "Canonical labels: #{canonical.length}"
puts "Legacy migrations: #{legacy_migrations.length}"
puts "Repos scanned: #{results.length}"
puts "Repos with drift: #{payload.fetch('repos_with_drift')}"
puts
puts "This is a read-only dry run. No labels or issues were changed."

if options.apply && options.confirm_apply
puts "Confirmed apply mode: canonical labels may have been created or updated."
elsif options.apply
puts "Apply preview only. Pass --confirm-apply to create/update canonical labels."
else
puts "This is a read-only dry run. No labels or issues were changed."
end
puts "Legacy and unknown labels are skipped/preserved; no labels are deleted."
puts

puts "## Sync policy"
puts
sync_policy.each do |key, value|
puts "- #{key}: #{value}"
end
puts

if options.apply
puts "## Apply pilot guardrails"
puts
puts "- org-wide apply is disabled"
puts "- confirmed apply requires explicit --repo values"
puts "- pilot allowlist: #{PILOT_APPLY_REPOS.join(', ')}"
puts "- use --allow-non-pilot-repo only after maintainer approval"
puts
end

results.each do |result|
next if clean?(result) && !options.include_clean
next if clean?(result) && !options.include_clean && !options.apply

puts "## #{result.fetch('repo')}"
puts
if clean?(result)
puts "Clean: no missing, mismatched, legacy, or unknown labels."
puts
end

if options.apply
operations = result.fetch("operations")
print_label_list("### Would create", operations.fetch("would_create"))
print_label_list("### Would update", operations.fetch("would_update"))
print_label_list("### Skipped legacy labels", operations.fetch("skipped_legacy"))
print_label_list("### Skipped unknown local labels", operations.fetch("skipped_unknown"))

if result.key?("applied")
applied = result.fetch("applied")
print_label_list("### Created", applied.fetch("created"))
print_label_list("### Updated", applied.fetch("updated"))
print_label_list("### Apply errors", applied.fetch("errors"))
end
next
end

Expand Down
Loading
Loading