-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathbackporter.jl
More file actions
executable file
·1155 lines (985 loc) · 39.3 KB
/
backporter.jl
File metadata and controls
executable file
·1155 lines (985 loc) · 39.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env -S julia
"""
Julia Backporter Tool
A CLI tool for backporting Julia PRs to release branches.
Automatically detects target version and repository from git context.
Requires GITHUB_TOKEN environment variable to be set.
This script has been significantly refactored from its original ad-hoc form (with AI assistance):
- Added proper CLI interface with argument parsing
- Implemented smart defaults (auto-detect version from branch, repo from git remote)
- Added configuration management and error handling
- Parallel PR fetching for better performance
- Safety checks (branch validation, clean working directory)
- Automatic fetch/rebase before starting backports
- Proper project environment activation with symlink resolution
"""
# Activate the project environment in the script's directory (resolve symlinks)
import Pkg
script_dir = dirname(realpath(@__FILE__))
Pkg.activate(script_dir)
import GitHub
import Dates
import JSON
import HTTP
import URIs
using Dates: now
# ============================================================================
# Configuration
# ============================================================================
struct BackportConfig
backport_version::String
repo::String
backport_label::String
github_auth::String
end
function BackportConfig(backport_version::AbstractString, repo::AbstractString="JuliaLang/julia")
github_auth = get(ENV, "GITHUB_TOKEN", "")
if isempty(github_auth)
error("GITHUB_TOKEN environment variable must be set")
end
backport_label = "backport $backport_version"
BackportConfig(backport_version, repo, backport_label, github_auth)
end
# ============================================================================
# Command Line Interface
# ============================================================================
struct CLIOptions
version::Union{String, Nothing}
repo::Union{String, Nothing}
help::Bool
dry_run::Bool
validate_branch::Bool
require_clean::Bool
test_commit::Union{String, Nothing}
audit::Bool # Run label audit mode
cleanup_pr::Union{Int, Nothing} # PR number for cleanup mode
end
function parse_cli_args(args::Vector{String})
options = CLIOptions(nothing, nothing, false, false, true, true, nothing, false, nothing)
i = 1
while i <= length(args)
arg = args[i]
if arg == "--help" || arg == "-h"
options = CLIOptions(options.version, options.repo, true, options.dry_run, options.validate_branch, options.require_clean, options.test_commit, options.audit, options.cleanup_pr)
elseif arg == "--version" || arg == "-v"
if i + 1 <= length(args)
options = CLIOptions(args[i+1], options.repo, options.help, options.dry_run, options.validate_branch, options.require_clean, options.test_commit, options.audit, options.cleanup_pr)
i += 1
else
error("--version requires a value")
end
elseif arg == "--repo" || arg == "-r"
if i + 1 <= length(args)
options = CLIOptions(options.version, args[i+1], options.help, options.dry_run, options.validate_branch, options.require_clean, options.test_commit, options.audit, options.cleanup_pr)
i += 1
else
error("--repo requires a value")
end
elseif arg == "--test-commit" || arg == "-t"
if i + 1 <= length(args)
options = CLIOptions(options.version, options.repo, options.help, options.dry_run, options.validate_branch, options.require_clean, args[i+1], options.audit, options.cleanup_pr)
i += 1
else
error("--test-commit requires a commit hash")
end
elseif arg == "--dry-run" || arg == "-n"
options = CLIOptions(options.version, options.repo, options.help, true, options.validate_branch, options.require_clean, options.test_commit, options.audit, options.cleanup_pr)
elseif arg == "--no-validate-branch"
options = CLIOptions(options.version, options.repo, options.help, options.dry_run, false, options.require_clean, options.test_commit, options.audit, options.cleanup_pr)
elseif arg == "--no-require-clean"
options = CLIOptions(options.version, options.repo, options.help, options.dry_run, options.validate_branch, false, options.test_commit, options.audit, options.cleanup_pr)
elseif arg == "--audit" || arg == "-a"
options = CLIOptions(options.version, options.repo, options.help, options.dry_run, options.validate_branch, options.require_clean, options.test_commit, true, options.cleanup_pr)
elseif arg == "--cleanup-pr"
if i + 1 <= length(args)
options = CLIOptions(options.version, options.repo, options.help, options.dry_run, options.validate_branch, options.require_clean, options.test_commit, true, parse(Int, args[i+1]))
i += 1
else
error("--cleanup-pr requires a PR number")
end
else
error("Unknown argument: $arg")
end
i += 1
end
return options
end
function show_help()
println("Julia Backporter Tool")
println("====================")
println()
println("USAGE:")
println(" julia backporter.jl [OPTIONS]")
println()
println("OPTIONS:")
println(" -v, --version VERSION Backport version (e.g., 1.11, 1.12)")
println(" -r, --repo REPO Repository in format owner/name")
println(" -t, --test-commit HASH Test backport of a single commit")
println(" -n, --dry-run Show what would be done without making changes")
println(" --no-validate-branch Skip branch name validation")
println(" --no-require-clean Allow dirty working directory")
println(" -a, --audit Run label audit mode (check backport labels)")
println(" --cleanup-pr NUMBER Audit mode: process commits from a specific merged PR")
println(" -h, --help Show this help message")
println()
println("MODES:")
println(" Default: Backport PRs with the backport label to the release branch")
println(" Audit (--audit): Identify PRs with stale backport labels")
println(" Cleanup (--cleanup-pr): Check backport labels for a specific merged PR")
println()
println("DEFAULTS:")
println(" Version is auto-detected from current branch name")
println(" Repository is auto-detected from git remote origin")
println()
println("ENVIRONMENT:")
println(" GITHUB_TOKEN GitHub personal access token (required)")
println()
println("EXAMPLES:")
println(" export GITHUB_TOKEN=ghp_xxxxxxxxxxxx")
println(" julia backporter.jl # Use auto-detected settings")
println(" julia backporter.jl -v 1.11 # Backport to version 1.11")
println(" julia backporter.jl -r myorg/julia # Use custom repository")
println(" julia backporter.jl --dry-run # Preview changes only")
println(" julia backporter.jl -t 89dfb68 # Test single commit backport")
println(" julia backporter.jl --audit # Audit backport labels")
println(" julia backporter.jl --audit -v 1.11 # Audit specific version")
println(" julia backporter.jl --cleanup-pr 1234 # Check labels after PR merge")
end
# ============================================================================
# Git Operations
# ============================================================================
function cherry_picked_commits(version)
commits = Set{String}()
base = "origin/release-$version"
against = "backports-release-$version"
# Check if branches exist
if !success(`git rev-parse --verify $base`)
error("Base branch '$base' does not exist")
end
if !success(`git rev-parse --verify $against`)
error("Target branch '$against' does not exist")
end
try
# Get list of commit hashes first
hashes = readlines(`git log $base...$against --format=%H`)
# For each commit, get its full message and check for cherry-pick trailer
for hash in hashes
msg = read(`git log -1 --format=%B $hash`, String)
# Match cherry-pick trailer only at the end of lines (after newline or start)
# This avoids matching the pattern if it appears mid-sentence in the body
for match in eachmatch(r"(?:^|\n)\(cherry picked from commit ([a-f0-9]+)\)\s*$"m, msg)
push!(commits, match.captures[1])
end
end
catch e
error("Failed to get git log between $base and $against: $e")
end
return commits
end
function get_parents(hash::AbstractString)
try
result = read(`git rev-list --parents -n 1 $hash`, String)
return split(chomp(result))[2:end]
catch e
error("Failed to get parents for commit $hash: $e")
end
end
function get_real_hash(hash::AbstractString)
parents = get_parents(hash)
if length(parents) == 2 # It's a merge commit, use the second parent
hash = parents[2]
end
return hash
end
# Check if a PR's merge commit has been cherry-picked to the backport branch.
# We check both the transformed hash (get_real_hash extracts the second parent
# for merge commits) and the original merge_commit_sha, since someone
# cherry-picking manually may use merge_commit_sha directly. See issue #15.
function is_pr_backported(pr, already_backported_commits::Set{String})
return get_real_hash(pr.merge_commit_sha) in already_backported_commits ||
pr.merge_commit_sha in already_backported_commits
end
function is_working_directory_clean()
return success(`git diff --quiet`) && success(`git diff --cached --quiet`)
end
function validate_git_state(options::CLIOptions)
# Check if we're on the expected branch
if options.validate_branch
current_branch = branch()
expected_branch_pattern = r"^backports?-release-"
if !occursin(expected_branch_pattern, current_branch)
if options.dry_run
@warn "Current branch '$current_branch' doesn't match expected backport branch pattern."
else
@warn "Current branch '$current_branch' doesn't match expected backport branch pattern."
print("Continue anyway? (y/N): ")
response = readline()
if lowercase(strip(response)) != "y"
error("Exiting due to unexpected branch name.")
end
end
end
end
# Check if working directory is clean
if options.require_clean && !is_working_directory_clean()
error("Working directory is not clean. Please commit or stash changes before running backporter.")
end
end
function fetch_and_rebase(config::BackportConfig, options::CLIOptions)
current_branch = branch()
println("Fetching latest changes from origin...")
if !success(`git fetch origin`)
error("Failed to fetch from origin")
end
# Check if the remote tracking branch exists
if !success(`git rev-parse --verify origin/$current_branch`)
println("Remote branch origin/$current_branch does not exist, skipping rebase")
return
end
# Check if rebase is needed by comparing HEAD with origin
println("Rebasing $current_branch onto origin/$current_branch...")
if !options.dry_run
# Check if we're already up-to-date
local_head = chomp(String(read(`git rev-parse HEAD`)))
remote_head = chomp(String(read(`git rev-parse origin/$current_branch`)))
if local_head == remote_head
println("Already up-to-date with origin/$current_branch")
elseif success(`git merge-base --is-ancestor origin/$current_branch HEAD`)
println("Local branch is ahead of origin/$current_branch, no rebase needed")
else
if !success(`git rebase origin/$current_branch`)
# Try to abort the rebase
try
read(`git rebase --abort`)
catch
# Ignore abort errors
end
error("Failed to rebase $current_branch onto origin/$current_branch. Please resolve conflicts manually.")
end
println("Successfully rebased onto origin/$current_branch")
end
else
println("[DRY RUN] Would rebase $current_branch onto origin/$current_branch")
end
end
function try_cherry_pick(hash::AbstractString)
if !success(`git cherry-pick -x $hash`)
# Check if the cherry-pick failed due to an empty commit (already backported)
try
status_output = read(`git status --porcelain`, String)
if isempty(strip(status_output))
# Working tree is clean, check if we're in a cherry-pick state with empty commit
try
cherry_pick_head = read(`git rev-parse --verify CHERRY_PICK_HEAD`, String)
if !isempty(strip(cherry_pick_head))
# We're in cherry-pick state with empty commit - skip it and treat as success
read(`git cherry-pick --skip`)
println(" Skipped empty commit $hash (already backported)")
return true
end
catch
# Not in cherry-pick state, proceed with merge commit check
end
end
# Check if this is a merge commit and try with -m 1
parents = get_parents(hash)
if length(parents) > 1
println(" Detected merge commit $hash, retrying with -m 1...")
read(`git cherry-pick --abort`) # Clean up first
if success(`git cherry-pick -x $hash -m 1`)
println(" Successfully cherry-picked merge commit $hash with -m 1")
return true
else
# Still failed even with -m 1, abort and return false
try
read(`git cherry-pick --abort`)
catch e
@warn "Failed to abort cherry-pick after -m 1 attempt: $e"
end
return false
end
end
# Regular failure case - abort the cherry-pick
read(`git cherry-pick --abort`)
catch e
@warn "Failed to abort cherry-pick: $e"
end
return false
end
return true
end
function branch()
try
return chomp(String(read(`git rev-parse --abbrev-ref HEAD`)))
catch e
error("Failed to get current branch: $e")
end
end
function detect_version_from_branch()
# Detect backport version from current branch name
current_branch = branch()
# Match patterns like: backports-release-1.11, backport-release-1.12, etc.
m = match(r"backports?-release-([0-9]+\.[0-9]+)", current_branch)
if m !== nothing
return m.captures[1]
end
# Match patterns like: release-1.11, release-1.12
m = match(r"release-([0-9]+\.[0-9]+)", current_branch)
if m !== nothing
return m.captures[1]
end
return nothing
end
function detect_repo_from_remote()
# Detect repository from git remote origin
try
remote_url = chomp(String(read(`git remote get-url origin`)))
# Handle GitHub SSH URLs: git@github.com:owner/repo.git
m = match(r"git@github\.com:([^/]+/[^/]+)\.git", remote_url)
if m !== nothing
return m.captures[1]
end
# Handle GitHub HTTPS URLs: https://github.com/owner/repo.git
m = match(r"https:\/\/github\.com\/([\w]*?\/[\w]*?)(?:\.git)?$", remote_url)
if m !== nothing
return m.captures[1]
end
@warn "Could not parse repository from remote URL: $remote_url"
return nothing
catch e
@warn "Failed to get git remote origin: $e"
return nothing
end
end
function create_config_from_options(options::CLIOptions)
# Create BackportConfig from CLI options with smart defaults
# Determine version
version = options.version
if version === nothing
version = detect_version_from_branch()
if version === nothing
error("Could not detect version from branch name. Please specify with --version")
end
println("Auto-detected version: $version")
end
# Determine repository
repo = options.repo
if repo === nothing
repo = detect_repo_from_remote()
if repo === nothing
repo = "JuliaLang/julia" # fallback default
println("Using default repository: $repo")
else
println("Auto-detected repository: $repo")
end
end
return BackportConfig(version, repo)
end
# ============================================================================
# Data Structures
# ============================================================================
# GitHub authentication
struct GitHubAuthenticator
auth::Ref{GitHub.Authorization}
end
GitHubAuthenticator() = GitHubAuthenticator(Ref{GitHub.Authorization}())
function authenticate!(authenticator::GitHubAuthenticator, config::BackportConfig)
if !isassigned(authenticator.auth)
try
authenticator.auth[] = GitHub.authenticate(config.github_auth)
catch e
error("Failed to authenticate with GitHub: $e. Please check your GITHUB_TOKEN.")
end
end
return authenticator.auth[]
end
function find_pr_associated_with_commit(hash::AbstractString, config::BackportConfig, auth::GitHubAuthenticator)
try
auth_ref = authenticate!(auth, config)
request_path = "/search/issues?q=$hash+type:pr+repo:$(config.repo)"
json = GitHub.gh_get_json(GitHub.DEFAULT_API, request_path; auth=auth_ref)
if json["total_count"] !== 1
return nothing
end
item = only(json["items"])
if !haskey(item, "pull_request")
return nothing
end
pr = parse(Int, basename(item["pull_request"]["url"]))
return pr
catch e
@warn "Failed to find PR for commit $hash: $e"
return nothing
end
end
function was_squashed_pr(pr, config::BackportConfig, auth::GitHubAuthenticator)
parents = get_parents(pr.merge_commit_sha)
if length(parents) != 1
return false
end
return pr.number != find_pr_associated_with_commit(parents[1], config, auth)
end
# ============================================================================
# GitHub API Functions
# ============================================================================
function collect_label_prs(config::BackportConfig, auth::GitHubAuthenticator)
prs = []
page = 1
backport_label_encoded = replace(config.backport_label, " " => "+")
while true
query = "repo:$(config.repo)+is:pr+label:%22$backport_label_encoded%22"
search_path = "/search/issues?q=$query&per_page=100&page=$page"
auth_ref = authenticate!(auth, config)
try
data = GitHub.gh_get_json(GitHub.DEFAULT_API, search_path; auth=auth_ref)
append!(prs, data["items"])
# Check if there are more pages
if !haskey(data, "items") || isempty(data["items"])
break
end
page += 1
catch e
error("Failed to fetch PRs from GitHub: $e")
end
end
# Filter and map to your desired structure if necessary
println("Fetching detailed PR information for $(length(prs)) PRs...")
# Fetch detailed PR information in parallel
pr_numbers = [pr_item["number"] for pr_item in prs]
if !isempty(pr_numbers)
println("Fetching $(length(pr_numbers)) PRs in parallel...")
# Fetch PRs in parallel using asyncmap
auth_ref = authenticate!(auth, config)
detailed_prs = asyncmap(pr_numbers; ntasks=min(20, length(pr_numbers))) do pr_number
try
GitHub.pull_request(config.repo, pr_number; auth=auth_ref)
catch e
@warn "Failed to fetch PR #$pr_number: $e"
nothing
end
end
# Filter out any failed fetches
return filter(pr -> pr !== nothing, detailed_prs)
else
return []
end
end
function do_backporting(config::BackportConfig, auth::GitHubAuthenticator)
label_prs = collect_label_prs(config, auth)
_do_backporting(label_prs, config, auth)
end
# Categorize PRs into: open, closed (unmerged), already backported, and candidates for backporting
function categorize_prs(prs, config::BackportConfig)
already_backported_commits = cherry_picked_commits(config.backport_version)
open_prs = []
closed_prs = []
already_backported = []
backport_candidates = []
for pr in prs
if pr.state != "closed"
push!(open_prs, pr)
else
if pr.merged_at === nothing
push!(closed_prs, pr)
elseif is_pr_backported(pr, already_backported_commits)
push!(already_backported, pr)
else
push!(backport_candidates, pr)
end
end
end
return (; open_prs, closed_prs, already_backported, backport_candidates)
end
function _do_backporting_analysis(prs, config::BackportConfig, auth::GitHubAuthenticator)
# Analyze PRs without making changes (for dry-run mode)
(; open_prs, closed_prs, already_backported, backport_candidates) = categorize_prs(prs, config)
println("Analysis Results:")
println(" Open PRs: $(length(open_prs))")
println(" Closed/unmerged PRs: $(length(closed_prs))")
println(" Already backported: $(length(already_backported))")
println(" Backport candidates: $(length(backport_candidates))")
# Show what would be done without actually doing it
if !isempty(backport_candidates)
println("\n[DRY RUN] Would attempt to backport:")
for pr in backport_candidates
println(" - #$(pr.number): $(pr.title)")
end
end
end
function test_single_commit(commit_hash::String, options::CLIOptions)
println("Testing backport of single commit: $commit_hash")
if options.dry_run
println("[DRY RUN] Would attempt to cherry-pick commit $commit_hash")
return
end
if try_cherry_pick(commit_hash)
println("✓ Successfully backported commit $commit_hash")
else
println("✗ Failed to backport commit $commit_hash")
end
end
function _do_backporting(prs, config::BackportConfig, auth::GitHubAuthenticator)
(; open_prs, closed_prs, already_backported, backport_candidates) = categorize_prs(prs, config)
sort!(closed_prs; by = x -> x.number)
sort!(already_backported; by = x -> x.merged_at)
sort!(backport_candidates; by = x -> x.merged_at)
failed_backports = []
successful_backports = []
multi_commit_prs = []
for pr in backport_candidates
if pr.commits === nothing
# Handle case where commits field is missing - refetch PR
i = findfirst(x -> x.number == pr.number, prs)
pr = GitHub.pull_request(config.repo, pr.number; auth=authenticate!(auth, config))
@assert pr.commits !== nothing
prs[i] = pr
end
if pr.commits != 1
# Check if this was squashed - we can still backport squashed PRs
if was_squashed_pr(pr, config, auth) && try_cherry_pick(get_real_hash(pr.merge_commit_sha))
push!(successful_backports, pr)
else
push!(multi_commit_prs, pr)
end
elseif try_cherry_pick(get_real_hash(pr.merge_commit_sha))
push!(successful_backports, pr)
else
push!(failed_backports, pr)
end
end
# Output results and recommendations
remove_label_prs = [closed_prs; already_backported]
if !isempty(remove_label_prs)
sort!(remove_label_prs; by = x -> (x.merged_at == nothing ? now() : x.merged_at))
println("The following PRs are closed or already backported but still has a backport label, remove the label:")
# https://github.com/KristofferC/Backporter/issues/11
println("(don't remove the label until you have merged the backports PR)")
for pr in remove_label_prs
println(" #$(pr.number) - $(pr.html_url)")
end
println()
end
if !isempty(open_prs)
println("The following PRs are open but have a backport label, merge first?")
for pr in open_prs
println(" #$(pr.number) - $(pr.html_url)")
end
println()
end
if !isempty(failed_backports)
println("The following PRs failed to backport cleanly, manually backport:")
for pr in failed_backports
println(" #$(pr.number) - $(pr.html_url) - $(pr.merge_commit_sha)")
end
println()
end
if !isempty(multi_commit_prs)
println("The following PRs had multiple commits, manually backport")
for pr in multi_commit_prs
println(" #$(pr.number) - $(pr.html_url)")
end
println()
end
if !isempty(successful_backports)
println("The following PRs were backported to this branch:")
for pr in successful_backports
println(" #$(pr.number) - $(pr.html_url)")
end
printstyled("Push the updated branch"; bold=true)
println()
end
println("Update the first post with:")
function summarize_pr(pr; checked=true)
println("- [$(checked ? "x" : " ")] #$(pr.number) <!-- $(pr.title) -->")
end
backported_prs = [successful_backports; already_backported]
if !isempty(backported_prs)
sort!(backported_prs; by = x -> x.merged_at)
println("Backported PRs:")
for pr in backported_prs
summarize_pr(pr)
end
end
if !isempty(failed_backports)
println()
println("Need manual backport:")
for pr in failed_backports
summarize_pr(pr; checked=false)
end
end
if !isempty(multi_commit_prs)
println()
println("Contains multiple commits, manual intervention needed:")
for pr in multi_commit_prs
summarize_pr(pr; checked=false)
end
end
if !isempty(open_prs)
println()
println("Non-merged PRs with backport label:")
for pr in open_prs
summarize_pr(pr; checked=false)
end
end
end
# ============================================================================
# Label Audit Functions
# ============================================================================
struct LabelAuditConfig
version::String
repo::String
github_auth::String
backport_label::String
release_branch::String
end
function LabelAuditConfig(version::String, repo::String)
github_auth = get(ENV, "GITHUB_TOKEN", "")
if isempty(github_auth)
error("GITHUB_TOKEN environment variable must be set")
end
backport_label = "backport $version"
release_branch = "release-$version"
LabelAuditConfig(version, repo, github_auth, backport_label, release_branch)
end
function get_github_auth(auth::String)
return GitHub.authenticate(auth)
end
function find_backport_versions(repo::String, github_auth::String)
versions = String[]
println("Discovering backport labels...")
page = 1
while true
auth_ref = get_github_auth(github_auth)
request_path = "/repos/$repo/labels?per_page=100&page=$page"
data = GitHub.gh_get_json(GitHub.DEFAULT_API, request_path; auth=auth_ref)
isempty(data) && break
for label in data
name = label["name"]
m = match(r"^backport (\d+\.\d+)$", name)
if m !== nothing
push!(versions, m.captures[1])
end
end
page += 1
end
sort!(versions; by=v -> VersionNumber(v), rev=true)
return versions
end
struct CommitInfo
sha::String
backport_pr::Union{Int,Nothing}
end
function extract_pr_numbers_from_message(message::String)
prs = Int[]
for m in eachmatch(r"\(#(\d+)\)", message)
push!(prs, parse(Int, m.captures[1]))
end
return prs
end
function extract_merge_pr_from_message(message::String)
m = match(r"Merge pull request #(\d+)", message)
m !== nothing && return parse(Int, m.captures[1])
return nothing
end
function clone_repo_to_temp(config::LabelAuditConfig)
temp_dir = mktempdir()
repo_url = "https://github.com/$(config.repo).git"
println("Cloning $(config.repo) to temporary directory...")
if !success(`git clone --filter=blob:none --no-checkout $repo_url $temp_dir`)
rm(temp_dir; force=true, recursive=true)
error("Failed to clone repository $(config.repo)")
end
return temp_dir
end
function get_commits_from_branch(config::LabelAuditConfig)
temp_dir = clone_repo_to_temp(config)
try
println("Fetching $(config.release_branch)...")
if !success(`git -C $temp_dir fetch origin $(config.release_branch)`)
error("Failed to fetch $(config.release_branch)")
end
return parse_git_log_for_commits(temp_dir, "origin/$(config.release_branch)")
finally
rm(temp_dir; force=true, recursive=true)
end
end
function get_commits_from_pr(config::LabelAuditConfig, pr_number::Int)
temp_dir = clone_repo_to_temp(config)
try
println("Fetching PR #$pr_number...")
if !success(`git -C $temp_dir fetch origin pull/$pr_number/head:pr-$pr_number`)
error("Failed to fetch PR #$pr_number")
end
return parse_git_log_for_commits(temp_dir, "pr-$pr_number"; backport_pr=pr_number)
finally
rm(temp_dir; force=true, recursive=true)
end
end
function parse_git_log_for_commits(repo_dir::String, ref::String; backport_pr::Union{Int,Nothing}=nothing)
commits = Dict{Int,CommitInfo}()
current_backport_pr = backport_pr
println("Parsing git log from $ref...")
log_output = read(`git -C $repo_dir log --format=%H%n%B%n---COMMIT_SEPARATOR--- $ref`, String)
for commit_block in split(log_output, "---COMMIT_SEPARATOR---")
commit_block = strip(commit_block)
isempty(commit_block) && continue
lines = split(commit_block, '\n')
isempty(lines) && continue
sha = strip(lines[1])
message = join(lines[2:end], '\n')
if backport_pr === nothing
merge_pr = extract_merge_pr_from_message(message)
if merge_pr !== nothing
current_backport_pr = merge_pr
end
end
for pr_num in extract_pr_numbers_from_message(message)
if !haskey(commits, pr_num)
commits[pr_num] = CommitInfo(sha, current_backport_pr)
end
end
end
return commits
end
function get_labeled_closed_prs(config::LabelAuditConfig)
prs = []
println("Fetching closed PRs with label $(config.backport_label)...")
page = 1
while true
auth_ref = get_github_auth(config.github_auth)
query = URIs.escapeuri("repo:$(config.repo) is:pr is:closed label:\"$(config.backport_label)\"")
request_path = "/search/issues?q=$query&per_page=100&page=$page"
data = GitHub.gh_get_json(GitHub.DEFAULT_API, request_path; auth=auth_ref)
items = get(data, "items", [])
isempty(items) && break
for item in items
haskey(item, "pull_request") && push!(prs, item)
end
page += 1
end
return prs
end
function remove_backport_label(config::LabelAuditConfig, pr_number::Int)
auth_ref = get_github_auth(config.github_auth)
encoded_label = URIs.escapeuri(config.backport_label)
request_path = "/repos/$(config.repo)/issues/$pr_number/labels/$encoded_label"
GitHub.gh_delete(GitHub.DEFAULT_API, request_path; auth=auth_ref)
end
struct AuditResult
to_remove::Vector{Tuple{Int,String,CommitInfo}}
to_keep::Vector{Tuple{Int,String}}
end
function audit_labels(config::LabelAuditConfig; pr_commits::Union{Dict{Int,CommitInfo},Nothing}=nothing)
commits = if pr_commits !== nothing
pr_commits
else
get_commits_from_branch(config)
end
println("Found $(length(commits)) cherry-picked PRs")
labeled_prs = get_labeled_closed_prs(config)
println("Found $(length(labeled_prs)) closed PRs with label $(config.backport_label)")
to_remove = Tuple{Int,String,CommitInfo}[]
to_keep = Tuple{Int,String}[]
for pr in labeled_prs
pr_num = pr["number"]
title = pr["title"]
if haskey(commits, pr_num)
push!(to_remove, (pr_num, title, commits[pr_num]))
else
push!(to_keep, (pr_num, title))
end
end
return AuditResult(to_remove, to_keep)
end
function format_backport_info(info::CommitInfo)
bp_str = info.backport_pr !== nothing ? " via #$(info.backport_pr)" : ""
return "$(info.sha[1:7])$bp_str"
end
function print_audit_results(result::AuditResult, config::LabelAuditConfig)
println()
println("=== PRs already backported (label should be removed) ===")
if isempty(result.to_remove)
println("None")
else
for (pr_num, title, info) in result.to_remove
println(" #$pr_num: $title ($(format_backport_info(info)))")
end
end
println()
println("=== PRs still needing backport (label should remain) ===")
if isempty(result.to_keep)
println("None")
else
for (pr_num, title) in result.to_keep
println(" #$pr_num: $title")
end
end
println()
end
function apply_audit_changes(result::AuditResult, config::LabelAuditConfig)
if isempty(result.to_remove)
println("No labels to remove")
return
end
println("Removing labels...")
for (pr_num, title, info) in result.to_remove
try
remove_backport_label(config, pr_num)
println(" Removed label from #$pr_num")
catch e
println(" Error processing #$pr_num: $e")
end
end
println()
println("Done. Removed $(config.backport_label) from $(length(result.to_remove)) PR(s)")
end
function run_audit_for_version(version::String, repo::String, dry_run::Bool, cleanup_pr::Union{Int,Nothing})
if !occursin(r"^\d+\.\d+$", version)
error("Invalid version format: $version. Expected X.Y (e.g., 1.13)")
end
config = LabelAuditConfig(version, repo)
println("Backport Label Audit")
println("====================")
println("Version: $(config.version)")
println("Repository: $(config.repo)")
println("Label: $(config.backport_label)")
println("Branch: $(config.release_branch)")
println("Dry run: $dry_run")
println()
if cleanup_pr !== nothing
pr_commits = get_commits_from_pr(config, cleanup_pr)
if isempty(pr_commits)
println("No cherry-picked PRs found in PR #$cleanup_pr")
return
end