From 37cc85ca4c9b0980f2e595cca7a94f5339d22874 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Mon, 16 Mar 2026 18:49:27 +0900 Subject: [PATCH 01/18] [ZEPPELIN-6404] Rewrite merge PR CLI as single-file Go using standard library Replace multi-file cobra-based CLI with a single dev/merge-pr.go using only Go standard library (flag package). No go.mod needed - runs directly with `go run dev/merge-pr.go --pr [flags]`. Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.go | 524 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 524 insertions(+) create mode 100644 dev/merge-pr.go diff --git a/dev/merge-pr.go b/dev/merge-pr.go new file mode 100644 index 00000000000..7e7e3fc7956 --- /dev/null +++ b/dev/merge-pr.go @@ -0,0 +1,524 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// merge-pr.go merges Apache Zeppelin pull requests via the GitHub API, +// optionally cherry-picks into release branches, and resolves JIRA issues. +// +// Usage: +// +// go run dev/merge-pr.go --pr 5167 --dry-run +// go run dev/merge-pr.go --pr 5167 --resolve-jira --fix-version 0.13.0 +// go run dev/merge-pr.go --pr 5167 --resolve-jira --release-branch branch-0.12,branch-0.11 +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "regexp" + "sort" + "strconv" + "strings" +) + +const ( + githubAPIBase = "https://api.github.com/repos/apache/zeppelin" + jiraAPIBase = "https://issues.apache.org/jira/rest/api/2" +) + +// ── CSV flag type ─────────────────────────────────────────────────────────── + +type csvFlag []string + +func (f *csvFlag) String() string { return strings.Join(*f, ",") } +func (f *csvFlag) Set(v string) error { + for _, s := range strings.Split(v, ",") { + if t := strings.TrimSpace(s); t != "" { + *f = append(*f, t) + } + } + return nil +} + +// ── Flags ─────────────────────────────────────────────────────────────────── + +var ( + flagPR int + flagTarget string + flagFixVersions csvFlag + flagReleaseBranches csvFlag + flagResolveJira bool + flagDryRun bool + flagPRRemote string + flagPushRemote string + flagGithubToken string + flagJiraToken string +) + +func init() { + flag.IntVar(&flagPR, "pr", 0, "Pull request number (required)") + flag.StringVar(&flagTarget, "target", "", "Target branch (default: PR base branch)") + flag.Var(&flagFixVersions, "fix-version", "JIRA fix version(s), comma-separated") + flag.Var(&flagReleaseBranches, "release-branch", "Release branch(es) to cherry-pick into, comma-separated") + flag.BoolVar(&flagResolveJira, "resolve-jira", false, "Resolve associated JIRA issue(s)") + flag.BoolVar(&flagDryRun, "dry-run", false, "Show what would be done without making changes") + flag.StringVar(&flagPRRemote, "pr-remote", envOrDefault("PR_REMOTE_NAME", "apache"), "Git remote for pull requests") + flag.StringVar(&flagPushRemote, "push-remote", envOrDefault("PUSH_REMOTE_NAME", "apache"), "Git remote for pushing") + flag.StringVar(&flagGithubToken, "github-token", "", "GitHub OAuth token (env: GITHUB_OAUTH_KEY)") + flag.StringVar(&flagJiraToken, "jira-token", "", "JIRA access token (env: JIRA_ACCESS_TOKEN)") +} + +func envOrDefault(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// ── Git ───────────────────────────────────────────────────────────────────── + +func gitRun(args ...string) (string, error) { + out, err := exec.Command("git", args...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)), nil +} + +func gitCurrentRef() (string, error) { + ref, err := gitRun("rev-parse", "--abbrev-ref", "HEAD") + if err != nil { + return "", err + } + if ref == "HEAD" { + return gitRun("rev-parse", "HEAD") + } + return ref, nil +} + +// ── HTTP ──────────────────────────────────────────────────────────────────── + +func httpDo(method, url string, body interface{}, auth string) ([]byte, int, error) { + var r io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + r = bytes.NewReader(b) + } + req, err := http.NewRequest(method, url, r) + if err != nil { + return nil, 0, err + } + if auth != "" { + req.Header.Set("Authorization", auth) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + return data, resp.StatusCode, err +} + +// ── GitHub ─────────────────────────────────────────────────────────────────── + +type pullRequest struct { + URL string `json:"url"` + Title string `json:"title"` + Body string `json:"body"` + Mergeable bool `json:"mergeable"` + Base struct{ Ref string `json:"ref"` } `json:"base"` + Head struct{ Ref string `json:"ref"` } `json:"head"` + User struct{ Login string `json:"login"` } `json:"user"` +} + +type mergeResponse struct{ SHA string `json:"sha"` } + +func ghAuth() string { + if flagGithubToken != "" { + return "token " + flagGithubToken + } + return "" +} + +func ghGetPR(num int) (*pullRequest, error) { + data, code, err := httpDo("GET", fmt.Sprintf("%s/pulls/%d", githubAPIBase, num), nil, ghAuth()) + if err != nil { + return nil, err + } + if code != 200 { + return nil, fmt.Errorf("GET PR #%d: HTTP %d: %s", num, code, data) + } + var pr pullRequest + return &pr, json.Unmarshal(data, &pr) +} + +func ghMergePR(num int, title, msg string) (*mergeResponse, error) { + body := map[string]string{"commit_title": title, "commit_message": msg, "merge_method": "squash"} + data, code, err := httpDo("PUT", fmt.Sprintf("%s/pulls/%d/merge", githubAPIBase, num), body, ghAuth()) + if err != nil { + return nil, err + } + if code == 405 { + return nil, fmt.Errorf("merge PR #%d is not allowed", num) + } + if code != 200 { + return nil, fmt.Errorf("merge PR #%d: HTTP %d: %s", num, code, data) + } + var resp mergeResponse + return &resp, json.Unmarshal(data, &resp) +} + +// ── JIRA ──────────────────────────────────────────────────────────────────── + +type jiraIssue struct { + Key string `json:"key"` + Fields struct { + Summary string `json:"summary"` + Status struct{ Name string `json:"name"` } `json:"status"` + Assignee *struct{ DisplayName string `json:"displayName"` } `json:"assignee"` + } `json:"fields"` +} + +type jiraVersion struct { + ID string `json:"id"` + Name string `json:"name"` + Released bool `json:"released"` + Archived bool `json:"archived"` +} + +type jiraTransition struct { + ID string `json:"id"` + Name string `json:"name"` +} + +func jiraAuth() string { + if flagJiraToken != "" { + return "Bearer " + flagJiraToken + } + return "" +} + +func jiraGetIssue(key string) (*jiraIssue, error) { + data, code, err := httpDo("GET", fmt.Sprintf("%s/issue/%s", jiraAPIBase, key), nil, jiraAuth()) + if err != nil { + return nil, err + } + if code != 200 { + return nil, fmt.Errorf("GET %s: HTTP %d: %s", key, code, data) + } + var issue jiraIssue + return &issue, json.Unmarshal(data, &issue) +} + +func jiraUnreleasedVersions() ([]jiraVersion, error) { + data, code, err := httpDo("GET", jiraAPIBase+"/project/ZEPPELIN/versions", nil, jiraAuth()) + if err != nil { + return nil, err + } + if code != 200 { + return nil, fmt.Errorf("GET versions: HTTP %d: %s", code, data) + } + var all []jiraVersion + if err := json.Unmarshal(data, &all); err != nil { + return nil, err + } + re := regexp.MustCompile(`^\d+\.\d+\.\d+$`) + var out []jiraVersion + for _, v := range all { + if !v.Released && !v.Archived && re.MatchString(v.Name) { + out = append(out, v) + } + } + sort.Slice(out, func(i, j int) bool { return cmpVer(out[i].Name, out[j].Name) > 0 }) + return out, nil +} + +func jiraTransitions(key string) ([]jiraTransition, error) { + data, code, err := httpDo("GET", fmt.Sprintf("%s/issue/%s/transitions", jiraAPIBase, key), nil, jiraAuth()) + if err != nil { + return nil, err + } + if code != 200 { + return nil, fmt.Errorf("GET transitions %s: HTTP %d: %s", key, code, data) + } + var r struct{ Transitions []jiraTransition `json:"transitions"` } + return r.Transitions, json.Unmarshal(data, &r) +} + +func jiraResolve(key, tid string, fv []jiraVersion, comment string) error { + var fvu []map[string]interface{} + for _, v := range fv { + fvu = append(fvu, map[string]interface{}{"add": map[string]string{"id": v.ID, "name": v.Name}}) + } + body := map[string]interface{}{ + "transition": map[string]string{"id": tid}, + "update": map[string]interface{}{ + "comment": []map[string]interface{}{{"add": map[string]string{"body": comment}}}, + "fixVersions": fvu, + }, + } + _, code, err := httpDo("POST", fmt.Sprintf("%s/issue/%s/transitions", jiraAPIBase, key), body, jiraAuth()) + if err != nil { + return err + } + if code != 204 { + return fmt.Errorf("resolve %s: HTTP %d", key, code) + } + return nil +} + +func cmpVer(a, b string) int { + ap, bp := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(ap) && i < len(bp); i++ { + ai, _ := strconv.Atoi(ap[i]) + bi, _ := strconv.Atoi(bp[i]) + if ai != bi { + return ai - bi + } + } + return len(ap) - len(bp) +} + +// ── Title normalization ───────────────────────────────────────────────────── + +var jiraIDRe = regexp.MustCompile(`ZEPPELIN-\d{3,6}`) + +func standardizeTitle(text string) string { + text = strings.TrimRight(text, ".") + if strings.HasPrefix(text, `Revert "`) && strings.HasSuffix(text, `"`) { + return text + } + if m, _ := regexp.MatchString(`^\[ZEPPELIN-\d{3,6}\]`, text); m { + return text + } + re := regexp.MustCompile(`(?i)(ZEPPELIN[-\s]*\d{3,6})`) + for _, ref := range re.FindAllString(text, -1) { + text = strings.Replace(text, ref, "", 1) + n := strings.ToUpper(regexp.MustCompile(`\s+`).ReplaceAllString(ref, "-")) + text = "[" + n + "]" + text + } + text = regexp.MustCompile(`^\W+`).ReplaceAllString(text, "") + return regexp.MustCompile(`\s+`).ReplaceAllString(strings.TrimSpace(text), " ") +} + +// ── Main ──────────────────────────────────────────────────────────────────── + +func main() { + flag.Parse() + if flagPR == 0 { + fmt.Fprintln(os.Stderr, "Error: --pr is required") + flag.Usage() + os.Exit(1) + } + if flagGithubToken == "" { + flagGithubToken = os.Getenv("GITHUB_OAUTH_KEY") + } + if flagJiraToken == "" { + flagJiraToken = os.Getenv("JIRA_ACCESS_TOKEN") + } + + if err := run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} + +func run() error { + originalHead, err := gitCurrentRef() + if err != nil { + return fmt.Errorf("get current ref: %w", err) + } + + pr, err := ghGetPR(flagPR) + if err != nil { + return err + } + if !pr.Mergeable { + return fmt.Errorf("PR #%d is not mergeable", flagPR) + } + if strings.Contains(pr.Title, "[WIP]") { + fmt.Fprintf(os.Stderr, "WARNING: PR title contains [WIP]: %s\n", pr.Title) + } + + if flagTarget == "" { + flagTarget = pr.Base.Ref + } + title := standardizeTitle(pr.Title) + src := fmt.Sprintf("%s/%s", pr.User.Login, pr.Head.Ref) + + fmt.Printf("=== Pull Request #%d ===\n", flagPR) + fmt.Printf("title: %s\n", title) + fmt.Printf("source: %s\n", src) + fmt.Printf("target: %s\n", flagTarget) + fmt.Printf("url: %s\n", pr.URL) + if len(flagReleaseBranches) > 0 { + fmt.Printf("release-branches: %s\n", strings.Join(flagReleaseBranches, ", ")) + } + + if flagDryRun { + fmt.Println("\n[dry-run] Would merge PR and stop here.") + return nil + } + + // Merge + body := strings.ReplaceAll(pr.Body, "@", "") + name, _ := gitRun("config", "--get", "user.name") + email, _ := gitRun("config", "--get", "user.email") + msg := fmt.Sprintf("%s\n\nCloses #%d from %s.\n\nSigned-off-by: %s <%s>", body, flagPR, src, name, email) + + resp, err := ghMergePR(flagPR, title, msg) + if err != nil { + return err + } + hash := resp.SHA[:8] + fmt.Printf("\nPR #%d merged! (hash: %s)\n", flagPR, hash) + + gitRun("fetch", flagPushRemote, flagTarget) + + // Cherry-pick into release branches + merged := []string{flagTarget} + for _, branch := range flagReleaseBranches { + pick := fmt.Sprintf("PR_TOOL_PICK_PR_%d_%s", flagPR, strings.ToUpper(branch)) + if _, err := gitRun("fetch", flagPushRemote, branch+":"+pick); err != nil { + fmt.Fprintf(os.Stderr, "Warning: fetch %s failed: %v\n", branch, err) + continue + } + gitRun("checkout", pick) + if _, err := gitRun("cherry-pick", "-sx", resp.SHA); err != nil { + fmt.Fprintf(os.Stderr, "Warning: cherry-pick into %s failed: %v\n", branch, err) + gitRun("cherry-pick", "--abort") + gitRun("checkout", originalHead) + gitRun("branch", "-D", pick) + continue + } + if _, err := gitRun("push", flagPushRemote, pick+":"+branch); err != nil { + fmt.Fprintf(os.Stderr, "Warning: push to %s failed: %v\n", branch, err) + } else { + h, _ := gitRun("rev-parse", pick) + if len(h) > 8 { + h = h[:8] + } + fmt.Printf("Picked into %s (hash: %s)\n", branch, h) + merged = append(merged, branch) + } + gitRun("checkout", originalHead) + gitRun("branch", "-D", pick) + } + + // Resolve JIRA + if flagResolveJira { + if err := doResolveJira(title, merged); err != nil { + fmt.Fprintf(os.Stderr, "Warning: JIRA resolution failed: %v\n", err) + } + } + return nil +} + +func doResolveJira(title string, merged []string) error { + if flagJiraToken == "" { + return fmt.Errorf("JIRA_ACCESS_TOKEN is not set") + } + ids := jiraIDRe.FindAllString(title, -1) + if len(ids) == 0 { + fmt.Println("No JIRA ID found in PR title, skipping.") + return nil + } + + versions, err := jiraUnreleasedVersions() + if err != nil { + return err + } + + var fixVer []jiraVersion + if len(flagFixVersions) > 0 { + vm := make(map[string]jiraVersion) + for _, v := range versions { + vm[v.Name] = v + } + for _, fv := range flagFixVersions { + v, ok := vm[fv] + if !ok { + return fmt.Errorf("fix version %q not found", fv) + } + fixVer = append(fixVer, v) + } + } else if len(versions) > 0 { + for _, ref := range merged { + if ref == "master" { + fixVer = append(fixVer, versions[0]) + break + } + } + } + + for _, id := range ids { + issue, err := jiraGetIssue(id) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: get %s: %v\n", id, err) + continue + } + st := issue.Fields.Status.Name + if st == "Resolved" || st == "Closed" { + fmt.Printf("JIRA %s already %q, skipping.\n", id, st) + continue + } + + fmt.Printf("=== JIRA %s ===\n", issue.Key) + fmt.Printf("Summary: %s\n", issue.Fields.Summary) + fmt.Printf("Status: %s\n", st) + if issue.Fields.Assignee != nil { + fmt.Printf("Assignee: %s\n", issue.Fields.Assignee.DisplayName) + } + + ts, err := jiraTransitions(id) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: transitions %s: %v\n", id, err) + continue + } + var rid string + for _, t := range ts { + if t.Name == "Resolve Issue" { + rid = t.ID + break + } + } + if rid == "" { + fmt.Fprintf(os.Stderr, "Warning: no 'Resolve Issue' transition for %s\n", id) + continue + } + + comment := fmt.Sprintf("Issue resolved by pull request %d\n[https://github.com/apache/zeppelin/pull/%d]", flagPR, flagPR) + if err := jiraResolve(id, rid, fixVer, comment); err != nil { + fmt.Fprintf(os.Stderr, "Warning: resolve %s: %v\n", id, err) + continue + } + fmt.Printf("Resolved %s!\n", id) + } + return nil +} From a152f9f09d363093f6139086f40989905b35e6e8 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Mon, 16 Mar 2026 19:06:01 +0900 Subject: [PATCH 02/18] [ZEPPELIN-6404] Simplify merge-pr.go: precompile regexps, fix quality issues - Extract 6 regexp patterns to package-level vars (avoid recompilation) - Add shortSHA helper to unify hash truncation with length guard - Remove unused flagPRRemote flag - Use local variable for target branch instead of mutating global - Deduplicate cherry-pick cleanup with closure - Fix jiraTransitions error handling for consistency Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.go | 68 +++++++++++++++++++++++++++++-------------------- 1 file changed, 40 insertions(+), 28 deletions(-) diff --git a/dev/merge-pr.go b/dev/merge-pr.go index 7e7e3fc7956..5e35e7187f2 100644 --- a/dev/merge-pr.go +++ b/dev/merge-pr.go @@ -68,7 +68,6 @@ var ( flagReleaseBranches csvFlag flagResolveJira bool flagDryRun bool - flagPRRemote string flagPushRemote string flagGithubToken string flagJiraToken string @@ -81,7 +80,6 @@ func init() { flag.Var(&flagReleaseBranches, "release-branch", "Release branch(es) to cherry-pick into, comma-separated") flag.BoolVar(&flagResolveJira, "resolve-jira", false, "Resolve associated JIRA issue(s)") flag.BoolVar(&flagDryRun, "dry-run", false, "Show what would be done without making changes") - flag.StringVar(&flagPRRemote, "pr-remote", envOrDefault("PR_REMOTE_NAME", "apache"), "Git remote for pull requests") flag.StringVar(&flagPushRemote, "push-remote", envOrDefault("PUSH_REMOTE_NAME", "apache"), "Git remote for pushing") flag.StringVar(&flagGithubToken, "github-token", "", "GitHub OAuth token (env: GITHUB_OAUTH_KEY)") flag.StringVar(&flagJiraToken, "jira-token", "", "JIRA access token (env: JIRA_ACCESS_TOKEN)") @@ -248,10 +246,9 @@ func jiraUnreleasedVersions() ([]jiraVersion, error) { if err := json.Unmarshal(data, &all); err != nil { return nil, err } - re := regexp.MustCompile(`^\d+\.\d+\.\d+$`) var out []jiraVersion for _, v := range all { - if !v.Released && !v.Archived && re.MatchString(v.Name) { + if !v.Released && !v.Archived && reSemanticVer.MatchString(v.Name) { out = append(out, v) } } @@ -268,7 +265,10 @@ func jiraTransitions(key string) ([]jiraTransition, error) { return nil, fmt.Errorf("GET transitions %s: HTTP %d: %s", key, code, data) } var r struct{ Transitions []jiraTransition `json:"transitions"` } - return r.Transitions, json.Unmarshal(data, &r) + if err := json.Unmarshal(data, &r); err != nil { + return nil, err + } + return r.Transitions, nil } func jiraResolve(key, tid string, fv []jiraVersion, comment string) error { @@ -305,26 +305,39 @@ func cmpVer(a, b string) int { return len(ap) - len(bp) } -// ── Title normalization ───────────────────────────────────────────────────── +// ── Helpers ────────────────────────────────────────────────────────────────── -var jiraIDRe = regexp.MustCompile(`ZEPPELIN-\d{3,6}`) +var ( + jiraIDRe = regexp.MustCompile(`ZEPPELIN-\d{3,6}`) + reTitleFormatted = regexp.MustCompile(`^\[ZEPPELIN-\d{3,6}\]`) + reTitleRef = regexp.MustCompile(`(?i)(ZEPPELIN[-\s]*\d{3,6})`) + reWhitespace = regexp.MustCompile(`\s+`) + reLeadingNonWord = regexp.MustCompile(`^\W+`) + reSemanticVer = regexp.MustCompile(`^\d+\.\d+\.\d+$`) +) + +func shortSHA(s string) string { + if len(s) > 8 { + return s[:8] + } + return s +} func standardizeTitle(text string) string { text = strings.TrimRight(text, ".") if strings.HasPrefix(text, `Revert "`) && strings.HasSuffix(text, `"`) { return text } - if m, _ := regexp.MatchString(`^\[ZEPPELIN-\d{3,6}\]`, text); m { + if reTitleFormatted.MatchString(text) { return text } - re := regexp.MustCompile(`(?i)(ZEPPELIN[-\s]*\d{3,6})`) - for _, ref := range re.FindAllString(text, -1) { + for _, ref := range reTitleRef.FindAllString(text, -1) { text = strings.Replace(text, ref, "", 1) - n := strings.ToUpper(regexp.MustCompile(`\s+`).ReplaceAllString(ref, "-")) + n := strings.ToUpper(reWhitespace.ReplaceAllString(ref, "-")) text = "[" + n + "]" + text } - text = regexp.MustCompile(`^\W+`).ReplaceAllString(text, "") - return regexp.MustCompile(`\s+`).ReplaceAllString(strings.TrimSpace(text), " ") + text = reLeadingNonWord.ReplaceAllString(text, "") + return reWhitespace.ReplaceAllString(strings.TrimSpace(text), " ") } // ── Main ──────────────────────────────────────────────────────────────────── @@ -366,8 +379,9 @@ func run() error { fmt.Fprintf(os.Stderr, "WARNING: PR title contains [WIP]: %s\n", pr.Title) } - if flagTarget == "" { - flagTarget = pr.Base.Ref + target := flagTarget + if target == "" { + target = pr.Base.Ref } title := standardizeTitle(pr.Title) src := fmt.Sprintf("%s/%s", pr.User.Login, pr.Head.Ref) @@ -375,7 +389,7 @@ func run() error { fmt.Printf("=== Pull Request #%d ===\n", flagPR) fmt.Printf("title: %s\n", title) fmt.Printf("source: %s\n", src) - fmt.Printf("target: %s\n", flagTarget) + fmt.Printf("target: %s\n", target) fmt.Printf("url: %s\n", pr.URL) if len(flagReleaseBranches) > 0 { fmt.Printf("release-branches: %s\n", strings.Join(flagReleaseBranches, ", ")) @@ -396,15 +410,18 @@ func run() error { if err != nil { return err } - hash := resp.SHA[:8] - fmt.Printf("\nPR #%d merged! (hash: %s)\n", flagPR, hash) + fmt.Printf("\nPR #%d merged! (hash: %s)\n", flagPR, shortSHA(resp.SHA)) - gitRun("fetch", flagPushRemote, flagTarget) + gitRun("fetch", flagPushRemote, target) // Cherry-pick into release branches - merged := []string{flagTarget} + merged := []string{target} for _, branch := range flagReleaseBranches { pick := fmt.Sprintf("PR_TOOL_PICK_PR_%d_%s", flagPR, strings.ToUpper(branch)) + cleanup := func() { + gitRun("checkout", originalHead) + gitRun("branch", "-D", pick) + } if _, err := gitRun("fetch", flagPushRemote, branch+":"+pick); err != nil { fmt.Fprintf(os.Stderr, "Warning: fetch %s failed: %v\n", branch, err) continue @@ -413,22 +430,17 @@ func run() error { if _, err := gitRun("cherry-pick", "-sx", resp.SHA); err != nil { fmt.Fprintf(os.Stderr, "Warning: cherry-pick into %s failed: %v\n", branch, err) gitRun("cherry-pick", "--abort") - gitRun("checkout", originalHead) - gitRun("branch", "-D", pick) + cleanup() continue } if _, err := gitRun("push", flagPushRemote, pick+":"+branch); err != nil { fmt.Fprintf(os.Stderr, "Warning: push to %s failed: %v\n", branch, err) } else { h, _ := gitRun("rev-parse", pick) - if len(h) > 8 { - h = h[:8] - } - fmt.Printf("Picked into %s (hash: %s)\n", branch, h) + fmt.Printf("Picked into %s (hash: %s)\n", branch, shortSHA(h)) merged = append(merged, branch) } - gitRun("checkout", originalHead) - gitRun("branch", "-D", pick) + cleanup() } // Resolve JIRA From 25f1379fb6a7d3d3e809c2326fb253144d8537ff Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Mon, 16 Mar 2026 20:20:41 +0900 Subject: [PATCH 03/18] [ZEPPELIN-6404] Rename flags to plural: --fix-versions, --release-branches Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dev/merge-pr.go b/dev/merge-pr.go index 5e35e7187f2..37db00a3817 100644 --- a/dev/merge-pr.go +++ b/dev/merge-pr.go @@ -21,8 +21,8 @@ // Usage: // // go run dev/merge-pr.go --pr 5167 --dry-run -// go run dev/merge-pr.go --pr 5167 --resolve-jira --fix-version 0.13.0 -// go run dev/merge-pr.go --pr 5167 --resolve-jira --release-branch branch-0.12,branch-0.11 +// go run dev/merge-pr.go --pr 5167 --resolve-jira --fix-versions 0.13.0 +// go run dev/merge-pr.go --pr 5167 --resolve-jira --release-branches branch-0.12,branch-0.11 package main import ( @@ -76,8 +76,8 @@ var ( func init() { flag.IntVar(&flagPR, "pr", 0, "Pull request number (required)") flag.StringVar(&flagTarget, "target", "", "Target branch (default: PR base branch)") - flag.Var(&flagFixVersions, "fix-version", "JIRA fix version(s), comma-separated") - flag.Var(&flagReleaseBranches, "release-branch", "Release branch(es) to cherry-pick into, comma-separated") + flag.Var(&flagFixVersions, "fix-versions", "JIRA fix version(s), comma-separated") + flag.Var(&flagReleaseBranches, "release-branches", "Release branch(es) to cherry-pick into, comma-separated") flag.BoolVar(&flagResolveJira, "resolve-jira", false, "Resolve associated JIRA issue(s)") flag.BoolVar(&flagDryRun, "dry-run", false, "Show what would be done without making changes") flag.StringVar(&flagPushRemote, "push-remote", envOrDefault("PUSH_REMOTE_NAME", "apache"), "Git remote for pushing") From 67bd4903debf5d68fd400b29959f1b97ac02d1f6 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 12:58:10 +0900 Subject: [PATCH 04/18] [ZEPPELIN-6404] Match Python script behavior: component brackets and fix-version inference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - standardizeTitle now extracts [COMPONENT] brackets (e.g. [SPARK], [FLINK]) and assembles them after JIRA refs, matching Python's standardize_jira_ref - Add inferFixVersions to auto-map release branches to JIRA versions (e.g. branch-0.12 → smallest 0.12.x unreleased version) - Remove redundant X.Y.0 when X.(Y-1).0 is also present Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.go | 92 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 82 insertions(+), 10 deletions(-) diff --git a/dev/merge-pr.go b/dev/merge-pr.go index 37db00a3817..3307a86afca 100644 --- a/dev/merge-pr.go +++ b/dev/merge-pr.go @@ -309,8 +309,9 @@ func cmpVer(a, b string) int { var ( jiraIDRe = regexp.MustCompile(`ZEPPELIN-\d{3,6}`) - reTitleFormatted = regexp.MustCompile(`^\[ZEPPELIN-\d{3,6}\]`) + reTitleFormatted = regexp.MustCompile(`^\[ZEPPELIN-\d{3,6}\](\[[A-Z0-9_\s,]+\] )+\S+`) reTitleRef = regexp.MustCompile(`(?i)(ZEPPELIN[-\s]*\d{3,6})`) + reComponent = regexp.MustCompile(`(?i)(\[[\w\s,.\-]+\])`) reWhitespace = regexp.MustCompile(`\s+`) reLeadingNonWord = regexp.MustCompile(`^\W+`) reSemanticVer = regexp.MustCompile(`^\d+\.\d+\.\d+$`) @@ -331,13 +332,23 @@ func standardizeTitle(text string) string { if reTitleFormatted.MatchString(text) { return text } + // Extract JIRA ref(s) + var jiraRefs []string for _, ref := range reTitleRef.FindAllString(text, -1) { + jiraRefs = append(jiraRefs, "["+strings.ToUpper(reWhitespace.ReplaceAllString(ref, "-"))+"]") text = strings.Replace(text, ref, "", 1) - n := strings.ToUpper(reWhitespace.ReplaceAllString(ref, "-")) - text = "[" + n + "]" + text } + // Extract component(s): [SPARK], [FLINK], etc. + var components []string + for _, comp := range reComponent.FindAllString(text, -1) { + components = append(components, strings.ToUpper(comp)) + text = strings.Replace(text, comp, "", 1) + } + // Cleanup remaining leading symbols text = reLeadingNonWord.ReplaceAllString(text, "") - return reWhitespace.ReplaceAllString(strings.TrimSpace(text), " ") + // Assemble: [ZEPPELIN-XXXX][COMPONENT] remaining text + result := strings.Join(jiraRefs, "") + strings.Join(components, "") + " " + text + return reWhitespace.ReplaceAllString(strings.TrimSpace(result), " ") } // ── Main ──────────────────────────────────────────────────────────────────── @@ -481,12 +492,7 @@ func doResolveJira(title string, merged []string) error { fixVer = append(fixVer, v) } } else if len(versions) > 0 { - for _, ref := range merged { - if ref == "master" { - fixVer = append(fixVer, versions[0]) - break - } - } + fixVer = inferFixVersions(merged, versions) } for _, id := range ids { @@ -534,3 +540,69 @@ func doResolveJira(title string, merged []string) error { } return nil } + +// inferFixVersions maps merge branches to JIRA fix versions. +// For "master", picks the latest unreleased version. +// For release branches like "branch-0.12", finds the smallest matching 0.12.x version. +// Then removes redundant X.Y.0 if a previous minor X.(Y-1).0 is also selected. +func inferFixVersions(merged []string, versions []jiraVersion) []jiraVersion { + var names []string + has := make(map[string]bool) + for _, branch := range merged { + if branch == "master" { + if !has[versions[0].Name] { + names = append(names, versions[0].Name) + has[versions[0].Name] = true + } + } else { + // "branch-0.12" → prefix "0.12" + prefix := strings.TrimPrefix(branch, "branch-") + // Find all matching versions, pick the smallest (last in desc-sorted list) + var found []string + for _, v := range versions { + if strings.HasPrefix(v.Name, prefix+".") || v.Name == prefix { + found = append(found, v.Name) + } + } + if len(found) > 0 { + pick := found[len(found)-1] + if !has[pick] { + names = append(names, pick) + has[pick] = true + } + } else { + fmt.Fprintf(os.Stderr, "Warning: no version found for %s, skipping\n", branch) + } + } + } + // Remove redundant X.Y.0 when X.(Y-1).0 is also present + filtered := make([]string, 0, len(names)) + for _, v := range names { + parts := strings.Split(v, ".") + if len(parts) == 3 && parts[2] == "0" { + minor, _ := strconv.Atoi(parts[1]) + if minor > 0 { + prev := fmt.Sprintf("%s.%d.0", parts[0], minor-1) + if has[prev] { + continue + } + } + } + filtered = append(filtered, v) + } + // Map names back to jiraVersion structs + vm := make(map[string]jiraVersion) + for _, v := range versions { + vm[v.Name] = v + } + var result []jiraVersion + for _, name := range filtered { + if v, ok := vm[name]; ok { + result = append(result, v) + } + } + if len(result) > 0 { + fmt.Printf("Auto-inferred fix version(s): %s\n", strings.Join(filtered, ", ")) + } + return result +} From 548611069c442b1f4f6494c1b78754029e3cc566 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 13:11:14 +0900 Subject: [PATCH 05/18] [ZEPPELIN-6404] Combine explicit fix-versions with auto-inferred release branch versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --fix-versions and release branch inference now work together - e.g. --fix-versions 0.13.0 --release-branches branch-0.12 → 0.13.0, 0.12.1 - Master auto-inference only runs when --fix-versions is not specified Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.go | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/dev/merge-pr.go b/dev/merge-pr.go index 3307a86afca..632ecbb9f61 100644 --- a/dev/merge-pr.go +++ b/dev/merge-pr.go @@ -478,21 +478,31 @@ func doResolveJira(title string, merged []string) error { return err } + vm := make(map[string]jiraVersion) + for _, v := range versions { + vm[v.Name] = v + } + // Start with explicitly specified fix versions var fixVer []jiraVersion - if len(flagFixVersions) > 0 { - vm := make(map[string]jiraVersion) - for _, v := range versions { - vm[v.Name] = v + has := make(map[string]bool) + for _, fv := range flagFixVersions { + v, ok := vm[fv] + if !ok { + return fmt.Errorf("fix version %q not found", fv) } - for _, fv := range flagFixVersions { - v, ok := vm[fv] - if !ok { - return fmt.Errorf("fix version %q not found", fv) + fixVer = append(fixVer, v) + has[v.Name] = true + } + // Auto-infer: master → latest version only when no --fix-versions given; + // release branches → matching version always + if len(versions) > 0 { + inferMaster := len(flagFixVersions) == 0 + for _, iv := range inferFixVersions(merged, versions, inferMaster) { + if !has[iv.Name] { + fixVer = append(fixVer, iv) + has[iv.Name] = true } - fixVer = append(fixVer, v) } - } else if len(versions) > 0 { - fixVer = inferFixVersions(merged, versions) } for _, id := range ids { @@ -545,12 +555,12 @@ func doResolveJira(title string, merged []string) error { // For "master", picks the latest unreleased version. // For release branches like "branch-0.12", finds the smallest matching 0.12.x version. // Then removes redundant X.Y.0 if a previous minor X.(Y-1).0 is also selected. -func inferFixVersions(merged []string, versions []jiraVersion) []jiraVersion { +func inferFixVersions(merged []string, versions []jiraVersion, inferMaster bool) []jiraVersion { var names []string has := make(map[string]bool) for _, branch := range merged { if branch == "master" { - if !has[versions[0].Name] { + if inferMaster && !has[versions[0].Name] { names = append(names, versions[0].Name) has[versions[0].Name] = true } From 0d6ba9cc6929855255b6858ec1b7a9e8ca4b96a3 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 13:17:33 +0900 Subject: [PATCH 06/18] [ZEPPELIN-6404] Add effective command output in dry-run, fix JIRA Accept header - dry-run now shows the effective command with all inferred values resolved - Fix HTTP Accept header: use application/json instead of GitHub-specific header that caused JIRA API 406 errors Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.go | 57 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/dev/merge-pr.go b/dev/merge-pr.go index 632ecbb9f61..ed5fd8e6a7a 100644 --- a/dev/merge-pr.go +++ b/dev/merge-pr.go @@ -132,7 +132,7 @@ func httpDo(method, url string, body interface{}, auth string) ([]byte, int, err req.Header.Set("Authorization", auth) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/vnd.github.v3+json") + req.Header.Set("Accept", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { @@ -406,8 +406,39 @@ func run() error { fmt.Printf("release-branches: %s\n", strings.Join(flagReleaseBranches, ", ")) } + // Resolve fix versions for effective command display + var resolvedFixVersions []string + if flagResolveJira && flagJiraToken != "" { + ids := jiraIDRe.FindAllString(title, -1) + if len(ids) > 0 { + if versions, err := jiraUnreleasedVersions(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to fetch JIRA versions: %v\n", err) + } else if len(versions) > 0 { + vm := make(map[string]jiraVersion) + for _, v := range versions { + vm[v.Name] = v + } + has := make(map[string]bool) + for _, fv := range flagFixVersions { + if _, ok := vm[fv]; ok { + resolvedFixVersions = append(resolvedFixVersions, fv) + has[fv] = true + } + } + inferMaster := len(flagFixVersions) == 0 + for _, iv := range inferFixVersions(append([]string{target}, flagReleaseBranches...), versions, inferMaster) { + if !has[iv.Name] { + resolvedFixVersions = append(resolvedFixVersions, iv.Name) + has[iv.Name] = true + } + } + } + } + } + if flagDryRun { - fmt.Println("\n[dry-run] Would merge PR and stop here.") + fmt.Println() + printEffectiveCommand(target, resolvedFixVersions) return nil } @@ -551,6 +582,28 @@ func doResolveJira(title string, merged []string) error { return nil } +func printEffectiveCommand(target string, fixVersions []string) { + var parts []string + parts = append(parts, "go run dev/merge-pr.go") + parts = append(parts, fmt.Sprintf("--pr %d", flagPR)) + if target != "" && target != "master" { + parts = append(parts, fmt.Sprintf("--target %s", target)) + } + if len(flagReleaseBranches) > 0 { + parts = append(parts, fmt.Sprintf("--release-branches %s", strings.Join(flagReleaseBranches, ","))) + } + if flagResolveJira { + parts = append(parts, "--resolve-jira") + } + if len(fixVersions) > 0 { + parts = append(parts, fmt.Sprintf("--fix-versions %s", strings.Join(fixVersions, ","))) + } + if flagPushRemote != "apache" { + parts = append(parts, fmt.Sprintf("--push-remote %s", flagPushRemote)) + } + fmt.Printf("[dry-run] Effective command:\n %s\n", strings.Join(parts, " ")) +} + // inferFixVersions maps merge branches to JIRA fix versions. // For "master", picks the latest unreleased version. // For release branches like "branch-0.12", finds the smallest matching 0.12.x version. From 7a6e7aaaaca567c263a1c702b00eae57397459f9 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 13:22:08 +0900 Subject: [PATCH 07/18] [ZEPPELIN-6404] Add /merge-pr Claude Code skill Allows merging PRs with natural language input: /merge-pr 5167 fix-versions 0.13.0, cherry-pick into branch-0.12 Always dry-runs first and asks for confirmation before actual merge. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/merge-pr.md | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .claude/commands/merge-pr.md diff --git a/.claude/commands/merge-pr.md b/.claude/commands/merge-pr.md new file mode 100644 index 00000000000..5df3d57d8f9 --- /dev/null +++ b/.claude/commands/merge-pr.md @@ -0,0 +1,46 @@ +Merge a pull request using the Go CLI tool (`dev/merge-pr.go`). + +## Input + +User input: $ARGUMENTS + +This can be in any form — CLI flags, natural language, or a mix. Examples: +- `5167` +- `5167 fix-versions 0.13.0, also cherry-pick into branch-0.12` +- `5167 --fix-versions 0.13.0 --release-branches branch-0.12` +- `merge PR #5167 and resolve JIRA` + +Parse the user's intent and build the appropriate `go run dev/merge-pr.go` command. + +## Instructions + +1. Extract from the user input: PR number, fix-versions, release-branches, resolve-jira, and any other flags. +2. If the PR number is missing, ask for it. +3. Always add `--resolve-jira` unless the user explicitly says not to. +4. Run a dry-run first: + +``` +go run dev/merge-pr.go --pr --resolve-jira [--fix-versions ] [--release-branches ] --dry-run +``` + +5. Show the dry-run output to the user and ask for confirmation before proceeding. +6. If the user confirms, run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. +7. After merge, verify the result and report back. + +## Flags Reference + +| Flag | Description | +|------|-------------| +| `--pr` | PR number (required) | +| `--fix-versions` | JIRA fix version(s), comma-separated | +| `--release-branches` | Release branch(es) to cherry-pick into, comma-separated | +| `--resolve-jira` | Resolve associated JIRA issue(s) | +| `--dry-run` | Show what would be done without making changes | +| `--push-remote` | Git remote for pushing (default: apache) | +| `--target` | Target branch (default: PR base branch) | + +## Notes + +- Always dry-run first. Never merge without user confirmation. +- If `--fix-versions` is omitted and `--release-branches` is given, versions are auto-inferred from JIRA. +- Tokens are read from environment: `GITHUB_OAUTH_KEY`, `JIRA_ACCESS_TOKEN`. From ec4c5450aed0d446b6b2e6adc7410fb34ea26888 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 14:00:47 +0900 Subject: [PATCH 08/18] [ZEPPELIN-6404] Auto-download Go in /merge-pr skill if not installed The skill now checks for Go availability and downloads it to .go/ directory if not found, so the merge tool works without pre-installed Go. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/merge-pr.md | 19 ++++++++++++------- .gitignore | 3 +++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude/commands/merge-pr.md b/.claude/commands/merge-pr.md index 5df3d57d8f9..d03c2d15355 100644 --- a/.claude/commands/merge-pr.md +++ b/.claude/commands/merge-pr.md @@ -14,18 +14,23 @@ Parse the user's intent and build the appropriate `go run dev/merge-pr.go` comma ## Instructions -1. Extract from the user input: PR number, fix-versions, release-branches, resolve-jira, and any other flags. -2. If the PR number is missing, ask for it. -3. Always add `--resolve-jira` unless the user explicitly says not to. -4. Run a dry-run first: +1. Check if `go` is available by running `go version`. If not found: + - Detect OS and arch (`uname -s`, `uname -m`) + - Download Go to `.go/` directory: `curl -fsSL https://go.dev/dl/go1.23.6.-.tar.gz | tar -xz -C .go --strip-components=1` + - Use `.go/bin/go` instead of `go` for all subsequent commands. + - `.go/` is already in `.gitignore`. +2. Extract from the user input: PR number, fix-versions, release-branches, resolve-jira, and any other flags. +3. If the PR number is missing, ask for it. +4. Always add `--resolve-jira` unless the user explicitly says not to. +5. Run a dry-run first: ``` go run dev/merge-pr.go --pr --resolve-jira [--fix-versions ] [--release-branches ] --dry-run ``` -5. Show the dry-run output to the user and ask for confirmation before proceeding. -6. If the user confirms, run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. -7. After merge, verify the result and report back. +6. Show the dry-run output to the user and ask for confirmation before proceeding. +7. If the user confirms, run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. +8. After merge, verify the result and report back. ## Flags Reference diff --git a/.gitignore b/.gitignore index a77b3b5db6f..65eec86618f 100644 --- a/.gitignore +++ b/.gitignore @@ -149,3 +149,6 @@ tramp # dotenv files .env + +# local Go installation +.go/ From 7e3276afa49d87106d75844a48f941d66dfa528b Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 14:06:23 +0900 Subject: [PATCH 09/18] [ZEPPELIN-6404] Improve /merge-pr skill: interactive confirmation loop After dry-run, ask user if effective command looks correct and allow adjusting fix-versions, release-branches before actual merge. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/merge-pr.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.claude/commands/merge-pr.md b/.claude/commands/merge-pr.md index d03c2d15355..4d3869238ed 100644 --- a/.claude/commands/merge-pr.md +++ b/.claude/commands/merge-pr.md @@ -28,8 +28,12 @@ Parse the user's intent and build the appropriate `go run dev/merge-pr.go` comma go run dev/merge-pr.go --pr --resolve-jira [--fix-versions ] [--release-branches ] --dry-run ``` -6. Show the dry-run output to the user and ask for confirmation before proceeding. -7. If the user confirms, run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. +6. Show the dry-run output (including the effective command) to the user and ask: + - Does the effective command look correct? + - Do you want to change fix-versions, add release-branches, or adjust anything? + - If the user wants changes, re-run dry-run with updated flags and ask again. + - If the user confirms, proceed to step 7. +7. Run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. 8. After merge, verify the result and report back. ## Flags Reference From b85ec89fdc4ede5fd9230c0fbe1a3ce4cfaa947a Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 14:07:42 +0900 Subject: [PATCH 10/18] [ZEPPELIN-6404] Show --help and ask for input when PR number is missing Co-Authored-By: Claude Opus 4.6 --- .claude/commands/merge-pr.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/commands/merge-pr.md b/.claude/commands/merge-pr.md index 4d3869238ed..6bf97459e01 100644 --- a/.claude/commands/merge-pr.md +++ b/.claude/commands/merge-pr.md @@ -20,7 +20,7 @@ Parse the user's intent and build the appropriate `go run dev/merge-pr.go` comma - Use `.go/bin/go` instead of `go` for all subsequent commands. - `.go/` is already in `.gitignore`. 2. Extract from the user input: PR number, fix-versions, release-branches, resolve-jira, and any other flags. -3. If the PR number is missing, ask for it. +3. If the PR number is missing or no arguments given, run `go run dev/merge-pr.go --help` to show available flags, then ask the user for the PR number and any options they want. 4. Always add `--resolve-jira` unless the user explicitly says not to. 5. Run a dry-run first: From 55a6b3d85dd63eb64c0ed6daa73bffd41200f74c Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 14:14:46 +0900 Subject: [PATCH 11/18] [ZEPPELIN-6404] Comment on PR with merge summary after merge Posts a comment on the GitHub PR listing which branches were merged into, e.g. "Merged into master (394e2457). Cherry-picked into branch-0.12." Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/dev/merge-pr.go b/dev/merge-pr.go index ed5fd8e6a7a..8e45c414718 100644 --- a/dev/merge-pr.go +++ b/dev/merge-pr.go @@ -192,6 +192,18 @@ func ghMergePR(num int, title, msg string) (*mergeResponse, error) { return &resp, json.Unmarshal(data, &resp) } +func ghCommentPR(num int, body string) error { + payload := map[string]string{"body": body} + _, code, err := httpDo("POST", fmt.Sprintf("%s/issues/%d/comments", githubAPIBase, num), payload, ghAuth()) + if err != nil { + return err + } + if code != 201 { + return fmt.Errorf("comment PR #%d: HTTP %d", num, code) + } + return nil +} + // ── JIRA ──────────────────────────────────────────────────────────────────── type jiraIssue struct { @@ -485,6 +497,19 @@ func run() error { cleanup() } + // Comment on PR with merge summary + var commentLines []string + commentLines = append(commentLines, fmt.Sprintf("Merged into %s (%s).", target, shortSHA(resp.SHA))) + for _, branch := range merged[1:] { + commentLines = append(commentLines, fmt.Sprintf("Cherry-picked into %s.", branch)) + } + comment := strings.Join(commentLines, "\n") + if err := ghCommentPR(flagPR, comment); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to comment on PR: %v\n", err) + } else { + fmt.Println("Commented on PR with merge summary.") + } + // Resolve JIRA if flagResolveJira { if err := doResolveJira(title, merged); err != nil { From 912c9e38f7b9577f928a5f4b53a8240211292189 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 15:39:14 +0900 Subject: [PATCH 12/18] [ZEPPELIN-6404] Add Apache license header to merge-pr.md Co-Authored-By: Claude Opus 4.6 --- .claude/commands/merge-pr.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.claude/commands/merge-pr.md b/.claude/commands/merge-pr.md index 6bf97459e01..166db6edede 100644 --- a/.claude/commands/merge-pr.md +++ b/.claude/commands/merge-pr.md @@ -1,3 +1,20 @@ + + Merge a pull request using the Go CLI tool (`dev/merge-pr.go`). ## Input From 1aa88e5ed545a65045e28f033c391d9e70765436 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 15:39:24 +0900 Subject: [PATCH 13/18] [ZEPPELIN-6404] Add Java CLI tool for merging pull requests Single-file Java 11 CLI (java dev/merge-pr.java) that mirrors the Go version. No external dependencies - uses java.net.http.HttpClient and manual JSON parsing. Supports GitHub squash merge, cherry-pick into release branches, JIRA resolution, fix-version inference, PR commenting, and effective command display in dry-run mode. Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.java | 652 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 652 insertions(+) create mode 100644 dev/merge-pr.java diff --git a/dev/merge-pr.java b/dev/merge-pr.java new file mode 100644 index 00000000000..33e6b667f16 --- /dev/null +++ b/dev/merge-pr.java @@ -0,0 +1,652 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// merge-pr.java merges Apache Zeppelin pull requests via the GitHub API, +// optionally cherry-picks into release branches, and resolves JIRA issues. +// +// Usage: +// java dev/merge-pr.java --pr 5167 --dry-run +// java dev/merge-pr.java --pr 5167 --resolve-jira --fix-versions 0.13.0 +// java dev/merge-pr.java --pr 5167 --resolve-jira --release-branches branch-0.12,branch-0.11 + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Single-file Java CLI for merging Apache Zeppelin pull requests. + * Requires Java 11+. No external dependencies. + * Run with: java dev/merge-pr.java --pr NUMBER [flags] + */ +public class MergePr { + + static final String GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin"; + static final String JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2"; + static final HttpClient HTTP = HttpClient.newHttpClient(); + + static final Pattern JIRA_ID_RE = Pattern.compile("ZEPPELIN-\\d{3,6}"); + static final Pattern TITLE_FORMATTED_RE = Pattern.compile("^\\[ZEPPELIN-\\d{3,6}](\\[[A-Z0-9_\\s,]+] )+\\S+"); + static final Pattern TITLE_REF_RE = Pattern.compile("(?i)(ZEPPELIN[-\\s]*\\d{3,6})"); + static final Pattern COMPONENT_RE = Pattern.compile("(?i)(\\[[\\w\\s,.\\-]+])"); + static final Pattern WHITESPACE_RE = Pattern.compile("\\s+"); + static final Pattern LEADING_NON_WORD_RE = Pattern.compile("^\\W+"); + static final Pattern SEMANTIC_VER_RE = Pattern.compile("^\\d+\\.\\d+\\.\\d+$"); + + // ── Flags ────────────────────────────────────────────────────────────── + + static int flagPR; + static String flagTarget = ""; + static List flagFixVersions = new ArrayList<>(); + static List flagReleaseBranches = new ArrayList<>(); + static boolean flagResolveJira; + static boolean flagDryRun; + static String flagPushRemote; + static String flagGithubToken; + static String flagJiraToken; + + static void parseArgs(String[] args) { + flagPushRemote = envOrDefault("PUSH_REMOTE_NAME", "apache"); + flagGithubToken = envOrDefault("GITHUB_OAUTH_KEY", ""); + flagJiraToken = envOrDefault("JIRA_ACCESS_TOKEN", ""); + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--pr": flagPR = Integer.parseInt(args[++i]); break; + case "--target": flagTarget = args[++i]; break; + case "--fix-versions": flagFixVersions = parseCsv(args[++i]); break; + case "--release-branches": flagReleaseBranches = parseCsv(args[++i]); break; + case "--resolve-jira": flagResolveJira = true; break; + case "--dry-run": flagDryRun = true; break; + case "--push-remote": flagPushRemote = args[++i]; break; + case "--github-token": flagGithubToken = args[++i]; break; + case "--jira-token": flagJiraToken = args[++i]; break; + case "--help": case "-h": printUsage(); System.exit(0); break; + default: + System.err.println("Unknown flag: " + args[i]); + printUsage(); + System.exit(1); + } + } + } + + static void printUsage() { + System.err.println("Usage: java dev/merge-pr.java [flags]"); + System.err.println(" --pr int Pull request number (required)"); + System.err.println(" --target string Target branch (default: PR base branch)"); + System.err.println(" --fix-versions value JIRA fix version(s), comma-separated"); + System.err.println(" --release-branches value Release branch(es) to cherry-pick into, comma-separated"); + System.err.println(" --resolve-jira Resolve associated JIRA issue(s)"); + System.err.println(" --dry-run Show what would be done without making changes"); + System.err.println(" --push-remote string Git remote for pushing (default: apache)"); + System.err.println(" --github-token string GitHub OAuth token (env: GITHUB_OAUTH_KEY)"); + System.err.println(" --jira-token string JIRA access token (env: JIRA_ACCESS_TOKEN)"); + } + + static List parseCsv(String value) { + List result = new ArrayList<>(); + for (String s : value.split(",")) { + String trimmed = s.trim(); + if (!trimmed.isEmpty()) result.add(trimmed); + } + return result; + } + + static String envOrDefault(String key, String def) { + String v = System.getenv(key); + return (v != null && !v.isEmpty()) ? v : def; + } + + // ── Git ──────────────────────────────────────────────────────────────── + + static String gitRun(String... args) throws Exception { + String[] cmd = new String[args.length + 1]; + cmd[0] = "git"; + System.arraycopy(args, 0, cmd, 1, args.length); + Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start(); + String output; + try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { + output = r.lines().collect(Collectors.joining("\n")).trim(); + } + int exit = p.waitFor(); + if (exit != 0) { + throw new RuntimeException("git " + String.join(" ", args) + " failed:\n" + output); + } + return output; + } + + static String gitCurrentRef() throws Exception { + String ref = gitRun("rev-parse", "--abbrev-ref", "HEAD"); + return "HEAD".equals(ref) ? gitRun("rev-parse", "HEAD") : ref; + } + + // ── HTTP ─────────────────────────────────────────────────────────────── + + static HttpResponse httpDo(String method, String url, String body, String auth) + throws IOException, InterruptedException { + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("Accept", "application/json"); + if (auth != null && !auth.isEmpty()) { + builder.header("Authorization", auth); + } + if (body != null) { + builder.method(method, HttpRequest.BodyPublishers.ofString(body)); + } else { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } + return HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + } + + // ── Simple JSON parser (no external deps) ────────────────────────────── + + // Minimal JSON helpers — we only need to read specific fields from GitHub/JIRA responses. + // For writing, we build JSON strings directly. + + static String jsonStr(String json, String key) { + String pattern = "\"" + key + "\"\\s*:\\s*\"([^\"]*?)\""; + Matcher m = Pattern.compile(pattern).matcher(json); + return m.find() ? m.group(1) : ""; + } + + static boolean jsonBool(String json, String key) { + String pattern = "\"" + key + "\"\\s*:\\s*(true|false)"; + Matcher m = Pattern.compile(pattern).matcher(json); + return m.find() && "true".equals(m.group(1)); + } + + static String jsonObj(String json, String key) { + String pattern = "\"" + key + "\"\\s*:\\s*\\{"; + Matcher m = Pattern.compile(pattern).matcher(json); + if (!m.find()) return "{}"; + int start = m.end() - 1; + int depth = 0; + for (int i = start; i < json.length(); i++) { + if (json.charAt(i) == '{') depth++; + else if (json.charAt(i) == '}') { depth--; if (depth == 0) return json.substring(start, i + 1); } + } + return "{}"; + } + + static List jsonArray(String json, String key) { + String pattern = "\"" + key + "\"\\s*:\\s*\\["; + Matcher m = Pattern.compile(pattern).matcher(json); + if (!m.find()) return List.of(); + int start = m.end() - 1; + int depth = 0; + int arrEnd = json.length(); + for (int i = start; i < json.length(); i++) { + if (json.charAt(i) == '[') depth++; + else if (json.charAt(i) == ']') { depth--; if (depth == 0) { arrEnd = i + 1; break; } } + } + String arr = json.substring(start, arrEnd); + // Split top-level objects + List items = new ArrayList<>(); + depth = 0; + int itemStart = -1; + for (int i = 1; i < arr.length() - 1; i++) { + char c = arr.charAt(i); + if (c == '{' && depth == 0) itemStart = i; + if (c == '{') depth++; + if (c == '}') depth--; + if (c == '}' && depth == 0 && itemStart >= 0) { + items.add(arr.substring(itemStart, i + 1)); + itemStart = -1; + } + } + return items; + } + + // ── GitHub ────────────────────────────────────────────────────────────── + + static String ghAuth() { + return flagGithubToken.isEmpty() ? "" : "token " + flagGithubToken; + } + + static String ghGetPR(int num) throws Exception { + HttpResponse r = httpDo("GET", GITHUB_API_BASE + "/pulls/" + num, null, ghAuth()); + if (r.statusCode() != 200) throw new RuntimeException("GET PR #" + num + ": HTTP " + r.statusCode()); + return r.body(); + } + + static String ghMergePR(int num, String title, String msg) throws Exception { + String body = String.format("{\"commit_title\":%s,\"commit_message\":%s,\"merge_method\":\"squash\"}", + jsonEscape(title), jsonEscape(msg)); + HttpResponse r = httpDo("PUT", GITHUB_API_BASE + "/pulls/" + num + "/merge", body, ghAuth()); + if (r.statusCode() == 405) throw new RuntimeException("Merge PR #" + num + " is not allowed"); + if (r.statusCode() != 200) throw new RuntimeException("Merge PR #" + num + ": HTTP " + r.statusCode()); + return r.body(); + } + + static void ghCommentPR(int num, String comment) throws Exception { + String body = String.format("{\"body\":%s}", jsonEscape(comment)); + HttpResponse r = httpDo("POST", GITHUB_API_BASE + "/issues/" + num + "/comments", body, ghAuth()); + if (r.statusCode() != 201) { + System.err.println("Warning: comment PR #" + num + ": HTTP " + r.statusCode()); + } + } + + static String jsonEscape(String s) { + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""; + } + + // ── JIRA ─────────────────────────────────────────────────────────────── + + static String jiraAuth() { + return flagJiraToken.isEmpty() ? "" : "Bearer " + flagJiraToken; + } + + static String jiraGetIssue(String key) throws Exception { + HttpResponse r = httpDo("GET", JIRA_API_BASE + "/issue/" + key, null, jiraAuth()); + if (r.statusCode() != 200) throw new RuntimeException("GET " + key + ": HTTP " + r.statusCode()); + return r.body(); + } + + static List> jiraUnreleasedVersions() throws Exception { + HttpResponse r = httpDo("GET", JIRA_API_BASE + "/project/ZEPPELIN/versions", null, jiraAuth()); + if (r.statusCode() != 200) throw new RuntimeException("GET versions: HTTP " + r.statusCode()); + List all = jsonArray(r.body(), "dummy"); // won't work — top level is array + // Parse top-level array manually + String body = r.body().trim(); + all = new ArrayList<>(); + int depth = 0; int start = -1; + for (int i = 1; i < body.length() - 1; i++) { + if (body.charAt(i) == '{' && depth == 0) start = i; + if (body.charAt(i) == '{') depth++; + if (body.charAt(i) == '}') depth--; + if (body.charAt(i) == '}' && depth == 0 && start >= 0) { + all.add(body.substring(start, i + 1)); + start = -1; + } + } + List> versions = new ArrayList<>(); + for (String v : all) { + String name = jsonStr(v, "name"); + boolean released = jsonBool(v, "released"); + boolean archived = jsonBool(v, "archived"); + if (!released && !archived && SEMANTIC_VER_RE.matcher(name).matches()) { + Map ver = new HashMap<>(); + ver.put("id", jsonStr(v, "id")); + ver.put("name", name); + versions.add(ver); + } + } + versions.sort((a, b) -> cmpVer(b.get("name"), a.get("name"))); + return versions; + } + + static List> jiraTransitions(String key) throws Exception { + HttpResponse r = httpDo("GET", JIRA_API_BASE + "/issue/" + key + "/transitions", null, jiraAuth()); + if (r.statusCode() != 200) throw new RuntimeException("GET transitions " + key + ": HTTP " + r.statusCode()); + List> result = new ArrayList<>(); + for (String t : jsonArray(r.body(), "transitions")) { + Map tr = new HashMap<>(); + tr.put("id", jsonStr(t, "id")); + tr.put("name", jsonStr(t, "name")); + result.add(tr); + } + return result; + } + + static void jiraResolve(String key, String transitionId, List> fixVersions, String comment) + throws Exception { + StringBuilder fvJson = new StringBuilder("["); + for (int i = 0; i < fixVersions.size(); i++) { + if (i > 0) fvJson.append(","); + fvJson.append(String.format("{\"add\":{\"id\":\"%s\",\"name\":\"%s\"}}", + fixVersions.get(i).get("id"), fixVersions.get(i).get("name"))); + } + fvJson.append("]"); + String body = String.format( + "{\"transition\":{\"id\":\"%s\"},\"update\":{\"comment\":[{\"add\":{\"body\":%s}}],\"fixVersions\":%s}}", + transitionId, jsonEscape(comment), fvJson); + HttpResponse r = httpDo("POST", JIRA_API_BASE + "/issue/" + key + "/transitions", body, jiraAuth()); + if (r.statusCode() != 204) throw new RuntimeException("Resolve " + key + ": HTTP " + r.statusCode()); + } + + static int cmpVer(String a, String b) { + String[] ap = a.split("\\."), bp = b.split("\\."); + for (int i = 0; i < Math.min(ap.length, bp.length); i++) { + int d = Integer.parseInt(ap[i]) - Integer.parseInt(bp[i]); + if (d != 0) return d; + } + return ap.length - bp.length; + } + + // ── Title normalization ──────────────────────────────────────────────── + + static String standardizeTitle(String text) { + text = text.replaceAll("\\.+$", ""); + if (text.startsWith("Revert \"") && text.endsWith("\"")) return text; + if (TITLE_FORMATTED_RE.matcher(text).find()) return text; + + List jiraRefs = new ArrayList<>(); + Matcher refMatcher = TITLE_REF_RE.matcher(text); + while (refMatcher.find()) { + String ref = refMatcher.group(1); + jiraRefs.add("[" + WHITESPACE_RE.matcher(ref.toUpperCase()).replaceAll("-") + "]"); + text = text.replace(ref, ""); + } + List components = new ArrayList<>(); + Matcher compMatcher = COMPONENT_RE.matcher(text); + while (compMatcher.find()) { + String comp = compMatcher.group(1); + components.add(comp.toUpperCase()); + text = text.replace(comp, ""); + } + text = LEADING_NON_WORD_RE.matcher(text).replaceAll(""); + String result = String.join("", jiraRefs) + String.join("", components) + " " + text; + return WHITESPACE_RE.matcher(result.trim()).replaceAll(" "); + } + + static String shortSHA(String sha) { + return sha.length() > 8 ? sha.substring(0, 8) : sha; + } + + // ── Fix version inference ────────────────────────────────────────────── + + static List> inferFixVersions(List merged, + List> versions, boolean inferMaster) { + List names = new ArrayList<>(); + LinkedHashSet has = new LinkedHashSet<>(); + for (String branch : merged) { + if ("master".equals(branch)) { + if (inferMaster && !has.contains(versions.get(0).get("name"))) { + String name = versions.get(0).get("name"); + names.add(name); + has.add(name); + } + } else { + String prefix = branch.startsWith("branch-") ? branch.substring(7) : branch; + List found = new ArrayList<>(); + for (Map v : versions) { + String vn = v.get("name"); + if (vn.startsWith(prefix + ".") || vn.equals(prefix)) found.add(vn); + } + if (!found.isEmpty()) { + String pick = found.get(found.size() - 1); + if (!has.contains(pick)) { names.add(pick); has.add(pick); } + } else { + System.err.println("Warning: no version found for " + branch + ", skipping"); + } + } + } + // Remove redundant X.Y.0 when X.(Y-1).0 is also present + List filtered = new ArrayList<>(); + for (String v : names) { + String[] parts = v.split("\\."); + if (parts.length == 3 && "0".equals(parts[2])) { + int minor = Integer.parseInt(parts[1]); + if (minor > 0 && has.contains(parts[0] + "." + (minor - 1) + ".0")) continue; + } + filtered.add(v); + } + Map> vm = new HashMap<>(); + for (Map v : versions) vm.put(v.get("name"), v); + List> result = new ArrayList<>(); + for (String name : filtered) { + if (vm.containsKey(name)) result.add(vm.get(name)); + } + if (!result.isEmpty()) { + System.out.println("Auto-inferred fix version(s): " + String.join(", ", filtered)); + } + return result; + } + + // ── Effective command ────────────────────────────────────────────────── + + static void printEffectiveCommand(String target, List fixVersions) { + List parts = new ArrayList<>(); + parts.add("java dev/merge-pr.java"); + parts.add("--pr " + flagPR); + if (!target.isEmpty() && !"master".equals(target)) parts.add("--target " + target); + if (!flagReleaseBranches.isEmpty()) parts.add("--release-branches " + String.join(",", flagReleaseBranches)); + if (flagResolveJira) parts.add("--resolve-jira"); + if (!fixVersions.isEmpty()) parts.add("--fix-versions " + String.join(",", fixVersions)); + if (!"apache".equals(flagPushRemote)) parts.add("--push-remote " + flagPushRemote); + System.out.println("[dry-run] Effective command:\n " + String.join(" ", parts)); + } + + // ── Main ─────────────────────────────────────────────────────────────── + + public static void main(String[] args) throws Exception { + parseArgs(args); + if (flagPR == 0) { + System.err.println("Error: --pr is required"); + printUsage(); + System.exit(1); + } + run(); + } + + static void run() throws Exception { + String originalHead = gitCurrentRef(); + + String prJson = ghGetPR(flagPR); + if (!jsonBool(prJson, "mergeable")) { + throw new RuntimeException("PR #" + flagPR + " is not mergeable"); + } + String prTitle = jsonStr(prJson, "title"); + if (prTitle.contains("[WIP]")) { + System.err.println("WARNING: PR title contains [WIP]: " + prTitle); + } + + String target = flagTarget.isEmpty() ? jsonStr(jsonObj(prJson, "base"), "ref") : flagTarget; + String title = standardizeTitle(prTitle); + String headRef = jsonStr(jsonObj(prJson, "head"), "ref"); + String userLogin = jsonStr(jsonObj(prJson, "user"), "login"); + String src = userLogin + "/" + headRef; + String prBody = jsonStr(prJson, "body"); + + System.out.println("=== Pull Request #" + flagPR + " ==="); + System.out.println("title: " + title); + System.out.println("source: " + src); + System.out.println("target: " + target); + System.out.println("url: " + jsonStr(prJson, "url")); + if (!flagReleaseBranches.isEmpty()) { + System.out.println("release-branches: " + String.join(", ", flagReleaseBranches)); + } + + // Resolve fix versions for effective command display + List resolvedFixVersions = new ArrayList<>(); + if (flagResolveJira && !flagJiraToken.isEmpty()) { + List ids = new ArrayList<>(); + Matcher idm = JIRA_ID_RE.matcher(title); + while (idm.find()) ids.add(idm.group()); + if (!ids.isEmpty()) { + try { + List> versions = jiraUnreleasedVersions(); + if (!versions.isEmpty()) { + Map> vm = new HashMap<>(); + for (Map v : versions) vm.put(v.get("name"), v); + LinkedHashSet has = new LinkedHashSet<>(); + for (String fv : flagFixVersions) { + if (vm.containsKey(fv)) { resolvedFixVersions.add(fv); has.add(fv); } + } + boolean inferMaster = flagFixVersions.isEmpty(); + List branches = new ArrayList<>(); + branches.add(target); + branches.addAll(flagReleaseBranches); + for (Map iv : inferFixVersions(branches, versions, inferMaster)) { + if (!has.contains(iv.get("name"))) { + resolvedFixVersions.add(iv.get("name")); + has.add(iv.get("name")); + } + } + } + } catch (Exception e) { + System.err.println("Warning: failed to fetch JIRA versions: " + e.getMessage()); + } + } + } + + if (flagDryRun) { + System.out.println(); + printEffectiveCommand(target, resolvedFixVersions); + return; + } + + // Merge + String body = prBody.replace("@", ""); + String name = "", email = ""; + try { name = gitRun("config", "--get", "user.name"); } catch (Exception ignored) {} + try { email = gitRun("config", "--get", "user.email"); } catch (Exception ignored) {} + String msg = body + "\n\nCloses #" + flagPR + " from " + src + ".\n\nSigned-off-by: " + name + " <" + email + ">"; + + String mergeJson = ghMergePR(flagPR, title, msg); + String sha = jsonStr(mergeJson, "sha"); + System.out.println("\nPR #" + flagPR + " merged! (hash: " + shortSHA(sha) + ")"); + + try { gitRun("fetch", flagPushRemote, target); } catch (Exception ignored) {} + + // Cherry-pick into release branches + List merged = new ArrayList<>(); + merged.add(target); + for (String branch : flagReleaseBranches) { + String pick = "PR_TOOL_PICK_PR_" + flagPR + "_" + branch.toUpperCase(); + try { + gitRun("fetch", flagPushRemote, branch + ":" + pick); + } catch (Exception e) { + System.err.println("Warning: fetch " + branch + " failed: " + e.getMessage()); + continue; + } + gitRun("checkout", pick); + try { + gitRun("cherry-pick", "-sx", sha); + } catch (Exception e) { + System.err.println("Warning: cherry-pick into " + branch + " failed: " + e.getMessage()); + try { gitRun("cherry-pick", "--abort"); } catch (Exception ignored) {} + gitRun("checkout", originalHead); + gitRun("branch", "-D", pick); + continue; + } + try { + gitRun("push", flagPushRemote, pick + ":" + branch); + String h = gitRun("rev-parse", pick); + System.out.println("Picked into " + branch + " (hash: " + shortSHA(h) + ")"); + merged.add(branch); + } catch (Exception e) { + System.err.println("Warning: push to " + branch + " failed: " + e.getMessage()); + } + gitRun("checkout", originalHead); + gitRun("branch", "-D", pick); + } + + // Comment on PR + StringBuilder comment = new StringBuilder(); + comment.append("Merged into ").append(target).append(" (").append(shortSHA(sha)).append(")."); + for (int i = 1; i < merged.size(); i++) { + comment.append("\nCherry-picked into ").append(merged.get(i)).append("."); + } + try { + ghCommentPR(flagPR, comment.toString()); + System.out.println("Commented on PR with merge summary."); + } catch (Exception e) { + System.err.println("Warning: failed to comment on PR: " + e.getMessage()); + } + + // Resolve JIRA + if (flagResolveJira) { + try { + doResolveJira(title, merged); + } catch (Exception e) { + System.err.println("Warning: JIRA resolution failed: " + e.getMessage()); + } + } + } + + static void doResolveJira(String title, List merged) throws Exception { + if (flagJiraToken.isEmpty()) throw new RuntimeException("JIRA_ACCESS_TOKEN is not set"); + + List ids = new ArrayList<>(); + Matcher m = JIRA_ID_RE.matcher(title); + while (m.find()) ids.add(m.group()); + if (ids.isEmpty()) { System.out.println("No JIRA ID found in PR title, skipping."); return; } + + List> versions = jiraUnreleasedVersions(); + + Map> vm = new HashMap<>(); + for (Map v : versions) vm.put(v.get("name"), v); + + List> fixVer = new ArrayList<>(); + LinkedHashSet has = new LinkedHashSet<>(); + for (String fv : flagFixVersions) { + if (!vm.containsKey(fv)) throw new RuntimeException("fix version \"" + fv + "\" not found"); + fixVer.add(vm.get(fv)); + has.add(fv); + } + if (!versions.isEmpty()) { + boolean inferMaster = flagFixVersions.isEmpty(); + for (Map iv : inferFixVersions(merged, versions, inferMaster)) { + if (!has.contains(iv.get("name"))) { + fixVer.add(iv); + has.add(iv.get("name")); + } + } + } + + for (String id : ids) { + String issueJson; + try { issueJson = jiraGetIssue(id); } catch (Exception e) { + System.err.println("Warning: get " + id + ": " + e.getMessage()); continue; + } + String status = jsonStr(jsonObj(jsonObj(issueJson, "fields"), "status"), "name"); + if ("Resolved".equals(status) || "Closed".equals(status)) { + System.out.println("JIRA " + id + " already \"" + status + "\", skipping."); + continue; + } + + System.out.println("=== JIRA " + id + " ==="); + System.out.println("Summary: " + jsonStr(jsonObj(issueJson, "fields"), "summary")); + System.out.println("Status: " + status); + + List> transitions = jiraTransitions(id); + String resolveId = null; + for (Map t : transitions) { + if ("Resolve Issue".equals(t.get("name"))) { resolveId = t.get("id"); break; } + } + if (resolveId == null) { + System.err.println("Warning: no 'Resolve Issue' transition for " + id); + continue; + } + + String jiraComment = "Issue resolved by pull request " + flagPR + + "\n[https://github.com/apache/zeppelin/pull/" + flagPR + "]"; + try { + jiraResolve(id, resolveId, fixVer, jiraComment); + System.out.println("Resolved " + id + "!"); + } catch (Exception e) { + System.err.println("Warning: resolve " + id + ": " + e.getMessage()); + } + } + } +} From 4d62d3a7dc83265a7546f05d7acb960ad13d6a86 Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 15:43:47 +0900 Subject: [PATCH 14/18] [ZEPPELIN-6404] Remove Go CLI and update skill to use Java - Delete dev/merge-pr.go - Remove .go/ from .gitignore - Update merge-pr.md skill to reference java dev/merge-pr.java Co-Authored-By: Claude Opus 4.6 --- .claude/commands/merge-pr.md | 27 +- .gitignore | 3 - dev/merge-pr.go | 696 ----------------------------------- 3 files changed, 11 insertions(+), 715 deletions(-) delete mode 100644 dev/merge-pr.go diff --git a/.claude/commands/merge-pr.md b/.claude/commands/merge-pr.md index 166db6edede..880a15e7560 100644 --- a/.claude/commands/merge-pr.md +++ b/.claude/commands/merge-pr.md @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. --> -Merge a pull request using the Go CLI tool (`dev/merge-pr.go`). +Merge a pull request using the Java CLI tool (`dev/merge-pr.java`). ## Input @@ -27,31 +27,26 @@ This can be in any form — CLI flags, natural language, or a mix. Examples: - `5167 --fix-versions 0.13.0 --release-branches branch-0.12` - `merge PR #5167 and resolve JIRA` -Parse the user's intent and build the appropriate `go run dev/merge-pr.go` command. +Parse the user's intent and build the appropriate `java dev/merge-pr.java` command. ## Instructions -1. Check if `go` is available by running `go version`. If not found: - - Detect OS and arch (`uname -s`, `uname -m`) - - Download Go to `.go/` directory: `curl -fsSL https://go.dev/dl/go1.23.6.-.tar.gz | tar -xz -C .go --strip-components=1` - - Use `.go/bin/go` instead of `go` for all subsequent commands. - - `.go/` is already in `.gitignore`. -2. Extract from the user input: PR number, fix-versions, release-branches, resolve-jira, and any other flags. -3. If the PR number is missing or no arguments given, run `go run dev/merge-pr.go --help` to show available flags, then ask the user for the PR number and any options they want. -4. Always add `--resolve-jira` unless the user explicitly says not to. -5. Run a dry-run first: +1. Extract from the user input: PR number, fix-versions, release-branches, resolve-jira, and any other flags. +2. If the PR number is missing or no arguments given, run `java dev/merge-pr.java --help` to show available flags, then ask the user for the PR number and any options they want. +3. Always add `--resolve-jira` unless the user explicitly says not to. +4. Run a dry-run first: ``` -go run dev/merge-pr.go --pr --resolve-jira [--fix-versions ] [--release-branches ] --dry-run +java dev/merge-pr.java --pr --resolve-jira [--fix-versions ] [--release-branches ] --dry-run ``` -6. Show the dry-run output (including the effective command) to the user and ask: +5. Show the dry-run output (including the effective command) to the user and ask: - Does the effective command look correct? - Do you want to change fix-versions, add release-branches, or adjust anything? - If the user wants changes, re-run dry-run with updated flags and ask again. - - If the user confirms, proceed to step 7. -7. Run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. -8. After merge, verify the result and report back. + - If the user confirms, proceed to step 6. +6. Run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. +7. After merge, verify the result and report back. ## Flags Reference diff --git a/.gitignore b/.gitignore index 65eec86618f..a77b3b5db6f 100644 --- a/.gitignore +++ b/.gitignore @@ -149,6 +149,3 @@ tramp # dotenv files .env - -# local Go installation -.go/ diff --git a/dev/merge-pr.go b/dev/merge-pr.go deleted file mode 100644 index 8e45c414718..00000000000 --- a/dev/merge-pr.go +++ /dev/null @@ -1,696 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// merge-pr.go merges Apache Zeppelin pull requests via the GitHub API, -// optionally cherry-picks into release branches, and resolves JIRA issues. -// -// Usage: -// -// go run dev/merge-pr.go --pr 5167 --dry-run -// go run dev/merge-pr.go --pr 5167 --resolve-jira --fix-versions 0.13.0 -// go run dev/merge-pr.go --pr 5167 --resolve-jira --release-branches branch-0.12,branch-0.11 -package main - -import ( - "bytes" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "regexp" - "sort" - "strconv" - "strings" -) - -const ( - githubAPIBase = "https://api.github.com/repos/apache/zeppelin" - jiraAPIBase = "https://issues.apache.org/jira/rest/api/2" -) - -// ── CSV flag type ─────────────────────────────────────────────────────────── - -type csvFlag []string - -func (f *csvFlag) String() string { return strings.Join(*f, ",") } -func (f *csvFlag) Set(v string) error { - for _, s := range strings.Split(v, ",") { - if t := strings.TrimSpace(s); t != "" { - *f = append(*f, t) - } - } - return nil -} - -// ── Flags ─────────────────────────────────────────────────────────────────── - -var ( - flagPR int - flagTarget string - flagFixVersions csvFlag - flagReleaseBranches csvFlag - flagResolveJira bool - flagDryRun bool - flagPushRemote string - flagGithubToken string - flagJiraToken string -) - -func init() { - flag.IntVar(&flagPR, "pr", 0, "Pull request number (required)") - flag.StringVar(&flagTarget, "target", "", "Target branch (default: PR base branch)") - flag.Var(&flagFixVersions, "fix-versions", "JIRA fix version(s), comma-separated") - flag.Var(&flagReleaseBranches, "release-branches", "Release branch(es) to cherry-pick into, comma-separated") - flag.BoolVar(&flagResolveJira, "resolve-jira", false, "Resolve associated JIRA issue(s)") - flag.BoolVar(&flagDryRun, "dry-run", false, "Show what would be done without making changes") - flag.StringVar(&flagPushRemote, "push-remote", envOrDefault("PUSH_REMOTE_NAME", "apache"), "Git remote for pushing") - flag.StringVar(&flagGithubToken, "github-token", "", "GitHub OAuth token (env: GITHUB_OAUTH_KEY)") - flag.StringVar(&flagJiraToken, "jira-token", "", "JIRA access token (env: JIRA_ACCESS_TOKEN)") -} - -func envOrDefault(key, def string) string { - if v := os.Getenv(key); v != "" { - return v - } - return def -} - -// ── Git ───────────────────────────────────────────────────────────────────── - -func gitRun(args ...string) (string, error) { - out, err := exec.Command("git", args...).CombinedOutput() - if err != nil { - return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, out) - } - return strings.TrimSpace(string(out)), nil -} - -func gitCurrentRef() (string, error) { - ref, err := gitRun("rev-parse", "--abbrev-ref", "HEAD") - if err != nil { - return "", err - } - if ref == "HEAD" { - return gitRun("rev-parse", "HEAD") - } - return ref, nil -} - -// ── HTTP ──────────────────────────────────────────────────────────────────── - -func httpDo(method, url string, body interface{}, auth string) ([]byte, int, error) { - var r io.Reader - if body != nil { - b, err := json.Marshal(body) - if err != nil { - return nil, 0, err - } - r = bytes.NewReader(b) - } - req, err := http.NewRequest(method, url, r) - if err != nil { - return nil, 0, err - } - if auth != "" { - req.Header.Set("Authorization", auth) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, 0, err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - return data, resp.StatusCode, err -} - -// ── GitHub ─────────────────────────────────────────────────────────────────── - -type pullRequest struct { - URL string `json:"url"` - Title string `json:"title"` - Body string `json:"body"` - Mergeable bool `json:"mergeable"` - Base struct{ Ref string `json:"ref"` } `json:"base"` - Head struct{ Ref string `json:"ref"` } `json:"head"` - User struct{ Login string `json:"login"` } `json:"user"` -} - -type mergeResponse struct{ SHA string `json:"sha"` } - -func ghAuth() string { - if flagGithubToken != "" { - return "token " + flagGithubToken - } - return "" -} - -func ghGetPR(num int) (*pullRequest, error) { - data, code, err := httpDo("GET", fmt.Sprintf("%s/pulls/%d", githubAPIBase, num), nil, ghAuth()) - if err != nil { - return nil, err - } - if code != 200 { - return nil, fmt.Errorf("GET PR #%d: HTTP %d: %s", num, code, data) - } - var pr pullRequest - return &pr, json.Unmarshal(data, &pr) -} - -func ghMergePR(num int, title, msg string) (*mergeResponse, error) { - body := map[string]string{"commit_title": title, "commit_message": msg, "merge_method": "squash"} - data, code, err := httpDo("PUT", fmt.Sprintf("%s/pulls/%d/merge", githubAPIBase, num), body, ghAuth()) - if err != nil { - return nil, err - } - if code == 405 { - return nil, fmt.Errorf("merge PR #%d is not allowed", num) - } - if code != 200 { - return nil, fmt.Errorf("merge PR #%d: HTTP %d: %s", num, code, data) - } - var resp mergeResponse - return &resp, json.Unmarshal(data, &resp) -} - -func ghCommentPR(num int, body string) error { - payload := map[string]string{"body": body} - _, code, err := httpDo("POST", fmt.Sprintf("%s/issues/%d/comments", githubAPIBase, num), payload, ghAuth()) - if err != nil { - return err - } - if code != 201 { - return fmt.Errorf("comment PR #%d: HTTP %d", num, code) - } - return nil -} - -// ── JIRA ──────────────────────────────────────────────────────────────────── - -type jiraIssue struct { - Key string `json:"key"` - Fields struct { - Summary string `json:"summary"` - Status struct{ Name string `json:"name"` } `json:"status"` - Assignee *struct{ DisplayName string `json:"displayName"` } `json:"assignee"` - } `json:"fields"` -} - -type jiraVersion struct { - ID string `json:"id"` - Name string `json:"name"` - Released bool `json:"released"` - Archived bool `json:"archived"` -} - -type jiraTransition struct { - ID string `json:"id"` - Name string `json:"name"` -} - -func jiraAuth() string { - if flagJiraToken != "" { - return "Bearer " + flagJiraToken - } - return "" -} - -func jiraGetIssue(key string) (*jiraIssue, error) { - data, code, err := httpDo("GET", fmt.Sprintf("%s/issue/%s", jiraAPIBase, key), nil, jiraAuth()) - if err != nil { - return nil, err - } - if code != 200 { - return nil, fmt.Errorf("GET %s: HTTP %d: %s", key, code, data) - } - var issue jiraIssue - return &issue, json.Unmarshal(data, &issue) -} - -func jiraUnreleasedVersions() ([]jiraVersion, error) { - data, code, err := httpDo("GET", jiraAPIBase+"/project/ZEPPELIN/versions", nil, jiraAuth()) - if err != nil { - return nil, err - } - if code != 200 { - return nil, fmt.Errorf("GET versions: HTTP %d: %s", code, data) - } - var all []jiraVersion - if err := json.Unmarshal(data, &all); err != nil { - return nil, err - } - var out []jiraVersion - for _, v := range all { - if !v.Released && !v.Archived && reSemanticVer.MatchString(v.Name) { - out = append(out, v) - } - } - sort.Slice(out, func(i, j int) bool { return cmpVer(out[i].Name, out[j].Name) > 0 }) - return out, nil -} - -func jiraTransitions(key string) ([]jiraTransition, error) { - data, code, err := httpDo("GET", fmt.Sprintf("%s/issue/%s/transitions", jiraAPIBase, key), nil, jiraAuth()) - if err != nil { - return nil, err - } - if code != 200 { - return nil, fmt.Errorf("GET transitions %s: HTTP %d: %s", key, code, data) - } - var r struct{ Transitions []jiraTransition `json:"transitions"` } - if err := json.Unmarshal(data, &r); err != nil { - return nil, err - } - return r.Transitions, nil -} - -func jiraResolve(key, tid string, fv []jiraVersion, comment string) error { - var fvu []map[string]interface{} - for _, v := range fv { - fvu = append(fvu, map[string]interface{}{"add": map[string]string{"id": v.ID, "name": v.Name}}) - } - body := map[string]interface{}{ - "transition": map[string]string{"id": tid}, - "update": map[string]interface{}{ - "comment": []map[string]interface{}{{"add": map[string]string{"body": comment}}}, - "fixVersions": fvu, - }, - } - _, code, err := httpDo("POST", fmt.Sprintf("%s/issue/%s/transitions", jiraAPIBase, key), body, jiraAuth()) - if err != nil { - return err - } - if code != 204 { - return fmt.Errorf("resolve %s: HTTP %d", key, code) - } - return nil -} - -func cmpVer(a, b string) int { - ap, bp := strings.Split(a, "."), strings.Split(b, ".") - for i := 0; i < len(ap) && i < len(bp); i++ { - ai, _ := strconv.Atoi(ap[i]) - bi, _ := strconv.Atoi(bp[i]) - if ai != bi { - return ai - bi - } - } - return len(ap) - len(bp) -} - -// ── Helpers ────────────────────────────────────────────────────────────────── - -var ( - jiraIDRe = regexp.MustCompile(`ZEPPELIN-\d{3,6}`) - reTitleFormatted = regexp.MustCompile(`^\[ZEPPELIN-\d{3,6}\](\[[A-Z0-9_\s,]+\] )+\S+`) - reTitleRef = regexp.MustCompile(`(?i)(ZEPPELIN[-\s]*\d{3,6})`) - reComponent = regexp.MustCompile(`(?i)(\[[\w\s,.\-]+\])`) - reWhitespace = regexp.MustCompile(`\s+`) - reLeadingNonWord = regexp.MustCompile(`^\W+`) - reSemanticVer = regexp.MustCompile(`^\d+\.\d+\.\d+$`) -) - -func shortSHA(s string) string { - if len(s) > 8 { - return s[:8] - } - return s -} - -func standardizeTitle(text string) string { - text = strings.TrimRight(text, ".") - if strings.HasPrefix(text, `Revert "`) && strings.HasSuffix(text, `"`) { - return text - } - if reTitleFormatted.MatchString(text) { - return text - } - // Extract JIRA ref(s) - var jiraRefs []string - for _, ref := range reTitleRef.FindAllString(text, -1) { - jiraRefs = append(jiraRefs, "["+strings.ToUpper(reWhitespace.ReplaceAllString(ref, "-"))+"]") - text = strings.Replace(text, ref, "", 1) - } - // Extract component(s): [SPARK], [FLINK], etc. - var components []string - for _, comp := range reComponent.FindAllString(text, -1) { - components = append(components, strings.ToUpper(comp)) - text = strings.Replace(text, comp, "", 1) - } - // Cleanup remaining leading symbols - text = reLeadingNonWord.ReplaceAllString(text, "") - // Assemble: [ZEPPELIN-XXXX][COMPONENT] remaining text - result := strings.Join(jiraRefs, "") + strings.Join(components, "") + " " + text - return reWhitespace.ReplaceAllString(strings.TrimSpace(result), " ") -} - -// ── Main ──────────────────────────────────────────────────────────────────── - -func main() { - flag.Parse() - if flagPR == 0 { - fmt.Fprintln(os.Stderr, "Error: --pr is required") - flag.Usage() - os.Exit(1) - } - if flagGithubToken == "" { - flagGithubToken = os.Getenv("GITHUB_OAUTH_KEY") - } - if flagJiraToken == "" { - flagJiraToken = os.Getenv("JIRA_ACCESS_TOKEN") - } - - if err := run(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } -} - -func run() error { - originalHead, err := gitCurrentRef() - if err != nil { - return fmt.Errorf("get current ref: %w", err) - } - - pr, err := ghGetPR(flagPR) - if err != nil { - return err - } - if !pr.Mergeable { - return fmt.Errorf("PR #%d is not mergeable", flagPR) - } - if strings.Contains(pr.Title, "[WIP]") { - fmt.Fprintf(os.Stderr, "WARNING: PR title contains [WIP]: %s\n", pr.Title) - } - - target := flagTarget - if target == "" { - target = pr.Base.Ref - } - title := standardizeTitle(pr.Title) - src := fmt.Sprintf("%s/%s", pr.User.Login, pr.Head.Ref) - - fmt.Printf("=== Pull Request #%d ===\n", flagPR) - fmt.Printf("title: %s\n", title) - fmt.Printf("source: %s\n", src) - fmt.Printf("target: %s\n", target) - fmt.Printf("url: %s\n", pr.URL) - if len(flagReleaseBranches) > 0 { - fmt.Printf("release-branches: %s\n", strings.Join(flagReleaseBranches, ", ")) - } - - // Resolve fix versions for effective command display - var resolvedFixVersions []string - if flagResolveJira && flagJiraToken != "" { - ids := jiraIDRe.FindAllString(title, -1) - if len(ids) > 0 { - if versions, err := jiraUnreleasedVersions(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to fetch JIRA versions: %v\n", err) - } else if len(versions) > 0 { - vm := make(map[string]jiraVersion) - for _, v := range versions { - vm[v.Name] = v - } - has := make(map[string]bool) - for _, fv := range flagFixVersions { - if _, ok := vm[fv]; ok { - resolvedFixVersions = append(resolvedFixVersions, fv) - has[fv] = true - } - } - inferMaster := len(flagFixVersions) == 0 - for _, iv := range inferFixVersions(append([]string{target}, flagReleaseBranches...), versions, inferMaster) { - if !has[iv.Name] { - resolvedFixVersions = append(resolvedFixVersions, iv.Name) - has[iv.Name] = true - } - } - } - } - } - - if flagDryRun { - fmt.Println() - printEffectiveCommand(target, resolvedFixVersions) - return nil - } - - // Merge - body := strings.ReplaceAll(pr.Body, "@", "") - name, _ := gitRun("config", "--get", "user.name") - email, _ := gitRun("config", "--get", "user.email") - msg := fmt.Sprintf("%s\n\nCloses #%d from %s.\n\nSigned-off-by: %s <%s>", body, flagPR, src, name, email) - - resp, err := ghMergePR(flagPR, title, msg) - if err != nil { - return err - } - fmt.Printf("\nPR #%d merged! (hash: %s)\n", flagPR, shortSHA(resp.SHA)) - - gitRun("fetch", flagPushRemote, target) - - // Cherry-pick into release branches - merged := []string{target} - for _, branch := range flagReleaseBranches { - pick := fmt.Sprintf("PR_TOOL_PICK_PR_%d_%s", flagPR, strings.ToUpper(branch)) - cleanup := func() { - gitRun("checkout", originalHead) - gitRun("branch", "-D", pick) - } - if _, err := gitRun("fetch", flagPushRemote, branch+":"+pick); err != nil { - fmt.Fprintf(os.Stderr, "Warning: fetch %s failed: %v\n", branch, err) - continue - } - gitRun("checkout", pick) - if _, err := gitRun("cherry-pick", "-sx", resp.SHA); err != nil { - fmt.Fprintf(os.Stderr, "Warning: cherry-pick into %s failed: %v\n", branch, err) - gitRun("cherry-pick", "--abort") - cleanup() - continue - } - if _, err := gitRun("push", flagPushRemote, pick+":"+branch); err != nil { - fmt.Fprintf(os.Stderr, "Warning: push to %s failed: %v\n", branch, err) - } else { - h, _ := gitRun("rev-parse", pick) - fmt.Printf("Picked into %s (hash: %s)\n", branch, shortSHA(h)) - merged = append(merged, branch) - } - cleanup() - } - - // Comment on PR with merge summary - var commentLines []string - commentLines = append(commentLines, fmt.Sprintf("Merged into %s (%s).", target, shortSHA(resp.SHA))) - for _, branch := range merged[1:] { - commentLines = append(commentLines, fmt.Sprintf("Cherry-picked into %s.", branch)) - } - comment := strings.Join(commentLines, "\n") - if err := ghCommentPR(flagPR, comment); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to comment on PR: %v\n", err) - } else { - fmt.Println("Commented on PR with merge summary.") - } - - // Resolve JIRA - if flagResolveJira { - if err := doResolveJira(title, merged); err != nil { - fmt.Fprintf(os.Stderr, "Warning: JIRA resolution failed: %v\n", err) - } - } - return nil -} - -func doResolveJira(title string, merged []string) error { - if flagJiraToken == "" { - return fmt.Errorf("JIRA_ACCESS_TOKEN is not set") - } - ids := jiraIDRe.FindAllString(title, -1) - if len(ids) == 0 { - fmt.Println("No JIRA ID found in PR title, skipping.") - return nil - } - - versions, err := jiraUnreleasedVersions() - if err != nil { - return err - } - - vm := make(map[string]jiraVersion) - for _, v := range versions { - vm[v.Name] = v - } - // Start with explicitly specified fix versions - var fixVer []jiraVersion - has := make(map[string]bool) - for _, fv := range flagFixVersions { - v, ok := vm[fv] - if !ok { - return fmt.Errorf("fix version %q not found", fv) - } - fixVer = append(fixVer, v) - has[v.Name] = true - } - // Auto-infer: master → latest version only when no --fix-versions given; - // release branches → matching version always - if len(versions) > 0 { - inferMaster := len(flagFixVersions) == 0 - for _, iv := range inferFixVersions(merged, versions, inferMaster) { - if !has[iv.Name] { - fixVer = append(fixVer, iv) - has[iv.Name] = true - } - } - } - - for _, id := range ids { - issue, err := jiraGetIssue(id) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: get %s: %v\n", id, err) - continue - } - st := issue.Fields.Status.Name - if st == "Resolved" || st == "Closed" { - fmt.Printf("JIRA %s already %q, skipping.\n", id, st) - continue - } - - fmt.Printf("=== JIRA %s ===\n", issue.Key) - fmt.Printf("Summary: %s\n", issue.Fields.Summary) - fmt.Printf("Status: %s\n", st) - if issue.Fields.Assignee != nil { - fmt.Printf("Assignee: %s\n", issue.Fields.Assignee.DisplayName) - } - - ts, err := jiraTransitions(id) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: transitions %s: %v\n", id, err) - continue - } - var rid string - for _, t := range ts { - if t.Name == "Resolve Issue" { - rid = t.ID - break - } - } - if rid == "" { - fmt.Fprintf(os.Stderr, "Warning: no 'Resolve Issue' transition for %s\n", id) - continue - } - - comment := fmt.Sprintf("Issue resolved by pull request %d\n[https://github.com/apache/zeppelin/pull/%d]", flagPR, flagPR) - if err := jiraResolve(id, rid, fixVer, comment); err != nil { - fmt.Fprintf(os.Stderr, "Warning: resolve %s: %v\n", id, err) - continue - } - fmt.Printf("Resolved %s!\n", id) - } - return nil -} - -func printEffectiveCommand(target string, fixVersions []string) { - var parts []string - parts = append(parts, "go run dev/merge-pr.go") - parts = append(parts, fmt.Sprintf("--pr %d", flagPR)) - if target != "" && target != "master" { - parts = append(parts, fmt.Sprintf("--target %s", target)) - } - if len(flagReleaseBranches) > 0 { - parts = append(parts, fmt.Sprintf("--release-branches %s", strings.Join(flagReleaseBranches, ","))) - } - if flagResolveJira { - parts = append(parts, "--resolve-jira") - } - if len(fixVersions) > 0 { - parts = append(parts, fmt.Sprintf("--fix-versions %s", strings.Join(fixVersions, ","))) - } - if flagPushRemote != "apache" { - parts = append(parts, fmt.Sprintf("--push-remote %s", flagPushRemote)) - } - fmt.Printf("[dry-run] Effective command:\n %s\n", strings.Join(parts, " ")) -} - -// inferFixVersions maps merge branches to JIRA fix versions. -// For "master", picks the latest unreleased version. -// For release branches like "branch-0.12", finds the smallest matching 0.12.x version. -// Then removes redundant X.Y.0 if a previous minor X.(Y-1).0 is also selected. -func inferFixVersions(merged []string, versions []jiraVersion, inferMaster bool) []jiraVersion { - var names []string - has := make(map[string]bool) - for _, branch := range merged { - if branch == "master" { - if inferMaster && !has[versions[0].Name] { - names = append(names, versions[0].Name) - has[versions[0].Name] = true - } - } else { - // "branch-0.12" → prefix "0.12" - prefix := strings.TrimPrefix(branch, "branch-") - // Find all matching versions, pick the smallest (last in desc-sorted list) - var found []string - for _, v := range versions { - if strings.HasPrefix(v.Name, prefix+".") || v.Name == prefix { - found = append(found, v.Name) - } - } - if len(found) > 0 { - pick := found[len(found)-1] - if !has[pick] { - names = append(names, pick) - has[pick] = true - } - } else { - fmt.Fprintf(os.Stderr, "Warning: no version found for %s, skipping\n", branch) - } - } - } - // Remove redundant X.Y.0 when X.(Y-1).0 is also present - filtered := make([]string, 0, len(names)) - for _, v := range names { - parts := strings.Split(v, ".") - if len(parts) == 3 && parts[2] == "0" { - minor, _ := strconv.Atoi(parts[1]) - if minor > 0 { - prev := fmt.Sprintf("%s.%d.0", parts[0], minor-1) - if has[prev] { - continue - } - } - } - filtered = append(filtered, v) - } - // Map names back to jiraVersion structs - vm := make(map[string]jiraVersion) - for _, v := range versions { - vm[v.Name] = v - } - var result []jiraVersion - for _, name := range filtered { - if v, ok := vm[name]; ok { - result = append(result, v) - } - } - if len(result) > 0 { - fmt.Printf("Auto-inferred fix version(s): %s\n", strings.Join(filtered, ", ")) - } - return result -} From 49b6c3cdab59a3af3697aa527017238bafb3637e Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 15:48:03 +0900 Subject: [PATCH 15/18] [ZEPPELIN-6404] Refactor Java CLI to instance-based style Convert all static fields/methods to instance-based: constructor parses args into instance fields, run() and helpers are instance methods. Only constants, JSON/utility helpers, and main() remain static. Also extract resolveFixVersionNames() and commentMergeSummary() for readability. Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.java | 361 +++++++++++++++++++++++----------------------- 1 file changed, 177 insertions(+), 184 deletions(-) diff --git a/dev/merge-pr.java b/dev/merge-pr.java index 33e6b667f16..6c611147d43 100644 --- a/dev/merge-pr.java +++ b/dev/merge-pr.java @@ -24,17 +24,13 @@ // java dev/merge-pr.java --pr 5167 --resolve-jira --release-branches branch-0.12,branch-0.11 import java.io.BufferedReader; -import java.io.IOException; import java.io.InputStreamReader; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Comparator; import java.util.HashMap; -import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -49,46 +45,49 @@ */ public class MergePr { - static final String GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin"; - static final String JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2"; - static final HttpClient HTTP = HttpClient.newHttpClient(); - - static final Pattern JIRA_ID_RE = Pattern.compile("ZEPPELIN-\\d{3,6}"); - static final Pattern TITLE_FORMATTED_RE = Pattern.compile("^\\[ZEPPELIN-\\d{3,6}](\\[[A-Z0-9_\\s,]+] )+\\S+"); - static final Pattern TITLE_REF_RE = Pattern.compile("(?i)(ZEPPELIN[-\\s]*\\d{3,6})"); - static final Pattern COMPONENT_RE = Pattern.compile("(?i)(\\[[\\w\\s,.\\-]+])"); - static final Pattern WHITESPACE_RE = Pattern.compile("\\s+"); - static final Pattern LEADING_NON_WORD_RE = Pattern.compile("^\\W+"); - static final Pattern SEMANTIC_VER_RE = Pattern.compile("^\\d+\\.\\d+\\.\\d+$"); - - // ── Flags ────────────────────────────────────────────────────────────── - - static int flagPR; - static String flagTarget = ""; - static List flagFixVersions = new ArrayList<>(); - static List flagReleaseBranches = new ArrayList<>(); - static boolean flagResolveJira; - static boolean flagDryRun; - static String flagPushRemote; - static String flagGithubToken; - static String flagJiraToken; - - static void parseArgs(String[] args) { - flagPushRemote = envOrDefault("PUSH_REMOTE_NAME", "apache"); - flagGithubToken = envOrDefault("GITHUB_OAUTH_KEY", ""); - flagJiraToken = envOrDefault("JIRA_ACCESS_TOKEN", ""); + private static final String GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin"; + private static final String JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2"; + + private static final Pattern JIRA_ID_RE = Pattern.compile("ZEPPELIN-\\d{3,6}"); + private static final Pattern TITLE_FORMATTED_RE = Pattern.compile("^\\[ZEPPELIN-\\d{3,6}](\\[[A-Z0-9_\\s,]+] )+\\S+"); + private static final Pattern TITLE_REF_RE = Pattern.compile("(?i)(ZEPPELIN[-\\s]*\\d{3,6})"); + private static final Pattern COMPONENT_RE = Pattern.compile("(?i)(\\[[\\w\\s,.\\-]+])"); + private static final Pattern WHITESPACE_RE = Pattern.compile("\\s+"); + private static final Pattern LEADING_NON_WORD_RE = Pattern.compile("^\\W+"); + private static final Pattern SEMANTIC_VER_RE = Pattern.compile("^\\d+\\.\\d+\\.\\d+$"); + + // ── Instance fields (parsed from CLI args) ───────────────────────────── + + private final HttpClient http = HttpClient.newHttpClient(); + + private int pr; + private String target = ""; + private List fixVersions = new ArrayList<>(); + private List releaseBranches = new ArrayList<>(); + private boolean resolveJira; + private boolean dryRun; + private String pushRemote; + private String githubToken; + private String jiraToken; + + // ── Constructor & arg parsing ────────────────────────────────────────── + + private MergePr(String[] args) { + pushRemote = envOrDefault("PUSH_REMOTE_NAME", "apache"); + githubToken = envOrDefault("GITHUB_OAUTH_KEY", ""); + jiraToken = envOrDefault("JIRA_ACCESS_TOKEN", ""); for (int i = 0; i < args.length; i++) { switch (args[i]) { - case "--pr": flagPR = Integer.parseInt(args[++i]); break; - case "--target": flagTarget = args[++i]; break; - case "--fix-versions": flagFixVersions = parseCsv(args[++i]); break; - case "--release-branches": flagReleaseBranches = parseCsv(args[++i]); break; - case "--resolve-jira": flagResolveJira = true; break; - case "--dry-run": flagDryRun = true; break; - case "--push-remote": flagPushRemote = args[++i]; break; - case "--github-token": flagGithubToken = args[++i]; break; - case "--jira-token": flagJiraToken = args[++i]; break; + case "--pr": pr = Integer.parseInt(args[++i]); break; + case "--target": target = args[++i]; break; + case "--fix-versions": fixVersions = parseCsv(args[++i]); break; + case "--release-branches": releaseBranches = parseCsv(args[++i]); break; + case "--resolve-jira": resolveJira = true; break; + case "--dry-run": dryRun = true; break; + case "--push-remote": pushRemote = args[++i]; break; + case "--github-token": githubToken = args[++i]; break; + case "--jira-token": jiraToken = args[++i]; break; case "--help": case "-h": printUsage(); System.exit(0); break; default: System.err.println("Unknown flag: " + args[i]); @@ -98,7 +97,7 @@ static void parseArgs(String[] args) { } } - static void printUsage() { + private static void printUsage() { System.err.println("Usage: java dev/merge-pr.java [flags]"); System.err.println(" --pr int Pull request number (required)"); System.err.println(" --target string Target branch (default: PR base branch)"); @@ -111,7 +110,7 @@ static void printUsage() { System.err.println(" --jira-token string JIRA access token (env: JIRA_ACCESS_TOKEN)"); } - static List parseCsv(String value) { + private static List parseCsv(String value) { List result = new ArrayList<>(); for (String s : value.split(",")) { String trimmed = s.trim(); @@ -120,14 +119,14 @@ static List parseCsv(String value) { return result; } - static String envOrDefault(String key, String def) { + private static String envOrDefault(String key, String def) { String v = System.getenv(key); return (v != null && !v.isEmpty()) ? v : def; } // ── Git ──────────────────────────────────────────────────────────────── - static String gitRun(String... args) throws Exception { + private String gitRun(String... args) throws Exception { String[] cmd = new String[args.length + 1]; cmd[0] = "git"; System.arraycopy(args, 0, cmd, 1, args.length); @@ -143,15 +142,15 @@ static String gitRun(String... args) throws Exception { return output; } - static String gitCurrentRef() throws Exception { + private String gitCurrentRef() throws Exception { String ref = gitRun("rev-parse", "--abbrev-ref", "HEAD"); return "HEAD".equals(ref) ? gitRun("rev-parse", "HEAD") : ref; } // ── HTTP ─────────────────────────────────────────────────────────────── - static HttpResponse httpDo(String method, String url, String body, String auth) - throws IOException, InterruptedException { + private HttpResponse httpDo(String method, String url, String body, String auth) + throws Exception { HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)) .header("Content-Type", "application/json") .header("Accept", "application/json"); @@ -163,27 +162,24 @@ static HttpResponse httpDo(String method, String url, String body, Strin } else { builder.method(method, HttpRequest.BodyPublishers.noBody()); } - return HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); } // ── Simple JSON parser (no external deps) ────────────────────────────── - // Minimal JSON helpers — we only need to read specific fields from GitHub/JIRA responses. - // For writing, we build JSON strings directly. - - static String jsonStr(String json, String key) { + private static String jsonStr(String json, String key) { String pattern = "\"" + key + "\"\\s*:\\s*\"([^\"]*?)\""; Matcher m = Pattern.compile(pattern).matcher(json); return m.find() ? m.group(1) : ""; } - static boolean jsonBool(String json, String key) { + private static boolean jsonBool(String json, String key) { String pattern = "\"" + key + "\"\\s*:\\s*(true|false)"; Matcher m = Pattern.compile(pattern).matcher(json); return m.find() && "true".equals(m.group(1)); } - static String jsonObj(String json, String key) { + private static String jsonObj(String json, String key) { String pattern = "\"" + key + "\"\\s*:\\s*\\{"; Matcher m = Pattern.compile(pattern).matcher(json); if (!m.find()) return "{}"; @@ -196,7 +192,7 @@ static String jsonObj(String json, String key) { return "{}"; } - static List jsonArray(String json, String key) { + private static List jsonArray(String json, String key) { String pattern = "\"" + key + "\"\\s*:\\s*\\["; Matcher m = Pattern.compile(pattern).matcher(json); if (!m.find()) return List.of(); @@ -207,10 +203,12 @@ static List jsonArray(String json, String key) { if (json.charAt(i) == '[') depth++; else if (json.charAt(i) == ']') { depth--; if (depth == 0) { arrEnd = i + 1; break; } } } - String arr = json.substring(start, arrEnd); - // Split top-level objects + return parseObjectArray(json.substring(start, arrEnd)); + } + + private static List parseObjectArray(String arr) { List items = new ArrayList<>(); - depth = 0; + int depth = 0; int itemStart = -1; for (int i = 1; i < arr.length() - 1; i++) { char c = arr.charAt(i); @@ -225,19 +223,24 @@ static List jsonArray(String json, String key) { return items; } + private static String jsonEscape(String s) { + return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""; + } + // ── GitHub ────────────────────────────────────────────────────────────── - static String ghAuth() { - return flagGithubToken.isEmpty() ? "" : "token " + flagGithubToken; + private String ghAuth() { + return githubToken.isEmpty() ? "" : "token " + githubToken; } - static String ghGetPR(int num) throws Exception { + private String ghGetPR(int num) throws Exception { HttpResponse r = httpDo("GET", GITHUB_API_BASE + "/pulls/" + num, null, ghAuth()); if (r.statusCode() != 200) throw new RuntimeException("GET PR #" + num + ": HTTP " + r.statusCode()); return r.body(); } - static String ghMergePR(int num, String title, String msg) throws Exception { + private String ghMergePR(int num, String title, String msg) throws Exception { String body = String.format("{\"commit_title\":%s,\"commit_message\":%s,\"merge_method\":\"squash\"}", jsonEscape(title), jsonEscape(msg)); HttpResponse r = httpDo("PUT", GITHUB_API_BASE + "/pulls/" + num + "/merge", body, ghAuth()); @@ -246,7 +249,7 @@ static String ghMergePR(int num, String title, String msg) throws Exception { return r.body(); } - static void ghCommentPR(int num, String comment) throws Exception { + private void ghCommentPR(int num, String comment) throws Exception { String body = String.format("{\"body\":%s}", jsonEscape(comment)); HttpResponse r = httpDo("POST", GITHUB_API_BASE + "/issues/" + num + "/comments", body, ghAuth()); if (r.statusCode() != 201) { @@ -254,40 +257,23 @@ static void ghCommentPR(int num, String comment) throws Exception { } } - static String jsonEscape(String s) { - return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") - .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""; - } - // ── JIRA ─────────────────────────────────────────────────────────────── - static String jiraAuth() { - return flagJiraToken.isEmpty() ? "" : "Bearer " + flagJiraToken; + private String jiraAuth() { + return jiraToken.isEmpty() ? "" : "Bearer " + jiraToken; } - static String jiraGetIssue(String key) throws Exception { + private String jiraGetIssue(String key) throws Exception { HttpResponse r = httpDo("GET", JIRA_API_BASE + "/issue/" + key, null, jiraAuth()); if (r.statusCode() != 200) throw new RuntimeException("GET " + key + ": HTTP " + r.statusCode()); return r.body(); } - static List> jiraUnreleasedVersions() throws Exception { + private List> jiraUnreleasedVersions() throws Exception { HttpResponse r = httpDo("GET", JIRA_API_BASE + "/project/ZEPPELIN/versions", null, jiraAuth()); if (r.statusCode() != 200) throw new RuntimeException("GET versions: HTTP " + r.statusCode()); - List all = jsonArray(r.body(), "dummy"); // won't work — top level is array - // Parse top-level array manually String body = r.body().trim(); - all = new ArrayList<>(); - int depth = 0; int start = -1; - for (int i = 1; i < body.length() - 1; i++) { - if (body.charAt(i) == '{' && depth == 0) start = i; - if (body.charAt(i) == '{') depth++; - if (body.charAt(i) == '}') depth--; - if (body.charAt(i) == '}' && depth == 0 && start >= 0) { - all.add(body.substring(start, i + 1)); - start = -1; - } - } + List all = parseObjectArray(body); List> versions = new ArrayList<>(); for (String v : all) { String name = jsonStr(v, "name"); @@ -304,7 +290,7 @@ static List> jiraUnreleasedVersions() throws Exception { return versions; } - static List> jiraTransitions(String key) throws Exception { + private List> jiraTransitions(String key) throws Exception { HttpResponse r = httpDo("GET", JIRA_API_BASE + "/issue/" + key + "/transitions", null, jiraAuth()); if (r.statusCode() != 200) throw new RuntimeException("GET transitions " + key + ": HTTP " + r.statusCode()); List> result = new ArrayList<>(); @@ -317,13 +303,13 @@ static List> jiraTransitions(String key) throws Exception { return result; } - static void jiraResolve(String key, String transitionId, List> fixVersions, String comment) + private void jiraResolve(String key, String transitionId, List> fvList, String comment) throws Exception { StringBuilder fvJson = new StringBuilder("["); - for (int i = 0; i < fixVersions.size(); i++) { + for (int i = 0; i < fvList.size(); i++) { if (i > 0) fvJson.append(","); fvJson.append(String.format("{\"add\":{\"id\":\"%s\",\"name\":\"%s\"}}", - fixVersions.get(i).get("id"), fixVersions.get(i).get("name"))); + fvList.get(i).get("id"), fvList.get(i).get("name"))); } fvJson.append("]"); String body = String.format( @@ -333,7 +319,9 @@ static void jiraResolve(String key, String transitionId, List 8 ? sha.substring(0, 8) : sha; } // ── Fix version inference ────────────────────────────────────────────── - static List> inferFixVersions(List merged, + private List> inferFixVersions(List merged, List> versions, boolean inferMaster) { List names = new ArrayList<>(); LinkedHashSet has = new LinkedHashSet<>(); @@ -424,94 +410,53 @@ static List> inferFixVersions(List merged, // ── Effective command ────────────────────────────────────────────────── - static void printEffectiveCommand(String target, List fixVersions) { + private void printEffectiveCommand(String targetBranch, List resolvedVersions) { List parts = new ArrayList<>(); parts.add("java dev/merge-pr.java"); - parts.add("--pr " + flagPR); - if (!target.isEmpty() && !"master".equals(target)) parts.add("--target " + target); - if (!flagReleaseBranches.isEmpty()) parts.add("--release-branches " + String.join(",", flagReleaseBranches)); - if (flagResolveJira) parts.add("--resolve-jira"); - if (!fixVersions.isEmpty()) parts.add("--fix-versions " + String.join(",", fixVersions)); - if (!"apache".equals(flagPushRemote)) parts.add("--push-remote " + flagPushRemote); + parts.add("--pr " + pr); + if (!targetBranch.isEmpty() && !"master".equals(targetBranch)) parts.add("--target " + targetBranch); + if (!releaseBranches.isEmpty()) parts.add("--release-branches " + String.join(",", releaseBranches)); + if (resolveJira) parts.add("--resolve-jira"); + if (!resolvedVersions.isEmpty()) parts.add("--fix-versions " + String.join(",", resolvedVersions)); + if (!"apache".equals(pushRemote)) parts.add("--push-remote " + pushRemote); System.out.println("[dry-run] Effective command:\n " + String.join(" ", parts)); } - // ── Main ─────────────────────────────────────────────────────────────── + // ── Main flow ────────────────────────────────────────────────────────── - public static void main(String[] args) throws Exception { - parseArgs(args); - if (flagPR == 0) { - System.err.println("Error: --pr is required"); - printUsage(); - System.exit(1); - } - run(); - } - - static void run() throws Exception { + private void run() throws Exception { String originalHead = gitCurrentRef(); - String prJson = ghGetPR(flagPR); + String prJson = ghGetPR(pr); if (!jsonBool(prJson, "mergeable")) { - throw new RuntimeException("PR #" + flagPR + " is not mergeable"); + throw new RuntimeException("PR #" + pr + " is not mergeable"); } String prTitle = jsonStr(prJson, "title"); if (prTitle.contains("[WIP]")) { System.err.println("WARNING: PR title contains [WIP]: " + prTitle); } - String target = flagTarget.isEmpty() ? jsonStr(jsonObj(prJson, "base"), "ref") : flagTarget; + String targetBranch = target.isEmpty() ? jsonStr(jsonObj(prJson, "base"), "ref") : target; String title = standardizeTitle(prTitle); String headRef = jsonStr(jsonObj(prJson, "head"), "ref"); String userLogin = jsonStr(jsonObj(prJson, "user"), "login"); String src = userLogin + "/" + headRef; String prBody = jsonStr(prJson, "body"); - System.out.println("=== Pull Request #" + flagPR + " ==="); + System.out.println("=== Pull Request #" + pr + " ==="); System.out.println("title: " + title); System.out.println("source: " + src); - System.out.println("target: " + target); + System.out.println("target: " + targetBranch); System.out.println("url: " + jsonStr(prJson, "url")); - if (!flagReleaseBranches.isEmpty()) { - System.out.println("release-branches: " + String.join(", ", flagReleaseBranches)); - } - - // Resolve fix versions for effective command display - List resolvedFixVersions = new ArrayList<>(); - if (flagResolveJira && !flagJiraToken.isEmpty()) { - List ids = new ArrayList<>(); - Matcher idm = JIRA_ID_RE.matcher(title); - while (idm.find()) ids.add(idm.group()); - if (!ids.isEmpty()) { - try { - List> versions = jiraUnreleasedVersions(); - if (!versions.isEmpty()) { - Map> vm = new HashMap<>(); - for (Map v : versions) vm.put(v.get("name"), v); - LinkedHashSet has = new LinkedHashSet<>(); - for (String fv : flagFixVersions) { - if (vm.containsKey(fv)) { resolvedFixVersions.add(fv); has.add(fv); } - } - boolean inferMaster = flagFixVersions.isEmpty(); - List branches = new ArrayList<>(); - branches.add(target); - branches.addAll(flagReleaseBranches); - for (Map iv : inferFixVersions(branches, versions, inferMaster)) { - if (!has.contains(iv.get("name"))) { - resolvedFixVersions.add(iv.get("name")); - has.add(iv.get("name")); - } - } - } - } catch (Exception e) { - System.err.println("Warning: failed to fetch JIRA versions: " + e.getMessage()); - } - } + if (!releaseBranches.isEmpty()) { + System.out.println("release-branches: " + String.join(", ", releaseBranches)); } - if (flagDryRun) { + List resolvedFixVersions = resolveFixVersionNames(title, targetBranch); + + if (dryRun) { System.out.println(); - printEffectiveCommand(target, resolvedFixVersions); + printEffectiveCommand(targetBranch, resolvedFixVersions); return; } @@ -520,21 +465,21 @@ static void run() throws Exception { String name = "", email = ""; try { name = gitRun("config", "--get", "user.name"); } catch (Exception ignored) {} try { email = gitRun("config", "--get", "user.email"); } catch (Exception ignored) {} - String msg = body + "\n\nCloses #" + flagPR + " from " + src + ".\n\nSigned-off-by: " + name + " <" + email + ">"; + String msg = body + "\n\nCloses #" + pr + " from " + src + ".\n\nSigned-off-by: " + name + " <" + email + ">"; - String mergeJson = ghMergePR(flagPR, title, msg); + String mergeJson = ghMergePR(pr, title, msg); String sha = jsonStr(mergeJson, "sha"); - System.out.println("\nPR #" + flagPR + " merged! (hash: " + shortSHA(sha) + ")"); + System.out.println("\nPR #" + pr + " merged! (hash: " + shortSHA(sha) + ")"); - try { gitRun("fetch", flagPushRemote, target); } catch (Exception ignored) {} + try { gitRun("fetch", pushRemote, targetBranch); } catch (Exception ignored) {} // Cherry-pick into release branches List merged = new ArrayList<>(); - merged.add(target); - for (String branch : flagReleaseBranches) { - String pick = "PR_TOOL_PICK_PR_" + flagPR + "_" + branch.toUpperCase(); + merged.add(targetBranch); + for (String branch : releaseBranches) { + String pick = "PR_TOOL_PICK_PR_" + pr + "_" + branch.toUpperCase(); try { - gitRun("fetch", flagPushRemote, branch + ":" + pick); + gitRun("fetch", pushRemote, branch + ":" + pick); } catch (Exception e) { System.err.println("Warning: fetch " + branch + " failed: " + e.getMessage()); continue; @@ -550,7 +495,7 @@ static void run() throws Exception { continue; } try { - gitRun("push", flagPushRemote, pick + ":" + branch); + gitRun("push", pushRemote, pick + ":" + branch); String h = gitRun("rev-parse", pick); System.out.println("Picked into " + branch + " (hash: " + shortSHA(h) + ")"); merged.add(branch); @@ -561,31 +506,67 @@ static void run() throws Exception { gitRun("branch", "-D", pick); } - // Comment on PR + commentMergeSummary(merged, sha); + + if (resolveJira) { + try { + doResolveJira(title, merged); + } catch (Exception e) { + System.err.println("Warning: JIRA resolution failed: " + e.getMessage()); + } + } + } + + private List resolveFixVersionNames(String title, String targetBranch) { + if (!resolveJira || jiraToken.isEmpty()) return new ArrayList<>(fixVersions); + + List ids = new ArrayList<>(); + Matcher idm = JIRA_ID_RE.matcher(title); + while (idm.find()) ids.add(idm.group()); + if (ids.isEmpty()) return new ArrayList<>(fixVersions); + + try { + List> versions = jiraUnreleasedVersions(); + if (versions.isEmpty()) return new ArrayList<>(fixVersions); + + Map> vm = new HashMap<>(); + for (Map v : versions) vm.put(v.get("name"), v); + + LinkedHashSet resolved = new LinkedHashSet<>(); + for (String fv : fixVersions) { + if (vm.containsKey(fv)) resolved.add(fv); + } + + boolean inferMaster = fixVersions.isEmpty(); + List branches = new ArrayList<>(); + branches.add(targetBranch); + branches.addAll(releaseBranches); + for (Map iv : inferFixVersions(branches, versions, inferMaster)) { + resolved.add(iv.get("name")); + } + return new ArrayList<>(resolved); + } catch (Exception e) { + System.err.println("Warning: failed to fetch JIRA versions: " + e.getMessage()); + return new ArrayList<>(fixVersions); + } + } + + private void commentMergeSummary(List merged, String sha) { StringBuilder comment = new StringBuilder(); - comment.append("Merged into ").append(target).append(" (").append(shortSHA(sha)).append(")."); + comment.append("Merged into ").append(merged.get(0)).append(" (").append(shortSHA(sha)).append(")."); for (int i = 1; i < merged.size(); i++) { comment.append("\nCherry-picked into ").append(merged.get(i)).append("."); } try { - ghCommentPR(flagPR, comment.toString()); + ghCommentPR(pr, comment.toString()); System.out.println("Commented on PR with merge summary."); } catch (Exception e) { System.err.println("Warning: failed to comment on PR: " + e.getMessage()); } - - // Resolve JIRA - if (flagResolveJira) { - try { - doResolveJira(title, merged); - } catch (Exception e) { - System.err.println("Warning: JIRA resolution failed: " + e.getMessage()); - } - } } - static void doResolveJira(String title, List merged) throws Exception { - if (flagJiraToken.isEmpty()) throw new RuntimeException("JIRA_ACCESS_TOKEN is not set"); + private void doResolveJira(String title, List merged) throws Exception { + if (jiraToken.isEmpty()) throw new RuntimeException("JIRA_ACCESS_TOKEN is not set"); List ids = new ArrayList<>(); Matcher m = JIRA_ID_RE.matcher(title); @@ -599,13 +580,13 @@ static void doResolveJira(String title, List merged) throws Exception { List> fixVer = new ArrayList<>(); LinkedHashSet has = new LinkedHashSet<>(); - for (String fv : flagFixVersions) { + for (String fv : fixVersions) { if (!vm.containsKey(fv)) throw new RuntimeException("fix version \"" + fv + "\" not found"); fixVer.add(vm.get(fv)); has.add(fv); } if (!versions.isEmpty()) { - boolean inferMaster = flagFixVersions.isEmpty(); + boolean inferMaster = fixVersions.isEmpty(); for (Map iv : inferFixVersions(merged, versions, inferMaster)) { if (!has.contains(iv.get("name"))) { fixVer.add(iv); @@ -639,8 +620,8 @@ static void doResolveJira(String title, List merged) throws Exception { continue; } - String jiraComment = "Issue resolved by pull request " + flagPR - + "\n[https://github.com/apache/zeppelin/pull/" + flagPR + "]"; + String jiraComment = "Issue resolved by pull request " + pr + + "\n[https://github.com/apache/zeppelin/pull/" + pr + "]"; try { jiraResolve(id, resolveId, fixVer, jiraComment); System.out.println("Resolved " + id + "!"); @@ -649,4 +630,16 @@ static void doResolveJira(String title, List merged) throws Exception { } } } + + // ── Entry point ──────────────────────────────────────────────────────── + + public static void main(String[] args) throws Exception { + MergePr cli = new MergePr(args); + if (cli.pr == 0) { + System.err.println("Error: --pr is required"); + printUsage(); + System.exit(1); + } + cli.run(); + } } From 4ee0887b1c379bc74136d71ef8b0387b6c02321e Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 15:55:55 +0900 Subject: [PATCH 16/18] [ZEPPELIN-6404] Fix help text alignment in merge-pr.java Co-Authored-By: Claude Opus 4.6 --- dev/merge-pr.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/dev/merge-pr.java b/dev/merge-pr.java index 6c611147d43..bc3c12cb814 100644 --- a/dev/merge-pr.java +++ b/dev/merge-pr.java @@ -99,15 +99,15 @@ private MergePr(String[] args) { private static void printUsage() { System.err.println("Usage: java dev/merge-pr.java [flags]"); - System.err.println(" --pr int Pull request number (required)"); - System.err.println(" --target string Target branch (default: PR base branch)"); - System.err.println(" --fix-versions value JIRA fix version(s), comma-separated"); - System.err.println(" --release-branches value Release branch(es) to cherry-pick into, comma-separated"); - System.err.println(" --resolve-jira Resolve associated JIRA issue(s)"); - System.err.println(" --dry-run Show what would be done without making changes"); - System.err.println(" --push-remote string Git remote for pushing (default: apache)"); - System.err.println(" --github-token string GitHub OAuth token (env: GITHUB_OAUTH_KEY)"); - System.err.println(" --jira-token string JIRA access token (env: JIRA_ACCESS_TOKEN)"); + System.err.println(" --pr int Pull request number (required)"); + System.err.println(" --target string Target branch (default: PR base branch)"); + System.err.println(" --fix-versions value JIRA fix version(s), comma-separated"); + System.err.println(" --release-branches value Release branch(es) to cherry-pick into, comma-separated"); + System.err.println(" --resolve-jira Resolve associated JIRA issue(s)"); + System.err.println(" --dry-run Show what would be done without making changes"); + System.err.println(" --push-remote string Git remote for pushing (default: apache)"); + System.err.println(" --github-token string GitHub OAuth token (env: GITHUB_OAUTH_KEY)"); + System.err.println(" --jira-token string JIRA access token (env: JIRA_ACCESS_TOKEN)"); } private static List parseCsv(String value) { From e2c5da9c89f2271bc84898fc3acff282cb97e86c Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 17:16:17 +0900 Subject: [PATCH 17/18] [ZEPPELIN-6404] Rewrite merge CLI in Python, remove Java and skill Replace dev/merge-pr.java with dev/merge_pr.py using only Python 3 built-in libraries (urllib, json, subprocess, argparse, re). Remove .claude/commands/merge-pr.md from git tracking. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/merge-pr.md | 67 ---- dev/merge-pr.java | 645 ----------------------------------- dev/merge_pr.py | 462 +++++++++++++++++++++++++ 3 files changed, 462 insertions(+), 712 deletions(-) delete mode 100644 .claude/commands/merge-pr.md delete mode 100644 dev/merge-pr.java create mode 100644 dev/merge_pr.py diff --git a/.claude/commands/merge-pr.md b/.claude/commands/merge-pr.md deleted file mode 100644 index 880a15e7560..00000000000 --- a/.claude/commands/merge-pr.md +++ /dev/null @@ -1,67 +0,0 @@ - - -Merge a pull request using the Java CLI tool (`dev/merge-pr.java`). - -## Input - -User input: $ARGUMENTS - -This can be in any form — CLI flags, natural language, or a mix. Examples: -- `5167` -- `5167 fix-versions 0.13.0, also cherry-pick into branch-0.12` -- `5167 --fix-versions 0.13.0 --release-branches branch-0.12` -- `merge PR #5167 and resolve JIRA` - -Parse the user's intent and build the appropriate `java dev/merge-pr.java` command. - -## Instructions - -1. Extract from the user input: PR number, fix-versions, release-branches, resolve-jira, and any other flags. -2. If the PR number is missing or no arguments given, run `java dev/merge-pr.java --help` to show available flags, then ask the user for the PR number and any options they want. -3. Always add `--resolve-jira` unless the user explicitly says not to. -4. Run a dry-run first: - -``` -java dev/merge-pr.java --pr --resolve-jira [--fix-versions ] [--release-branches ] --dry-run -``` - -5. Show the dry-run output (including the effective command) to the user and ask: - - Does the effective command look correct? - - Do you want to change fix-versions, add release-branches, or adjust anything? - - If the user wants changes, re-run dry-run with updated flags and ask again. - - If the user confirms, proceed to step 6. -6. Run the actual merge command (without `--dry-run`), using the effective command from the dry-run output. -7. After merge, verify the result and report back. - -## Flags Reference - -| Flag | Description | -|------|-------------| -| `--pr` | PR number (required) | -| `--fix-versions` | JIRA fix version(s), comma-separated | -| `--release-branches` | Release branch(es) to cherry-pick into, comma-separated | -| `--resolve-jira` | Resolve associated JIRA issue(s) | -| `--dry-run` | Show what would be done without making changes | -| `--push-remote` | Git remote for pushing (default: apache) | -| `--target` | Target branch (default: PR base branch) | - -## Notes - -- Always dry-run first. Never merge without user confirmation. -- If `--fix-versions` is omitted and `--release-branches` is given, versions are auto-inferred from JIRA. -- Tokens are read from environment: `GITHUB_OAUTH_KEY`, `JIRA_ACCESS_TOKEN`. diff --git a/dev/merge-pr.java b/dev/merge-pr.java deleted file mode 100644 index bc3c12cb814..00000000000 --- a/dev/merge-pr.java +++ /dev/null @@ -1,645 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// merge-pr.java merges Apache Zeppelin pull requests via the GitHub API, -// optionally cherry-picks into release branches, and resolves JIRA issues. -// -// Usage: -// java dev/merge-pr.java --pr 5167 --dry-run -// java dev/merge-pr.java --pr 5167 --resolve-jira --fix-versions 0.13.0 -// java dev/merge-pr.java --pr 5167 --resolve-jira --release-branches branch-0.12,branch-0.11 - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -/** - * Single-file Java CLI for merging Apache Zeppelin pull requests. - * Requires Java 11+. No external dependencies. - * Run with: java dev/merge-pr.java --pr NUMBER [flags] - */ -public class MergePr { - - private static final String GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin"; - private static final String JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2"; - - private static final Pattern JIRA_ID_RE = Pattern.compile("ZEPPELIN-\\d{3,6}"); - private static final Pattern TITLE_FORMATTED_RE = Pattern.compile("^\\[ZEPPELIN-\\d{3,6}](\\[[A-Z0-9_\\s,]+] )+\\S+"); - private static final Pattern TITLE_REF_RE = Pattern.compile("(?i)(ZEPPELIN[-\\s]*\\d{3,6})"); - private static final Pattern COMPONENT_RE = Pattern.compile("(?i)(\\[[\\w\\s,.\\-]+])"); - private static final Pattern WHITESPACE_RE = Pattern.compile("\\s+"); - private static final Pattern LEADING_NON_WORD_RE = Pattern.compile("^\\W+"); - private static final Pattern SEMANTIC_VER_RE = Pattern.compile("^\\d+\\.\\d+\\.\\d+$"); - - // ── Instance fields (parsed from CLI args) ───────────────────────────── - - private final HttpClient http = HttpClient.newHttpClient(); - - private int pr; - private String target = ""; - private List fixVersions = new ArrayList<>(); - private List releaseBranches = new ArrayList<>(); - private boolean resolveJira; - private boolean dryRun; - private String pushRemote; - private String githubToken; - private String jiraToken; - - // ── Constructor & arg parsing ────────────────────────────────────────── - - private MergePr(String[] args) { - pushRemote = envOrDefault("PUSH_REMOTE_NAME", "apache"); - githubToken = envOrDefault("GITHUB_OAUTH_KEY", ""); - jiraToken = envOrDefault("JIRA_ACCESS_TOKEN", ""); - - for (int i = 0; i < args.length; i++) { - switch (args[i]) { - case "--pr": pr = Integer.parseInt(args[++i]); break; - case "--target": target = args[++i]; break; - case "--fix-versions": fixVersions = parseCsv(args[++i]); break; - case "--release-branches": releaseBranches = parseCsv(args[++i]); break; - case "--resolve-jira": resolveJira = true; break; - case "--dry-run": dryRun = true; break; - case "--push-remote": pushRemote = args[++i]; break; - case "--github-token": githubToken = args[++i]; break; - case "--jira-token": jiraToken = args[++i]; break; - case "--help": case "-h": printUsage(); System.exit(0); break; - default: - System.err.println("Unknown flag: " + args[i]); - printUsage(); - System.exit(1); - } - } - } - - private static void printUsage() { - System.err.println("Usage: java dev/merge-pr.java [flags]"); - System.err.println(" --pr int Pull request number (required)"); - System.err.println(" --target string Target branch (default: PR base branch)"); - System.err.println(" --fix-versions value JIRA fix version(s), comma-separated"); - System.err.println(" --release-branches value Release branch(es) to cherry-pick into, comma-separated"); - System.err.println(" --resolve-jira Resolve associated JIRA issue(s)"); - System.err.println(" --dry-run Show what would be done without making changes"); - System.err.println(" --push-remote string Git remote for pushing (default: apache)"); - System.err.println(" --github-token string GitHub OAuth token (env: GITHUB_OAUTH_KEY)"); - System.err.println(" --jira-token string JIRA access token (env: JIRA_ACCESS_TOKEN)"); - } - - private static List parseCsv(String value) { - List result = new ArrayList<>(); - for (String s : value.split(",")) { - String trimmed = s.trim(); - if (!trimmed.isEmpty()) result.add(trimmed); - } - return result; - } - - private static String envOrDefault(String key, String def) { - String v = System.getenv(key); - return (v != null && !v.isEmpty()) ? v : def; - } - - // ── Git ──────────────────────────────────────────────────────────────── - - private String gitRun(String... args) throws Exception { - String[] cmd = new String[args.length + 1]; - cmd[0] = "git"; - System.arraycopy(args, 0, cmd, 1, args.length); - Process p = new ProcessBuilder(cmd).redirectErrorStream(true).start(); - String output; - try (BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()))) { - output = r.lines().collect(Collectors.joining("\n")).trim(); - } - int exit = p.waitFor(); - if (exit != 0) { - throw new RuntimeException("git " + String.join(" ", args) + " failed:\n" + output); - } - return output; - } - - private String gitCurrentRef() throws Exception { - String ref = gitRun("rev-parse", "--abbrev-ref", "HEAD"); - return "HEAD".equals(ref) ? gitRun("rev-parse", "HEAD") : ref; - } - - // ── HTTP ─────────────────────────────────────────────────────────────── - - private HttpResponse httpDo(String method, String url, String body, String auth) - throws Exception { - HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(url)) - .header("Content-Type", "application/json") - .header("Accept", "application/json"); - if (auth != null && !auth.isEmpty()) { - builder.header("Authorization", auth); - } - if (body != null) { - builder.method(method, HttpRequest.BodyPublishers.ofString(body)); - } else { - builder.method(method, HttpRequest.BodyPublishers.noBody()); - } - return http.send(builder.build(), HttpResponse.BodyHandlers.ofString()); - } - - // ── Simple JSON parser (no external deps) ────────────────────────────── - - private static String jsonStr(String json, String key) { - String pattern = "\"" + key + "\"\\s*:\\s*\"([^\"]*?)\""; - Matcher m = Pattern.compile(pattern).matcher(json); - return m.find() ? m.group(1) : ""; - } - - private static boolean jsonBool(String json, String key) { - String pattern = "\"" + key + "\"\\s*:\\s*(true|false)"; - Matcher m = Pattern.compile(pattern).matcher(json); - return m.find() && "true".equals(m.group(1)); - } - - private static String jsonObj(String json, String key) { - String pattern = "\"" + key + "\"\\s*:\\s*\\{"; - Matcher m = Pattern.compile(pattern).matcher(json); - if (!m.find()) return "{}"; - int start = m.end() - 1; - int depth = 0; - for (int i = start; i < json.length(); i++) { - if (json.charAt(i) == '{') depth++; - else if (json.charAt(i) == '}') { depth--; if (depth == 0) return json.substring(start, i + 1); } - } - return "{}"; - } - - private static List jsonArray(String json, String key) { - String pattern = "\"" + key + "\"\\s*:\\s*\\["; - Matcher m = Pattern.compile(pattern).matcher(json); - if (!m.find()) return List.of(); - int start = m.end() - 1; - int depth = 0; - int arrEnd = json.length(); - for (int i = start; i < json.length(); i++) { - if (json.charAt(i) == '[') depth++; - else if (json.charAt(i) == ']') { depth--; if (depth == 0) { arrEnd = i + 1; break; } } - } - return parseObjectArray(json.substring(start, arrEnd)); - } - - private static List parseObjectArray(String arr) { - List items = new ArrayList<>(); - int depth = 0; - int itemStart = -1; - for (int i = 1; i < arr.length() - 1; i++) { - char c = arr.charAt(i); - if (c == '{' && depth == 0) itemStart = i; - if (c == '{') depth++; - if (c == '}') depth--; - if (c == '}' && depth == 0 && itemStart >= 0) { - items.add(arr.substring(itemStart, i + 1)); - itemStart = -1; - } - } - return items; - } - - private static String jsonEscape(String s) { - return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") - .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""; - } - - // ── GitHub ────────────────────────────────────────────────────────────── - - private String ghAuth() { - return githubToken.isEmpty() ? "" : "token " + githubToken; - } - - private String ghGetPR(int num) throws Exception { - HttpResponse r = httpDo("GET", GITHUB_API_BASE + "/pulls/" + num, null, ghAuth()); - if (r.statusCode() != 200) throw new RuntimeException("GET PR #" + num + ": HTTP " + r.statusCode()); - return r.body(); - } - - private String ghMergePR(int num, String title, String msg) throws Exception { - String body = String.format("{\"commit_title\":%s,\"commit_message\":%s,\"merge_method\":\"squash\"}", - jsonEscape(title), jsonEscape(msg)); - HttpResponse r = httpDo("PUT", GITHUB_API_BASE + "/pulls/" + num + "/merge", body, ghAuth()); - if (r.statusCode() == 405) throw new RuntimeException("Merge PR #" + num + " is not allowed"); - if (r.statusCode() != 200) throw new RuntimeException("Merge PR #" + num + ": HTTP " + r.statusCode()); - return r.body(); - } - - private void ghCommentPR(int num, String comment) throws Exception { - String body = String.format("{\"body\":%s}", jsonEscape(comment)); - HttpResponse r = httpDo("POST", GITHUB_API_BASE + "/issues/" + num + "/comments", body, ghAuth()); - if (r.statusCode() != 201) { - System.err.println("Warning: comment PR #" + num + ": HTTP " + r.statusCode()); - } - } - - // ── JIRA ─────────────────────────────────────────────────────────────── - - private String jiraAuth() { - return jiraToken.isEmpty() ? "" : "Bearer " + jiraToken; - } - - private String jiraGetIssue(String key) throws Exception { - HttpResponse r = httpDo("GET", JIRA_API_BASE + "/issue/" + key, null, jiraAuth()); - if (r.statusCode() != 200) throw new RuntimeException("GET " + key + ": HTTP " + r.statusCode()); - return r.body(); - } - - private List> jiraUnreleasedVersions() throws Exception { - HttpResponse r = httpDo("GET", JIRA_API_BASE + "/project/ZEPPELIN/versions", null, jiraAuth()); - if (r.statusCode() != 200) throw new RuntimeException("GET versions: HTTP " + r.statusCode()); - String body = r.body().trim(); - List all = parseObjectArray(body); - List> versions = new ArrayList<>(); - for (String v : all) { - String name = jsonStr(v, "name"); - boolean released = jsonBool(v, "released"); - boolean archived = jsonBool(v, "archived"); - if (!released && !archived && SEMANTIC_VER_RE.matcher(name).matches()) { - Map ver = new HashMap<>(); - ver.put("id", jsonStr(v, "id")); - ver.put("name", name); - versions.add(ver); - } - } - versions.sort((a, b) -> cmpVer(b.get("name"), a.get("name"))); - return versions; - } - - private List> jiraTransitions(String key) throws Exception { - HttpResponse r = httpDo("GET", JIRA_API_BASE + "/issue/" + key + "/transitions", null, jiraAuth()); - if (r.statusCode() != 200) throw new RuntimeException("GET transitions " + key + ": HTTP " + r.statusCode()); - List> result = new ArrayList<>(); - for (String t : jsonArray(r.body(), "transitions")) { - Map tr = new HashMap<>(); - tr.put("id", jsonStr(t, "id")); - tr.put("name", jsonStr(t, "name")); - result.add(tr); - } - return result; - } - - private void jiraResolve(String key, String transitionId, List> fvList, String comment) - throws Exception { - StringBuilder fvJson = new StringBuilder("["); - for (int i = 0; i < fvList.size(); i++) { - if (i > 0) fvJson.append(","); - fvJson.append(String.format("{\"add\":{\"id\":\"%s\",\"name\":\"%s\"}}", - fvList.get(i).get("id"), fvList.get(i).get("name"))); - } - fvJson.append("]"); - String body = String.format( - "{\"transition\":{\"id\":\"%s\"},\"update\":{\"comment\":[{\"add\":{\"body\":%s}}],\"fixVersions\":%s}}", - transitionId, jsonEscape(comment), fvJson); - HttpResponse r = httpDo("POST", JIRA_API_BASE + "/issue/" + key + "/transitions", body, jiraAuth()); - if (r.statusCode() != 204) throw new RuntimeException("Resolve " + key + ": HTTP " + r.statusCode()); - } - - // ── Utilities ────────────────────────────────────────────────────────── - - private static int cmpVer(String a, String b) { - String[] ap = a.split("\\."), bp = b.split("\\."); - for (int i = 0; i < Math.min(ap.length, bp.length); i++) { - int d = Integer.parseInt(ap[i]) - Integer.parseInt(bp[i]); - if (d != 0) return d; - } - return ap.length - bp.length; - } - - private static String standardizeTitle(String text) { - text = text.replaceAll("\\.+$", ""); - if (text.startsWith("Revert \"") && text.endsWith("\"")) return text; - if (TITLE_FORMATTED_RE.matcher(text).find()) return text; - - List jiraRefs = new ArrayList<>(); - Matcher refMatcher = TITLE_REF_RE.matcher(text); - while (refMatcher.find()) { - String ref = refMatcher.group(1); - jiraRefs.add("[" + WHITESPACE_RE.matcher(ref.toUpperCase()).replaceAll("-") + "]"); - text = text.replace(ref, ""); - } - List components = new ArrayList<>(); - Matcher compMatcher = COMPONENT_RE.matcher(text); - while (compMatcher.find()) { - String comp = compMatcher.group(1); - components.add(comp.toUpperCase()); - text = text.replace(comp, ""); - } - text = LEADING_NON_WORD_RE.matcher(text).replaceAll(""); - String result = String.join("", jiraRefs) + String.join("", components) + " " + text; - return WHITESPACE_RE.matcher(result.trim()).replaceAll(" "); - } - - private static String shortSHA(String sha) { - return sha.length() > 8 ? sha.substring(0, 8) : sha; - } - - // ── Fix version inference ────────────────────────────────────────────── - - private List> inferFixVersions(List merged, - List> versions, boolean inferMaster) { - List names = new ArrayList<>(); - LinkedHashSet has = new LinkedHashSet<>(); - for (String branch : merged) { - if ("master".equals(branch)) { - if (inferMaster && !has.contains(versions.get(0).get("name"))) { - String name = versions.get(0).get("name"); - names.add(name); - has.add(name); - } - } else { - String prefix = branch.startsWith("branch-") ? branch.substring(7) : branch; - List found = new ArrayList<>(); - for (Map v : versions) { - String vn = v.get("name"); - if (vn.startsWith(prefix + ".") || vn.equals(prefix)) found.add(vn); - } - if (!found.isEmpty()) { - String pick = found.get(found.size() - 1); - if (!has.contains(pick)) { names.add(pick); has.add(pick); } - } else { - System.err.println("Warning: no version found for " + branch + ", skipping"); - } - } - } - // Remove redundant X.Y.0 when X.(Y-1).0 is also present - List filtered = new ArrayList<>(); - for (String v : names) { - String[] parts = v.split("\\."); - if (parts.length == 3 && "0".equals(parts[2])) { - int minor = Integer.parseInt(parts[1]); - if (minor > 0 && has.contains(parts[0] + "." + (minor - 1) + ".0")) continue; - } - filtered.add(v); - } - Map> vm = new HashMap<>(); - for (Map v : versions) vm.put(v.get("name"), v); - List> result = new ArrayList<>(); - for (String name : filtered) { - if (vm.containsKey(name)) result.add(vm.get(name)); - } - if (!result.isEmpty()) { - System.out.println("Auto-inferred fix version(s): " + String.join(", ", filtered)); - } - return result; - } - - // ── Effective command ────────────────────────────────────────────────── - - private void printEffectiveCommand(String targetBranch, List resolvedVersions) { - List parts = new ArrayList<>(); - parts.add("java dev/merge-pr.java"); - parts.add("--pr " + pr); - if (!targetBranch.isEmpty() && !"master".equals(targetBranch)) parts.add("--target " + targetBranch); - if (!releaseBranches.isEmpty()) parts.add("--release-branches " + String.join(",", releaseBranches)); - if (resolveJira) parts.add("--resolve-jira"); - if (!resolvedVersions.isEmpty()) parts.add("--fix-versions " + String.join(",", resolvedVersions)); - if (!"apache".equals(pushRemote)) parts.add("--push-remote " + pushRemote); - System.out.println("[dry-run] Effective command:\n " + String.join(" ", parts)); - } - - // ── Main flow ────────────────────────────────────────────────────────── - - private void run() throws Exception { - String originalHead = gitCurrentRef(); - - String prJson = ghGetPR(pr); - if (!jsonBool(prJson, "mergeable")) { - throw new RuntimeException("PR #" + pr + " is not mergeable"); - } - String prTitle = jsonStr(prJson, "title"); - if (prTitle.contains("[WIP]")) { - System.err.println("WARNING: PR title contains [WIP]: " + prTitle); - } - - String targetBranch = target.isEmpty() ? jsonStr(jsonObj(prJson, "base"), "ref") : target; - String title = standardizeTitle(prTitle); - String headRef = jsonStr(jsonObj(prJson, "head"), "ref"); - String userLogin = jsonStr(jsonObj(prJson, "user"), "login"); - String src = userLogin + "/" + headRef; - String prBody = jsonStr(prJson, "body"); - - System.out.println("=== Pull Request #" + pr + " ==="); - System.out.println("title: " + title); - System.out.println("source: " + src); - System.out.println("target: " + targetBranch); - System.out.println("url: " + jsonStr(prJson, "url")); - if (!releaseBranches.isEmpty()) { - System.out.println("release-branches: " + String.join(", ", releaseBranches)); - } - - List resolvedFixVersions = resolveFixVersionNames(title, targetBranch); - - if (dryRun) { - System.out.println(); - printEffectiveCommand(targetBranch, resolvedFixVersions); - return; - } - - // Merge - String body = prBody.replace("@", ""); - String name = "", email = ""; - try { name = gitRun("config", "--get", "user.name"); } catch (Exception ignored) {} - try { email = gitRun("config", "--get", "user.email"); } catch (Exception ignored) {} - String msg = body + "\n\nCloses #" + pr + " from " + src + ".\n\nSigned-off-by: " + name + " <" + email + ">"; - - String mergeJson = ghMergePR(pr, title, msg); - String sha = jsonStr(mergeJson, "sha"); - System.out.println("\nPR #" + pr + " merged! (hash: " + shortSHA(sha) + ")"); - - try { gitRun("fetch", pushRemote, targetBranch); } catch (Exception ignored) {} - - // Cherry-pick into release branches - List merged = new ArrayList<>(); - merged.add(targetBranch); - for (String branch : releaseBranches) { - String pick = "PR_TOOL_PICK_PR_" + pr + "_" + branch.toUpperCase(); - try { - gitRun("fetch", pushRemote, branch + ":" + pick); - } catch (Exception e) { - System.err.println("Warning: fetch " + branch + " failed: " + e.getMessage()); - continue; - } - gitRun("checkout", pick); - try { - gitRun("cherry-pick", "-sx", sha); - } catch (Exception e) { - System.err.println("Warning: cherry-pick into " + branch + " failed: " + e.getMessage()); - try { gitRun("cherry-pick", "--abort"); } catch (Exception ignored) {} - gitRun("checkout", originalHead); - gitRun("branch", "-D", pick); - continue; - } - try { - gitRun("push", pushRemote, pick + ":" + branch); - String h = gitRun("rev-parse", pick); - System.out.println("Picked into " + branch + " (hash: " + shortSHA(h) + ")"); - merged.add(branch); - } catch (Exception e) { - System.err.println("Warning: push to " + branch + " failed: " + e.getMessage()); - } - gitRun("checkout", originalHead); - gitRun("branch", "-D", pick); - } - - commentMergeSummary(merged, sha); - - if (resolveJira) { - try { - doResolveJira(title, merged); - } catch (Exception e) { - System.err.println("Warning: JIRA resolution failed: " + e.getMessage()); - } - } - } - - private List resolveFixVersionNames(String title, String targetBranch) { - if (!resolveJira || jiraToken.isEmpty()) return new ArrayList<>(fixVersions); - - List ids = new ArrayList<>(); - Matcher idm = JIRA_ID_RE.matcher(title); - while (idm.find()) ids.add(idm.group()); - if (ids.isEmpty()) return new ArrayList<>(fixVersions); - - try { - List> versions = jiraUnreleasedVersions(); - if (versions.isEmpty()) return new ArrayList<>(fixVersions); - - Map> vm = new HashMap<>(); - for (Map v : versions) vm.put(v.get("name"), v); - - LinkedHashSet resolved = new LinkedHashSet<>(); - for (String fv : fixVersions) { - if (vm.containsKey(fv)) resolved.add(fv); - } - - boolean inferMaster = fixVersions.isEmpty(); - List branches = new ArrayList<>(); - branches.add(targetBranch); - branches.addAll(releaseBranches); - for (Map iv : inferFixVersions(branches, versions, inferMaster)) { - resolved.add(iv.get("name")); - } - return new ArrayList<>(resolved); - } catch (Exception e) { - System.err.println("Warning: failed to fetch JIRA versions: " + e.getMessage()); - return new ArrayList<>(fixVersions); - } - } - - private void commentMergeSummary(List merged, String sha) { - StringBuilder comment = new StringBuilder(); - comment.append("Merged into ").append(merged.get(0)).append(" (").append(shortSHA(sha)).append(")."); - for (int i = 1; i < merged.size(); i++) { - comment.append("\nCherry-picked into ").append(merged.get(i)).append("."); - } - try { - ghCommentPR(pr, comment.toString()); - System.out.println("Commented on PR with merge summary."); - } catch (Exception e) { - System.err.println("Warning: failed to comment on PR: " + e.getMessage()); - } - } - - private void doResolveJira(String title, List merged) throws Exception { - if (jiraToken.isEmpty()) throw new RuntimeException("JIRA_ACCESS_TOKEN is not set"); - - List ids = new ArrayList<>(); - Matcher m = JIRA_ID_RE.matcher(title); - while (m.find()) ids.add(m.group()); - if (ids.isEmpty()) { System.out.println("No JIRA ID found in PR title, skipping."); return; } - - List> versions = jiraUnreleasedVersions(); - - Map> vm = new HashMap<>(); - for (Map v : versions) vm.put(v.get("name"), v); - - List> fixVer = new ArrayList<>(); - LinkedHashSet has = new LinkedHashSet<>(); - for (String fv : fixVersions) { - if (!vm.containsKey(fv)) throw new RuntimeException("fix version \"" + fv + "\" not found"); - fixVer.add(vm.get(fv)); - has.add(fv); - } - if (!versions.isEmpty()) { - boolean inferMaster = fixVersions.isEmpty(); - for (Map iv : inferFixVersions(merged, versions, inferMaster)) { - if (!has.contains(iv.get("name"))) { - fixVer.add(iv); - has.add(iv.get("name")); - } - } - } - - for (String id : ids) { - String issueJson; - try { issueJson = jiraGetIssue(id); } catch (Exception e) { - System.err.println("Warning: get " + id + ": " + e.getMessage()); continue; - } - String status = jsonStr(jsonObj(jsonObj(issueJson, "fields"), "status"), "name"); - if ("Resolved".equals(status) || "Closed".equals(status)) { - System.out.println("JIRA " + id + " already \"" + status + "\", skipping."); - continue; - } - - System.out.println("=== JIRA " + id + " ==="); - System.out.println("Summary: " + jsonStr(jsonObj(issueJson, "fields"), "summary")); - System.out.println("Status: " + status); - - List> transitions = jiraTransitions(id); - String resolveId = null; - for (Map t : transitions) { - if ("Resolve Issue".equals(t.get("name"))) { resolveId = t.get("id"); break; } - } - if (resolveId == null) { - System.err.println("Warning: no 'Resolve Issue' transition for " + id); - continue; - } - - String jiraComment = "Issue resolved by pull request " + pr - + "\n[https://github.com/apache/zeppelin/pull/" + pr + "]"; - try { - jiraResolve(id, resolveId, fixVer, jiraComment); - System.out.println("Resolved " + id + "!"); - } catch (Exception e) { - System.err.println("Warning: resolve " + id + ": " + e.getMessage()); - } - } - } - - // ── Entry point ──────────────────────────────────────────────────────── - - public static void main(String[] args) throws Exception { - MergePr cli = new MergePr(args); - if (cli.pr == 0) { - System.err.println("Error: --pr is required"); - printUsage(); - System.exit(1); - } - cli.run(); - } -} diff --git a/dev/merge_pr.py b/dev/merge_pr.py new file mode 100644 index 00000000000..af08870df62 --- /dev/null +++ b/dev/merge_pr.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +# +# Licensed to the Apache Software Foundation (ASF) under one or more +# contributor license agreements. See the NOTICE file distributed with +# this work for additional information regarding copyright ownership. +# The ASF licenses this file to You under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with +# the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""merge_pr.py - Merge Apache Zeppelin pull requests via the GitHub API. + +Optionally cherry-picks into release branches and resolves JIRA issues. +No external dependencies — uses only Python 3 built-in libraries. + +Usage: + python3 dev/merge_pr.py --pr 5167 --dry-run + python3 dev/merge_pr.py --pr 5167 --resolve-jira --fix-versions 0.13.0 + python3 dev/merge_pr.py --pr 5167 --resolve-jira --release-branches branch-0.12 +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request + +GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin" +JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2" + +JIRA_ID_RE = re.compile(r"ZEPPELIN-\d{3,6}") +TITLE_FORMATTED_RE = re.compile(r"^\[ZEPPELIN-\d{3,6}](\[[A-Z0-9_\s,]+] )+\S+") +TITLE_REF_RE = re.compile(r"(?i)(ZEPPELIN[-\s]*\d{3,6})") +COMPONENT_RE = re.compile(r"(?i)(\[[\w\s,.\-]+])") +WHITESPACE_RE = re.compile(r"\s+") +LEADING_NON_WORD_RE = re.compile(r"^\W+") +SEMANTIC_VER_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +class MergePR: + def __init__(self, args): + self.pr = args.pr + self.target = args.target or "" + self.fix_versions = _parse_csv(args.fix_versions) if args.fix_versions else [] + self.release_branches = _parse_csv(args.release_branches) if args.release_branches else [] + self.resolve_jira = args.resolve_jira + self.dry_run = args.dry_run + self.push_remote = args.push_remote or os.environ.get("PUSH_REMOTE_NAME", "apache") + self.github_token = args.github_token or os.environ.get("GITHUB_OAUTH_KEY", "") + self.jira_token = args.jira_token or os.environ.get("JIRA_ACCESS_TOKEN", "") + + # ── Git ────────────────────────────────────────────────────────────── + + def _git(self, *args): + result = subprocess.run( + ["git"] + list(args), + capture_output=True, text=True, + ) + if result.returncode != 0: + output = (result.stdout + result.stderr).strip() + raise RuntimeError(f"git {' '.join(args)} failed:\n{output}") + return result.stdout.strip() + + def _git_current_ref(self): + ref = self._git("rev-parse", "--abbrev-ref", "HEAD") + return self._git("rev-parse", "HEAD") if ref == "HEAD" else ref + + # ── HTTP ───────────────────────────────────────────────────────────── + + def _http(self, method, url, body=None, auth=""): + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header("Content-Type", "application/json") + req.add_header("Accept", "application/json") + if auth: + req.add_header("Authorization", auth) + try: + with urllib.request.urlopen(req) as resp: + return resp.status, json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + body_text = e.read().decode() if e.fp else "" + try: + return e.code, json.loads(body_text) + except json.JSONDecodeError: + return e.code, {"error": body_text} + + # ── GitHub ─────────────────────────────────────────────────────────── + + def _gh_auth(self): + return f"token {self.github_token}" if self.github_token else "" + + def _gh_get_pr(self, num): + code, data = self._http("GET", f"{GITHUB_API_BASE}/pulls/{num}", auth=self._gh_auth()) + if code != 200: + raise RuntimeError(f"GET PR #{num}: HTTP {code}") + return data + + def _gh_merge_pr(self, num, title, msg): + body = {"commit_title": title, "commit_message": msg, "merge_method": "squash"} + code, data = self._http("PUT", f"{GITHUB_API_BASE}/pulls/{num}/merge", body, self._gh_auth()) + if code == 405: + raise RuntimeError(f"Merge PR #{num} is not allowed") + if code != 200: + raise RuntimeError(f"Merge PR #{num}: HTTP {code}") + return data + + def _gh_comment_pr(self, num, comment): + code, _ = self._http("POST", f"{GITHUB_API_BASE}/issues/{num}/comments", + {"body": comment}, self._gh_auth()) + if code != 201: + print(f"Warning: comment PR #{num}: HTTP {code}", file=sys.stderr) + + # ── JIRA ───────────────────────────────────────────────────────────── + + def _jira_auth(self): + return f"Bearer {self.jira_token}" if self.jira_token else "" + + def _jira_get_issue(self, key): + code, data = self._http("GET", f"{JIRA_API_BASE}/issue/{key}", auth=self._jira_auth()) + if code != 200: + raise RuntimeError(f"GET {key}: HTTP {code}") + return data + + def _jira_unreleased_versions(self): + code, data = self._http("GET", f"{JIRA_API_BASE}/project/ZEPPELIN/versions", auth=self._jira_auth()) + if code != 200: + raise RuntimeError(f"GET versions: HTTP {code}") + versions = [] + for v in data: + name = v.get("name", "") + if not v.get("released") and not v.get("archived") and SEMANTIC_VER_RE.match(name): + versions.append({"id": str(v["id"]), "name": name}) + versions.sort(key=lambda v: _ver_tuple(v["name"]), reverse=True) + return versions + + def _jira_transitions(self, key): + code, data = self._http("GET", f"{JIRA_API_BASE}/issue/{key}/transitions", auth=self._jira_auth()) + if code != 200: + raise RuntimeError(f"GET transitions {key}: HTTP {code}") + return [{"id": t["id"], "name": t["name"]} for t in data.get("transitions", [])] + + def _jira_resolve(self, key, transition_id, fix_ver, comment): + body = { + "transition": {"id": transition_id}, + "update": { + "comment": [{"add": {"body": comment}}], + "fixVersions": [{"add": {"id": fv["id"], "name": fv["name"]}} for fv in fix_ver], + }, + } + code, _ = self._http("POST", f"{JIRA_API_BASE}/issue/{key}/transitions", body, self._jira_auth()) + if code != 204: + raise RuntimeError(f"Resolve {key}: HTTP {code}") + + # ── Fix version inference ──────────────────────────────────────────── + + def _infer_fix_versions(self, merged, versions, infer_master): + names, seen = [], set() + for branch in merged: + if branch == "master": + if infer_master and versions[0]["name"] not in seen: + names.append(versions[0]["name"]) + seen.add(versions[0]["name"]) + else: + prefix = branch[len("branch-"):] if branch.startswith("branch-") else branch + found = [v["name"] for v in versions if v["name"].startswith(prefix + ".") or v["name"] == prefix] + if found: + pick = found[-1] # smallest matching (list is desc-sorted) + if pick not in seen: + names.append(pick) + seen.add(pick) + else: + print(f"Warning: no version found for {branch}, skipping", file=sys.stderr) + + # Remove redundant X.Y.0 when X.(Y-1).0 is also present + filtered = [] + for v in names: + parts = v.split(".") + if len(parts) == 3 and parts[2] == "0": + minor = int(parts[1]) + if minor > 0 and f"{parts[0]}.{minor - 1}.0" in seen: + continue + filtered.append(v) + + vm = {v["name"]: v for v in versions} + result = [vm[n] for n in filtered if n in vm] + if result: + print(f"Auto-inferred fix version(s): {', '.join(filtered)}") + return result + + # ── Effective command ──────────────────────────────────────────────── + + def _print_effective_command(self, target_branch, resolved_versions): + parts = ["python3 dev/merge_pr.py", f"--pr {self.pr}"] + if target_branch and target_branch != "master": + parts.append(f"--target {target_branch}") + if self.release_branches: + parts.append(f"--release-branches {','.join(self.release_branches)}") + if self.resolve_jira: + parts.append("--resolve-jira") + if resolved_versions: + parts.append(f"--fix-versions {','.join(resolved_versions)}") + if self.push_remote != "apache": + parts.append(f"--push-remote {self.push_remote}") + print(f"[dry-run] Effective command:\n {' '.join(parts)}") + + # ── Main flow ──────────────────────────────────────────────────────── + + def run(self): + original_head = self._git_current_ref() + + pr_data = self._gh_get_pr(self.pr) + if not pr_data.get("mergeable"): + raise RuntimeError(f"PR #{self.pr} is not mergeable") + pr_title = pr_data["title"] + if "[WIP]" in pr_title: + print(f"WARNING: PR title contains [WIP]: {pr_title}", file=sys.stderr) + + target_branch = self.target or pr_data["base"]["ref"] + title = _standardize_title(pr_title) + src = f"{pr_data['user']['login']}/{pr_data['head']['ref']}" + pr_body = pr_data.get("body", "") or "" + + print(f"=== Pull Request #{self.pr} ===") + print(f"title: {title}") + print(f"source: {src}") + print(f"target: {target_branch}") + print(f"url: {pr_data['url']}") + if self.release_branches: + print(f"release-branches: {', '.join(self.release_branches)}") + + resolved_fix_versions = self._resolve_fix_version_names(title, target_branch) + + if self.dry_run: + print() + self._print_effective_command(target_branch, resolved_fix_versions) + return + + # Merge + body = pr_body.replace("@", "") + try: + name = self._git("config", "--get", "user.name") + except RuntimeError: + name = "" + try: + email = self._git("config", "--get", "user.email") + except RuntimeError: + email = "" + msg = f"{body}\n\nCloses #{self.pr} from {src}.\n\nSigned-off-by: {name} <{email}>" + + merge_data = self._gh_merge_pr(self.pr, title, msg) + sha = merge_data["sha"] + print(f"\nPR #{self.pr} merged! (hash: {_short_sha(sha)})") + + try: + self._git("fetch", self.push_remote, target_branch) + except RuntimeError: + pass + + # Cherry-pick into release branches + merged = [target_branch] + for branch in self.release_branches: + pick = f"PR_TOOL_PICK_PR_{self.pr}_{branch.upper()}" + try: + self._git("fetch", self.push_remote, f"{branch}:{pick}") + except RuntimeError as e: + print(f"Warning: fetch {branch} failed: {e}", file=sys.stderr) + continue + self._git("checkout", pick) + try: + self._git("cherry-pick", "-sx", sha) + except RuntimeError as e: + print(f"Warning: cherry-pick into {branch} failed: {e}", file=sys.stderr) + try: + self._git("cherry-pick", "--abort") + except RuntimeError: + pass + self._git("checkout", original_head) + self._git("branch", "-D", pick) + continue + try: + self._git("push", self.push_remote, f"{pick}:{branch}") + h = self._git("rev-parse", pick) + print(f"Picked into {branch} (hash: {_short_sha(h)})") + merged.append(branch) + except RuntimeError as e: + print(f"Warning: push to {branch} failed: {e}", file=sys.stderr) + self._git("checkout", original_head) + self._git("branch", "-D", pick) + + self._comment_merge_summary(merged, sha) + + if self.resolve_jira: + try: + self._do_resolve_jira(title, merged) + except RuntimeError as e: + print(f"Warning: JIRA resolution failed: {e}", file=sys.stderr) + + def _resolve_fix_version_names(self, title, target_branch): + if not self.resolve_jira or not self.jira_token: + return list(self.fix_versions) + + ids = JIRA_ID_RE.findall(title) + if not ids: + return list(self.fix_versions) + + try: + versions = self._jira_unreleased_versions() + if not versions: + return list(self.fix_versions) + + vm = {v["name"]: v for v in versions} + resolved = list(dict.fromkeys(fv for fv in self.fix_versions if fv in vm)) + + infer_master = not self.fix_versions + branches = [target_branch] + self.release_branches + for iv in self._infer_fix_versions(branches, versions, infer_master): + if iv["name"] not in resolved: + resolved.append(iv["name"]) + return resolved + except RuntimeError as e: + print(f"Warning: failed to fetch JIRA versions: {e}", file=sys.stderr) + return list(self.fix_versions) + + def _comment_merge_summary(self, merged, sha): + lines = [f"Merged into {merged[0]} ({_short_sha(sha)})."] + for branch in merged[1:]: + lines.append(f"Cherry-picked into {branch}.") + try: + self._gh_comment_pr(self.pr, "\n".join(lines)) + print("Commented on PR with merge summary.") + except RuntimeError as e: + print(f"Warning: failed to comment on PR: {e}", file=sys.stderr) + + def _do_resolve_jira(self, title, merged): + if not self.jira_token: + raise RuntimeError("JIRA_ACCESS_TOKEN is not set") + + ids = JIRA_ID_RE.findall(title) + if not ids: + print("No JIRA ID found in PR title, skipping.") + return + + versions = self._jira_unreleased_versions() + vm = {v["name"]: v for v in versions} + + fix_ver, seen = [], set() + for fv in self.fix_versions: + if fv not in vm: + raise RuntimeError(f'fix version "{fv}" not found') + fix_ver.append(vm[fv]) + seen.add(fv) + if versions: + infer_master = not self.fix_versions + for iv in self._infer_fix_versions(merged, versions, infer_master): + if iv["name"] not in seen: + fix_ver.append(iv) + seen.add(iv["name"]) + + for jira_id in ids: + try: + issue = self._jira_get_issue(jira_id) + except RuntimeError as e: + print(f"Warning: get {jira_id}: {e}", file=sys.stderr) + continue + status = issue.get("fields", {}).get("status", {}).get("name", "") + if status in ("Resolved", "Closed"): + print(f'JIRA {jira_id} already "{status}", skipping.') + continue + + print(f"=== JIRA {jira_id} ===") + print(f"Summary: {issue.get('fields', {}).get('summary', '')}") + print(f"Status: {status}") + + transitions = self._jira_transitions(jira_id) + resolve_id = next((t["id"] for t in transitions if t["name"] == "Resolve Issue"), None) + if not resolve_id: + print(f"Warning: no 'Resolve Issue' transition for {jira_id}", file=sys.stderr) + continue + + jira_comment = ( + f"Issue resolved by pull request {self.pr}" + f"\n[https://github.com/apache/zeppelin/pull/{self.pr}]" + ) + try: + self._jira_resolve(jira_id, resolve_id, fix_ver, jira_comment) + print(f"Resolved {jira_id}!") + except RuntimeError as e: + print(f"Warning: resolve {jira_id}: {e}", file=sys.stderr) + + +# ── Module-level utilities ─────────────────────────────────────────────── + +def _parse_csv(value): + return [s.strip() for s in value.split(",") if s.strip()] if value else [] + + +def _ver_tuple(v): + return tuple(int(x) for x in v.split(".")) + + +def _short_sha(sha): + return sha[:8] if len(sha) > 8 else sha + + +def _standardize_title(text): + text = text.rstrip(".") + if text.startswith('Revert "') and text.endswith('"'): + return text + if TITLE_FORMATTED_RE.match(text): + return text + + jira_refs = [] + for m in TITLE_REF_RE.finditer(text): + ref = m.group(1) + jira_refs.append("[" + WHITESPACE_RE.sub("-", ref.upper()) + "]") + text = text.replace(ref, "") + + components = [] + for m in COMPONENT_RE.finditer(text): + comp = m.group(1) + components.append(comp.upper()) + text = text.replace(comp, "") + + text = LEADING_NON_WORD_RE.sub("", text) + result = "".join(jira_refs) + "".join(components) + " " + text + return WHITESPACE_RE.sub(" ", result.strip()) + + +# ── Entry point ────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Merge Apache Zeppelin pull requests", + usage="python3 dev/merge_pr.py [flags]", + ) + parser.add_argument("--pr", type=int, required=True, help="Pull request number") + parser.add_argument("--target", default="", help="Target branch (default: PR base branch)") + parser.add_argument("--fix-versions", default="", help="JIRA fix version(s), comma-separated") + parser.add_argument("--release-branches", default="", help="Release branch(es) to cherry-pick into, comma-separated") + parser.add_argument("--resolve-jira", action="store_true", help="Resolve associated JIRA issue(s)") + parser.add_argument("--dry-run", action="store_true", help="Show what would be done without making changes") + parser.add_argument("--push-remote", default="", help="Git remote for pushing (default: apache)") + parser.add_argument("--github-token", default="", help="GitHub OAuth token (env: GITHUB_OAUTH_KEY)") + parser.add_argument("--jira-token", default="", help="JIRA access token (env: JIRA_ACCESS_TOKEN)") + + args = parser.parse_args() + MergePR(args).run() + + +if __name__ == "__main__": + main() From 85f63fed12cd4c66cc0dc32a187af0963d90558f Mon Sep 17 00:00:00 2001 From: Jongyoul Lee Date: Tue, 17 Mar 2026 17:21:52 +0900 Subject: [PATCH 18/18] [ZEPPELIN-6404] Simplify merge_pr.py: deduplicate JIRA calls, extract constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Resolve fix versions once and pass to both dry-run and JIRA resolution - Unify _resolve_fix_version_names and _do_resolve_jira fix-version logic into single _resolve_fix_versions method - Extract constants: DEFAULT_BRANCH, DEFAULT_REMOTE, JIRA_RESOLVE_TRANSITION, JIRA_CLOSED_STATUSES - Use try/finally in cherry-pick loop to guarantee HEAD restoration - Extract _pick_branch_name helper - Rename _http param body→payload to avoid shadowing Co-Authored-By: Claude Opus 4.6 --- dev/merge_pr.py | 165 ++++++++++++++++++++++-------------------------- 1 file changed, 76 insertions(+), 89 deletions(-) diff --git a/dev/merge_pr.py b/dev/merge_pr.py index af08870df62..ef87dad4d12 100644 --- a/dev/merge_pr.py +++ b/dev/merge_pr.py @@ -39,6 +39,11 @@ GITHUB_API_BASE = "https://api.github.com/repos/apache/zeppelin" JIRA_API_BASE = "https://issues.apache.org/jira/rest/api/2" +DEFAULT_BRANCH = "master" +DEFAULT_REMOTE = "apache" +JIRA_RESOLVE_TRANSITION = "Resolve Issue" +JIRA_CLOSED_STATUSES = frozenset(("Resolved", "Closed")) + JIRA_ID_RE = re.compile(r"ZEPPELIN-\d{3,6}") TITLE_FORMATTED_RE = re.compile(r"^\[ZEPPELIN-\d{3,6}](\[[A-Z0-9_\s,]+] )+\S+") TITLE_REF_RE = re.compile(r"(?i)(ZEPPELIN[-\s]*\d{3,6})") @@ -56,7 +61,7 @@ def __init__(self, args): self.release_branches = _parse_csv(args.release_branches) if args.release_branches else [] self.resolve_jira = args.resolve_jira self.dry_run = args.dry_run - self.push_remote = args.push_remote or os.environ.get("PUSH_REMOTE_NAME", "apache") + self.push_remote = args.push_remote or os.environ.get("PUSH_REMOTE_NAME", DEFAULT_REMOTE) self.github_token = args.github_token or os.environ.get("GITHUB_OAUTH_KEY", "") self.jira_token = args.jira_token or os.environ.get("JIRA_ACCESS_TOKEN", "") @@ -64,7 +69,7 @@ def __init__(self, args): def _git(self, *args): result = subprocess.run( - ["git"] + list(args), + ["git", *args], capture_output=True, text=True, ) if result.returncode != 0: @@ -78,8 +83,8 @@ def _git_current_ref(self): # ── HTTP ───────────────────────────────────────────────────────────── - def _http(self, method, url, body=None, auth=""): - data = json.dumps(body).encode() if body is not None else None + def _http(self, method, url, payload=None, auth=""): + data = json.dumps(payload).encode() if payload is not None else None req = urllib.request.Request(url, data=data, method=method) req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") @@ -89,11 +94,11 @@ def _http(self, method, url, body=None, auth=""): with urllib.request.urlopen(req) as resp: return resp.status, json.loads(resp.read().decode()) except urllib.error.HTTPError as e: - body_text = e.read().decode() if e.fp else "" + err_body = e.read().decode() if e.fp else "" try: - return e.code, json.loads(body_text) + return e.code, json.loads(err_body) except json.JSONDecodeError: - return e.code, {"error": body_text} + return e.code, {"error": err_body} # ── GitHub ─────────────────────────────────────────────────────────── @@ -107,8 +112,8 @@ def _gh_get_pr(self, num): return data def _gh_merge_pr(self, num, title, msg): - body = {"commit_title": title, "commit_message": msg, "merge_method": "squash"} - code, data = self._http("PUT", f"{GITHUB_API_BASE}/pulls/{num}/merge", body, self._gh_auth()) + payload = {"commit_title": title, "commit_message": msg, "merge_method": "squash"} + code, data = self._http("PUT", f"{GITHUB_API_BASE}/pulls/{num}/merge", payload, self._gh_auth()) if code == 405: raise RuntimeError(f"Merge PR #{num} is not allowed") if code != 200: @@ -151,26 +156,42 @@ def _jira_transitions(self, key): return [{"id": t["id"], "name": t["name"]} for t in data.get("transitions", [])] def _jira_resolve(self, key, transition_id, fix_ver, comment): - body = { + payload = { "transition": {"id": transition_id}, "update": { "comment": [{"add": {"body": comment}}], "fixVersions": [{"add": {"id": fv["id"], "name": fv["name"]}} for fv in fix_ver], }, } - code, _ = self._http("POST", f"{JIRA_API_BASE}/issue/{key}/transitions", body, self._jira_auth()) + code, _ = self._http("POST", f"{JIRA_API_BASE}/issue/{key}/transitions", payload, self._jira_auth()) if code != 204: raise RuntimeError(f"Resolve {key}: HTTP {code}") - # ── Fix version inference ──────────────────────────────────────────── + # ── Fix version resolution ─────────────────────────────────────────── + + def _resolve_fix_versions(self, branches, versions): + """Resolve fix version objects from explicit --fix-versions and branch inference. + + Returns a list of version dicts ({"id": ..., "name": ...}). + Raises RuntimeError if an explicit fix version is not found. + """ + vm = {v["name"]: v for v in versions} + fix_ver, seen = [], set() + + for fv in self.fix_versions: + if fv not in vm: + raise RuntimeError(f'fix version "{fv}" not found') + fix_ver.append(vm[fv]) + seen.add(fv) - def _infer_fix_versions(self, merged, versions, infer_master): - names, seen = [], set() - for branch in merged: - if branch == "master": - if infer_master and versions[0]["name"] not in seen: - names.append(versions[0]["name"]) - seen.add(versions[0]["name"]) + infer_master = not self.fix_versions + latest = versions[0]["name"] + names = [] + for branch in branches: + if branch == DEFAULT_BRANCH: + if infer_master and latest not in seen: + names.append(latest) + seen.add(latest) else: prefix = branch[len("branch-"):] if branch.startswith("branch-") else branch found = [v["name"] for v in versions if v["name"].startswith(prefix + ".") or v["name"] == prefix] @@ -192,25 +213,25 @@ def _infer_fix_versions(self, merged, versions, infer_master): continue filtered.append(v) - vm = {v["name"]: v for v in versions} - result = [vm[n] for n in filtered if n in vm] - if result: + inferred = [vm[n] for n in filtered if n in vm] + if inferred: print(f"Auto-inferred fix version(s): {', '.join(filtered)}") - return result + fix_ver.extend(inferred) + return fix_ver # ── Effective command ──────────────────────────────────────────────── - def _print_effective_command(self, target_branch, resolved_versions): + def _print_effective_command(self, target_branch, fix_ver): parts = ["python3 dev/merge_pr.py", f"--pr {self.pr}"] - if target_branch and target_branch != "master": + if target_branch and target_branch != DEFAULT_BRANCH: parts.append(f"--target {target_branch}") if self.release_branches: parts.append(f"--release-branches {','.join(self.release_branches)}") if self.resolve_jira: parts.append("--resolve-jira") - if resolved_versions: - parts.append(f"--fix-versions {','.join(resolved_versions)}") - if self.push_remote != "apache": + if fix_ver: + parts.append(f"--fix-versions {','.join(fv['name'] for fv in fix_ver)}") + if self.push_remote != DEFAULT_REMOTE: parts.append(f"--push-remote {self.push_remote}") print(f"[dry-run] Effective command:\n {' '.join(parts)}") @@ -239,11 +260,20 @@ def run(self): if self.release_branches: print(f"release-branches: {', '.join(self.release_branches)}") - resolved_fix_versions = self._resolve_fix_version_names(title, target_branch) + # Resolve fix versions once (used for both dry-run display and actual JIRA resolution) + fix_ver = [] + if self.resolve_jira and self.jira_token and JIRA_ID_RE.search(title): + try: + versions = self._jira_unreleased_versions() + if versions: + branches = [target_branch] + self.release_branches + fix_ver = self._resolve_fix_versions(branches, versions) + except RuntimeError as e: + print(f"Warning: failed to resolve fix versions: {e}", file=sys.stderr) if self.dry_run: print() - self._print_effective_command(target_branch, resolved_fix_versions) + self._print_effective_command(target_branch, fix_ver) return # Merge @@ -270,7 +300,7 @@ def run(self): # Cherry-pick into release branches merged = [target_branch] for branch in self.release_branches: - pick = f"PR_TOOL_PICK_PR_{self.pr}_{branch.upper()}" + pick = _pick_branch_name(self.pr, branch) try: self._git("fetch", self.push_remote, f"{branch}:{pick}") except RuntimeError as e: @@ -279,59 +309,28 @@ def run(self): self._git("checkout", pick) try: self._git("cherry-pick", "-sx", sha) + self._git("push", self.push_remote, f"{pick}:{branch}") + h = self._git("rev-parse", pick) + print(f"Picked into {branch} (hash: {_short_sha(h)})") + merged.append(branch) except RuntimeError as e: - print(f"Warning: cherry-pick into {branch} failed: {e}", file=sys.stderr) + print(f"Warning: cherry-pick/push into {branch} failed: {e}", file=sys.stderr) try: self._git("cherry-pick", "--abort") except RuntimeError: pass + finally: self._git("checkout", original_head) self._git("branch", "-D", pick) - continue - try: - self._git("push", self.push_remote, f"{pick}:{branch}") - h = self._git("rev-parse", pick) - print(f"Picked into {branch} (hash: {_short_sha(h)})") - merged.append(branch) - except RuntimeError as e: - print(f"Warning: push to {branch} failed: {e}", file=sys.stderr) - self._git("checkout", original_head) - self._git("branch", "-D", pick) self._comment_merge_summary(merged, sha) if self.resolve_jira: try: - self._do_resolve_jira(title, merged) + self._do_resolve_jira(title, fix_ver) except RuntimeError as e: print(f"Warning: JIRA resolution failed: {e}", file=sys.stderr) - def _resolve_fix_version_names(self, title, target_branch): - if not self.resolve_jira or not self.jira_token: - return list(self.fix_versions) - - ids = JIRA_ID_RE.findall(title) - if not ids: - return list(self.fix_versions) - - try: - versions = self._jira_unreleased_versions() - if not versions: - return list(self.fix_versions) - - vm = {v["name"]: v for v in versions} - resolved = list(dict.fromkeys(fv for fv in self.fix_versions if fv in vm)) - - infer_master = not self.fix_versions - branches = [target_branch] + self.release_branches - for iv in self._infer_fix_versions(branches, versions, infer_master): - if iv["name"] not in resolved: - resolved.append(iv["name"]) - return resolved - except RuntimeError as e: - print(f"Warning: failed to fetch JIRA versions: {e}", file=sys.stderr) - return list(self.fix_versions) - def _comment_merge_summary(self, merged, sha): lines = [f"Merged into {merged[0]} ({_short_sha(sha)})."] for branch in merged[1:]: @@ -342,7 +341,7 @@ def _comment_merge_summary(self, merged, sha): except RuntimeError as e: print(f"Warning: failed to comment on PR: {e}", file=sys.stderr) - def _do_resolve_jira(self, title, merged): + def _do_resolve_jira(self, title, fix_ver): if not self.jira_token: raise RuntimeError("JIRA_ACCESS_TOKEN is not set") @@ -351,22 +350,6 @@ def _do_resolve_jira(self, title, merged): print("No JIRA ID found in PR title, skipping.") return - versions = self._jira_unreleased_versions() - vm = {v["name"]: v for v in versions} - - fix_ver, seen = [], set() - for fv in self.fix_versions: - if fv not in vm: - raise RuntimeError(f'fix version "{fv}" not found') - fix_ver.append(vm[fv]) - seen.add(fv) - if versions: - infer_master = not self.fix_versions - for iv in self._infer_fix_versions(merged, versions, infer_master): - if iv["name"] not in seen: - fix_ver.append(iv) - seen.add(iv["name"]) - for jira_id in ids: try: issue = self._jira_get_issue(jira_id) @@ -374,7 +357,7 @@ def _do_resolve_jira(self, title, merged): print(f"Warning: get {jira_id}: {e}", file=sys.stderr) continue status = issue.get("fields", {}).get("status", {}).get("name", "") - if status in ("Resolved", "Closed"): + if status in JIRA_CLOSED_STATUSES: print(f'JIRA {jira_id} already "{status}", skipping.') continue @@ -383,9 +366,9 @@ def _do_resolve_jira(self, title, merged): print(f"Status: {status}") transitions = self._jira_transitions(jira_id) - resolve_id = next((t["id"] for t in transitions if t["name"] == "Resolve Issue"), None) + resolve_id = next((t["id"] for t in transitions if t["name"] == JIRA_RESOLVE_TRANSITION), None) if not resolve_id: - print(f"Warning: no 'Resolve Issue' transition for {jira_id}", file=sys.stderr) + print(f"Warning: no '{JIRA_RESOLVE_TRANSITION}' transition for {jira_id}", file=sys.stderr) continue jira_comment = ( @@ -413,6 +396,10 @@ def _short_sha(sha): return sha[:8] if len(sha) > 8 else sha +def _pick_branch_name(pr_num, branch): + return f"PR_TOOL_PICK_PR_{pr_num}_{branch.upper()}" + + def _standardize_title(text): text = text.rstrip(".") if text.startswith('Revert "') and text.endswith('"'):