|
3 | 3 | [babashka.cli :as cli]
|
4 | 4 | [babashka.process :as p]
|
5 | 5 | [cheshire.core :as json]
|
6 |
| - [clojure.pprint :as pp] |
7 | 6 | [clojure.string :as str]
|
8 | 7 | [ice.core :as ice]
|
9 | 8 | [util :as u]))
|
|
17 | 16 | :source-branch {:ref "<source-branch>"
|
18 | 17 | :desc "The source branch of the triggering PR."
|
19 | 18 | :alias :r
|
20 |
| - :require true} |
21 |
| - :dry-run {:desc "If set, will not execute the command, just print it out." |
22 |
| - :coerce :boolean |
23 |
| - :default false}} |
| 19 | + :require true}} |
24 | 20 | :error-fn u/cli-error-fn})
|
25 | 21 |
|
26 |
| -(defn source+target-branch->pr-number [source-branch target-branch] |
27 |
| - (let [head-ref-name (str source-branch "->" target-branch) |
28 |
| - _ (prn {:source-branch source-branch |
| 22 | +(defn- find-pr-list [source-branch target-branch] |
| 23 | + (let [pr (-> (p/sh "gh" "pr" "list" |
| 24 | + "--head" (u/head-ref-name source-branch target-branch) |
| 25 | + "--json" "number,headRefName") |
| 26 | + :out |
| 27 | + (json/parse-string true) |
| 28 | + first)] |
| 29 | + (if pr |
| 30 | + (do (ice/p [:green "Found PR #" (:number pr)]) |
| 31 | + (:number pr)) |
| 32 | + (throw (ex-info |
| 33 | + (str "No PR found for " (u/head-ref-name source-branch target-branch)) |
| 34 | + {:source-branch source-branch |
29 | 35 | :target-branch target-branch
|
30 |
| - :head-ref-name head-ref-name}) |
31 |
| - prs (-> (p/shell {:out :string} |
32 |
| - "gh" "pr" "list" |
33 |
| - "--limit" "1000" |
34 |
| - "--repo" "metabase/docs.metabase.github.io" |
35 |
| - "--json" "number,headRefName") |
36 |
| - :out |
37 |
| - (json/parse-string true) |
38 |
| - (into [])) |
39 |
| - _ (println "→ Open PR count: " (count prs)) |
40 |
| - _ (println "→ Open PRs: \n" |
41 |
| - (str/join "\n" |
42 |
| - (map #(str " " %) |
43 |
| - (str/split-lines (with-out-str (pp/pprint prs)))))) |
44 |
| - _ (ice/p "See: " [:bold "https://github.com/metabase/docs.metabase.github.io/pulls"] " for more details") |
45 |
| - _ (println "→ Looking for PR with headRefName:" head-ref-name) |
46 |
| - pr-to-merge (first (filter #(= (:headRefName %) head-ref-name) prs))] |
47 |
| - (println "Found PR: " (pr-str pr-to-merge)) |
48 |
| - (:number pr-to-merge))) |
49 |
| - |
50 |
| -(defn update-pr-branch |
51 |
| - "Update the PR branch to include latest changes from base branch, resolving conflicts by taking incoming changes" |
52 |
| - [{:keys [dry-run? pr-number head-ref-name]}] |
53 |
| - (ice/p [:blue "Updating PR branch to latest master..."]) |
54 |
| - (if-not dry-run? |
55 |
| - ;; First try the API approach (clean merge) |
56 |
| - (let [{:keys [exit]} (p/sh "gh" "api" |
57 |
| - "--method" "PUT" |
58 |
| - (str "/repos/metabase/docs.metabase.github.io/pulls/" pr-number "/update-branch"))] |
59 |
| - (if (zero? exit) |
60 |
| - (ice/p [:green "✓ PR branch updated successfully via API"]) |
61 |
| - (do |
62 |
| - (ice/p [:yellow "API update failed, likely due to conflict, trying git-based resolution..."]) |
63 |
| - ;; If API fails due to conflicts, resolve manually |
64 |
| - (try |
65 |
| - ;; Fetch latest and checkout the PR branch |
66 |
| - (p/sh "git" "fetch" "origin") |
67 |
| - (p/sh "git" "checkout" head-ref-name) |
68 |
| - (prn (p/sh "git" "status")) |
69 |
| - |
70 |
| - ;; Try to merge master - this will show conflicts |
71 |
| - (let [merge-result (p/shell {:continue true} "git" "merge" "origin/master")] |
72 |
| - (if (= 0 (:exit merge-result)) |
73 |
| - (ice/p [:green "✓ Clean merge successful"]) |
74 |
| - (do |
75 |
| - ;; Resolve conflicts by taking all changes from The PR Branch |
76 |
| - (ice/p [:blue "Resolving conflicts by preferring our changes..."]) |
77 |
| - (p/sh "git" "checkout" "--ours" ".") |
78 |
| - (p/sh "git" "add" ".") |
79 |
| - (p/sh "git" "commit" "--no-edit" "-m" (str "Merge master, preferring changes from PR #" pr-number)) |
80 |
| - (ice/p [:green "✓ Conflicts resolved, preferring PR branch's changes"])))) |
81 |
| - |
82 |
| - ;; Push the updated branch |
83 |
| - (p/sh "git" "push" "origin" head-ref-name) |
84 |
| - (ice/p [:green "✓ PR branch updated via git"]) |
85 |
| - |
86 |
| - (catch Exception git-e |
87 |
| - (ice/p [:red "Git-based update also failed: " (.getMessage git-e)])))))) |
88 |
| - (println "Dry run mode: would update PR branch, resolving conflicts by preferring incoming changes"))) |
89 |
| - |
90 |
| -(defn- gh-pr-merge [dry-run? pr-number] |
91 |
| - (let [cmd ["gh" "pr" "merge" pr-number "--squash" "--delete-branch"]] |
92 |
| - (if dry-run? |
93 |
| - (ice/p [:yellow "Dry run mode: not actually merging PR:\n" |
94 |
| - [:white [:bold "Would run: "] [:underline (str/join " " cmd)]]]) |
95 |
| - (apply p/sh cmd)))) |
| 36 | + :babashka/exit 1}))))) |
| 37 | + |
| 38 | +(defn- find-pr-view [source-branch target-branch] |
| 39 | + (let [pr-num (-> (p/sh "gh" "pr" "view" (u/head-ref-name source-branch target-branch) |
| 40 | + "--json" "number" |
| 41 | + "--jq" ".number") |
| 42 | + :out |
| 43 | + str/trim)] |
| 44 | + (when pr-num (parse-long pr-num)))) |
| 45 | + |
| 46 | +(defn- resolve-conflicts |
| 47 | + "Resolve conflicts by auto-merging changes in artifact directories based on merge-strategy. |
| 48 | + If merge-strategy is :ours, prefer changes from the PR branch. |
| 49 | + If merge-strategy is :theirs, prefer changes from the target branch." |
| 50 | + [artifact-dirs merge-strategy] |
| 51 | + (let [conflicted-files (->> (p/shell {:out :string :continue true} |
| 52 | + "git" "diff" "--name-only" "--diff-filter=U") |
| 53 | + :out |
| 54 | + str/trim |
| 55 | + str/split-lines |
| 56 | + (remove str/blank?)) |
| 57 | + strat (case merge-strategy |
| 58 | + :ours "--ours" |
| 59 | + :theirs "--theirs")] |
| 60 | + (if (empty? conflicted-files) |
| 61 | + (ice/p [:green "No conflicts to resolve"]) |
| 62 | + (do |
| 63 | + (ice/p [:blue "Conflicted files: " (str/join ", " conflicted-files)]) |
| 64 | + (ice/p [:blue "Artifact directories: " (str/join ", " artifact-dirs)]) |
| 65 | + (doseq [dir artifact-dirs] |
| 66 | + (let [files-in-dir (filter #(str/starts-with? % dir) conflicted-files)] |
| 67 | + (when (seq files-in-dir) |
| 68 | + (ice/p [:yellow "Resolving conflicts in directory: " dir]) |
| 69 | + (doseq [file files-in-dir] |
| 70 | + (ice/p [:yellow "Resolving file: " file]) |
| 71 | + (ice/p [:yellow " - Checking out " strat ": | " (:out (p/sh "git" "checkout" strat file))]) |
| 72 | + (ice/p [:yellow " - Adding file: | " (:out (p/sh "git" "add" file))]))))))))) |
| 73 | + |
| 74 | +(defn- update-and-merge-pr [source-branch target-branch pr-number merge-strategy] |
| 75 | + (let [head-ref-name (u/head-ref-name source-branch target-branch)] |
| 76 | + |
| 77 | + |
| 78 | + (ice/p [:blue "Updating PR branch..."]) |
| 79 | + (ice/p [:blue "Attempting merge with origin/master..."]) |
| 80 | + (let [merge-result (p/sh {:continue true} "git" "merge" "origin/master")] |
| 81 | + (when-not (zero? (:exit merge-result)) |
| 82 | + (ice/p [:red "✗ Merge failed: " (:err merge-result)]) |
| 83 | + (let [winner (if (= merge-strategy :ours) "PR" "master")] |
| 84 | + (ice/p [:yellow "Attempting to resolve conflicts with git, preferring changes from " winner "..."]) |
| 85 | + (resolve-conflicts (u/->artifact-dirs target-branch) merge-strategy) |
| 86 | + ;; Do the commit, now that we've resolved conflicts |
| 87 | + (pr-str (p/sh "git" "commit" "--no-edit" "-m" |
| 88 | + (str "Merge " target-branch " for PR #(" pr-number ")" |
| 89 | + ", preferring changes from " winner))))) |
| 90 | + |
| 91 | + (ice/p [:blue "Pushing changes to PR branch..."]) |
| 92 | + (ice/p "Result: " (pr-str (p/sh "git" "push" "origin" head-ref-name)))) |
| 93 | + |
| 94 | + ;; Wait a bit for GitHub to process to avoid a race condition |
| 95 | + (Thread/sleep 5000) |
| 96 | + |
| 97 | + ;; Merge the PR |
| 98 | + (ice/p [:blue "Merging PR #" pr-number "..."]) |
| 99 | + (let [merge-result (p/sh {:continue true} |
| 100 | + "gh" "pr" "merge" (str pr-number) |
| 101 | + "--squash" "--delete-branch" |
| 102 | + "--repo" "metabase/docs.metabase.github.io")] |
| 103 | + (if (zero? (:exit merge-result)) |
| 104 | + (ice/p [:green "✓ PR merged successfully!"]) |
| 105 | + (ice/p [:red "✗ Merge failed: " [:bold (:err merge-result)]]))))) |
| 106 | + |
| 107 | +(defn- should-pr-win? |
| 108 | + "Determine if the current PR should win conflicts based on PR number comparison" |
| 109 | + [current-pr-number target-branch] |
| 110 | + (let [_ (p/sh "git" "fetch" "origin") |
| 111 | + latest-master-commit (-> (p/sh "git" "log" "--oneline" "-1" (str "origin/" target-branch)) |
| 112 | + :out |
| 113 | + str/trim) |
| 114 | + ;; Extract PR number from commit message like "[auto] adding content to docs-rc-notes->master (#380)" |
| 115 | + master-pr-match (re-find #"#(\d+)" latest-master-commit) |
| 116 | + master-pr-number (when master-pr-match (parse-long (second master-pr-match)))] |
| 117 | + (ice/p latest-master-commit) |
| 118 | + (ice/p [:blue "Current PR: #" current-pr-number]) |
| 119 | + (ice/p [:blue "Latest master commit: " latest-master-commit]) |
| 120 | + (when master-pr-number |
| 121 | + (ice/p [:blue "Latest master PR: #" master-pr-number])) |
| 122 | + |
| 123 | + (cond |
| 124 | + (nil? master-pr-number) |
| 125 | + (do (ice/p [:yellow "No PR number found in master, defaulting to PR wins"]) |
| 126 | + true) |
| 127 | + |
| 128 | + (>= current-pr-number master-pr-number) |
| 129 | + (do (ice/p [:green "Current PR #" current-pr-number " is newer than master PR #" master-pr-number " - PR wins"]) |
| 130 | + true) |
| 131 | + |
| 132 | + :else |
| 133 | + (do (ice/p [:yellow "Current PR #" current-pr-number " is older than master PR #" master-pr-number " - master wins"]) |
| 134 | + false)))) |
| 135 | + |
| 136 | +(defn- checkout-branch! [head-ref-name] |
| 137 | + ;; First, discard any local changes that would prevent checkout |
| 138 | + (ice/p [:yellow "Discarding local changes..."]) |
| 139 | + (p/sh "git" "reset" "--hard" "HEAD") |
| 140 | + (p/sh "git" "clean" "-fd") |
| 141 | + |
| 142 | + ;; Try to checkout the branch |
| 143 | + (let [checkout-result (p/shell {:continue true} "git" "checkout" head-ref-name)] |
| 144 | + (when-not (zero? (:exit checkout-result)) |
| 145 | + ;; Branch doesn't exist locally, create it from origin and force reset |
| 146 | + (ice/p [:yellow "Branch doesn't exist locally, creating from origin..."]) |
| 147 | + (let [create-result (p/sh {:continue true} "git" "checkout" "-B" head-ref-name (str "origin/" head-ref-name))] |
| 148 | + (when-not (zero? (:exit create-result)) |
| 149 | + (throw (ex-info (str "Failed to checkout or create branch " head-ref-name) |
| 150 | + {:branch head-ref-name |
| 151 | + :error (:err create-result) |
| 152 | + :babashka/exit 1}))))) |
| 153 | + |
| 154 | + ;; Force reset to match the remote branch exactly |
| 155 | + (ice/p [:yellow "Force resetting to match remote branch..."]) |
| 156 | + (p/sh "git" "reset" "--hard" (str "origin/" head-ref-name)))) |
96 | 157 |
|
97 | 158 | (defn -main [& args]
|
98 |
| - (let [{:keys [source-branch target-branch] |
99 |
| - dry-run? :dry-run |
100 |
| - :as opts} (cli/parse-opts args cli-spec) |
| 159 | + (println "Merge opertaion running at: " (str (java.time.Instant/now))) |
| 160 | + (let [{:keys [source-branch target-branch]} (cli/parse-opts args cli-spec) |
101 | 161 | [source-branch target-branch] (mapv str/trim [source-branch target-branch])
|
102 |
| - pr-number (source+target-branch->pr-number source-branch target-branch)] |
103 |
| - (when-not pr-number |
104 |
| - (throw (ex-info (ice/p-str [:red "No PR found for source branch "] [:bold source-branch] " and target branch " [:bold target-branch] ".") |
105 |
| - {:babashka/exit 1 :opts opts}))) |
106 |
| - (update-pr-branch {:dry-run? dry-run? |
107 |
| - :pr-number pr-number |
108 |
| - :head-ref-name (str source-branch "->" target-branch)}) |
109 |
| - (ice/p [:green "Merging PR for branch "] [:bold source-branch] " into " [:bold target-branch] " with PR number " [:bold (pr-str pr-number)]) |
110 |
| - (gh-pr-merge dry-run? pr-number))) |
| 162 | + head-ref-name (u/head-ref-name source-branch target-branch)] |
| 163 | + |
| 164 | + ;; Ensure we're working with the latest remote state |
| 165 | + (ice/p [:blue "Fetching latest from origin..."]) (p/sh "git" "fetch" "origin") |
| 166 | + (ice/p [:blue "Checking out branch: " head-ref-name]) (checkout-branch! head-ref-name) |
| 167 | + |
| 168 | + (let [current-branch (:out (p/sh "git" "branch" "--show-current")) |
| 169 | + _ (ice/p [:green "Currently on branch: " (str/trim current-branch)]) |
| 170 | + pr-number-view (try (find-pr-view source-branch target-branch) |
| 171 | + (catch Exception e |
| 172 | + (ice/p [:red "Error finding pr-number via view: " (ex-message e)]))) |
| 173 | + pr-number-list (try (find-pr-list source-branch target-branch) |
| 174 | + (catch Exception e |
| 175 | + (ice/p [:red "Error finding pr-number via list: " (ex-message e)]))) |
| 176 | + pr-number (or pr-number-view pr-number-list) |
| 177 | + merge-strategy (if (should-pr-win? pr-number target-branch) :ours :theirs)] |
| 178 | + (ice/p [:green "Merging PR #" pr-number ": " (u/head-ref-name source-branch target-branch) " | with strategy: " [:blue merge-strategy]]) |
| 179 | + (update-and-merge-pr source-branch target-branch pr-number merge-strategy)))) |
111 | 180 |
|
112 | 181 | (when (= *file* (System/getProperty "babashka.file"))
|
113 | 182 | (apply -main *command-line-args*))
|
0 commit comments