github.com/juju/juju@v0.0.0-20240430160146-1752b71fcf00/scripts/try-merge/main.go (about) 1 // Copyright 2023 Canonical Ltd. 2 // Licensed under the AGPLv3, see LICENCE file for details. 3 4 package main 5 6 import ( 7 "encoding/json" 8 "fmt" 9 "os" 10 "strings" 11 "text/template" 12 13 "github.com/juju/collections/set" 14 ) 15 16 // Environment variables to configure script 17 var ( 18 sourceBranch string // branch containing changes (e.g. 2.9) 19 targetBranch string // branch to merge into (e.g. 3.1) 20 gitDir string // location of checked out branch. Git commands will be run here 21 22 emailToMMUser map[string]string // mapping of email address -> Mattermost username 23 ignoreEmails set.Strings // email addresses to ignore (e.g. bot accounts) 24 ) 25 26 func main() { 27 // Get configuration from environment 28 sourceBranch = os.Getenv("SOURCE_BRANCH") 29 targetBranch = os.Getenv("TARGET_BRANCH") 30 gitDir = os.Getenv("GIT_DIR") 31 fillEmailToMMUserMap() 32 fillIgnoreEmails() 33 34 if len(os.Args) < 2 { 35 fatalf("no command provided\n") 36 } 37 switch cmd := os.Args[1]; cmd { 38 // TODO: migrate the merging logic from merge.yml to here 39 //case "try-merge": 40 // tryMerge() 41 case "errmsg": 42 printErrMsg() 43 default: 44 fatalf("unrecognised command %q\n", cmd) 45 } 46 } 47 48 // Get the contents of the EMAIL_TO_MM_USER environment variable, which is 49 // a JSON mapping of email addresses to Mattermost usernames. Parse this into 50 // the emailToMMUser map. 51 func fillEmailToMMUserMap() { 52 emailToMMUser = map[string]string{} 53 jsonMap := os.Getenv("EMAIL_TO_MM_USER") 54 err := json.Unmarshal([]byte(jsonMap), &emailToMMUser) 55 if err != nil { 56 // No need to fail - we can still use the commit author name. 57 // Just log a warning. 58 stderrf("WARNING: couldn't parse EMAIL_TO_MM_USER: %v\n", err) 59 } 60 } 61 62 // Get the contents of the IGNORE_EMAILS environment variable, which is 63 // a JSON list of email addresses to ignore / not notify. Parse this into 64 // the ignoreEmails set. 65 func fillIgnoreEmails() { 66 jsonList := os.Getenv("IGNORE_EMAILS") 67 68 var ignoreEmailsList []string 69 err := json.Unmarshal([]byte(jsonList), &ignoreEmailsList) 70 if err != nil { 71 // No need to fail here 72 stderrf("WARNING: couldn't parse IGNORE_EMAILS: %v\n", err) 73 } 74 75 ignoreEmails = set.NewStrings(ignoreEmailsList...) 76 } 77 78 // After a failed merge, generate a nice notification message that will be 79 // sent to Mattermost. 80 func printErrMsg() { 81 // Check required env variables are set 82 if sourceBranch == "" { 83 fatalf("fatal: SOURCE_BRANCH not set\n") 84 } 85 if targetBranch == "" { 86 fatalf("fatal: TARGET_BRANCH not set\n") 87 } 88 89 badCommits := findOffendingCommits() 90 91 // Iterate through commits and find people to notify 92 peopleToNotify := set.NewStrings() 93 for _, commit := range badCommits { 94 if ignoreEmails.Contains(commit.CommitterEmail) { 95 stderrf("DEBUG: skipping commit %s: committer on ignore list\n", commit.SHA) 96 continue 97 } 98 if num, ok := commitHasOpenPR(commit); ok { 99 stderrf("DEBUG: skipping commit %s: has open PR #%d\n", commit.SHA, num) 100 continue 101 } 102 103 _, ok := emailToMMUser[commit.CommitterEmail] 104 if ok { 105 peopleToNotify.Add("@" + emailToMMUser[commit.CommitterEmail]) 106 } else { 107 // Don't have a username for this email - just use commit author name 108 stderrf("WARNING: no MM username found for email %q\n", commit.CommitterEmail) 109 peopleToNotify.Add(commit.CommitterName) 110 } 111 } 112 113 if !peopleToNotify.IsEmpty() { 114 printMessage(peopleToNotify) 115 } 116 } 117 118 // findOffendingCommits returns a list of commits that may be causing merge 119 // conflicts. This only works if Git is currently inside a failed merge. 120 func findOffendingCommits() []commitInfo { 121 // Call `git log` to get commit info 122 gitLogRes := execute(executeArgs{ 123 command: "git", 124 args: []string{"log", 125 // Restrict to commits which are present in source branch, but not target 126 fmt.Sprintf("%s..%s", targetBranch, sourceBranch), 127 "--merge", // show refs that touch files having a conflict 128 "--no-merges", // ignore merge commits 129 "--format=" + gitLogJSONFormat, 130 }, 131 dir: gitDir, 132 }) 133 handleExecuteError(gitLogRes) 134 stderrf("DEBUG: offending commits are\n%s\n", gitLogRes.stdout) 135 gitLogInfo := gitLogOutputToValidJSON(gitLogRes.stdout) 136 137 var commits []commitInfo 138 check(json.Unmarshal(gitLogInfo, &commits)) 139 return commits 140 } 141 142 var gitLogJSONFormat = `{"sha":"%H","authorName":"%an","authorEmail":"%ae","committerName":"%cn","committerEmail":"%ce"}` 143 144 // Transforms the output of `git log` into a valid JSON array. 145 func gitLogOutputToValidJSON(raw []byte) []byte { 146 rawString := string(raw) 147 lines := strings.Split(rawString, "\n") 148 // Remove empty last line 149 filteredLines := lines[:len(lines)-1] 150 joinedLines := strings.Join(filteredLines, ",") 151 array := "[" + joinedLines + "]" 152 return []byte(array) 153 } 154 155 type commitInfo struct { 156 SHA string `json:"sha"` 157 AuthorName string `json:"authorName"` 158 AuthorEmail string `json:"authorEmail"` 159 CommitterName string `json:"committerName"` 160 CommitterEmail string `json:"committerEmail"` 161 } 162 163 type prInfo struct { 164 Number int `json:"number"` 165 State string `json:"state"` 166 } 167 168 // Check if there is already an open merge containing this commit. If so, 169 // we don't need to notify. 170 func commitHasOpenPR(commit commitInfo) (prNumber int, ok bool) { 171 ghRes := execute(executeArgs{ 172 command: "gh", 173 args: []string{"pr", "list", 174 "--search", commit.SHA, 175 "--state", "all", 176 "--base", targetBranch, 177 "--json", "number,state", 178 }, 179 }) 180 handleExecuteError(ghRes) 181 182 prList := []prInfo{} 183 check(json.Unmarshal(ghRes.stdout, &prList)) 184 185 for _, pr := range prList { 186 // Check for merged PRs, just in case the merge PR landed while we've been 187 // checking for conflicts. 188 if pr.State == "OPEN" || pr.State == "MERGED" { 189 return pr.Number, true 190 } 191 } 192 return -1, false 193 } 194 195 func printMessage(peopleToNotify set.Strings) { 196 messageData := struct{ TaggedUsers, SourceBranch, TargetBranch, LogsLink string }{ 197 TaggedUsers: strings.Join(peopleToNotify.Values(), ", "), 198 SourceBranch: sourceBranch, 199 TargetBranch: targetBranch, 200 LogsLink: fmt.Sprintf("https://github.com/%s/actions/runs/%s", 201 os.Getenv("GITHUB_REPOSITORY"), os.Getenv("GITHUB_RUN_ID")), 202 } 203 204 tmpl, err := template.New("test").Parse( 205 "{{.TaggedUsers}}: your recent changes to `{{.SourceBranch}}` have caused merge conflicts. " + 206 "Please merge `{{.SourceBranch}}` into `{{.TargetBranch}}` and resolve the conflicts. " + 207 "[[logs]({{.LogsLink}})]", 208 ) 209 check(err) 210 check(tmpl.Execute(os.Stdout, messageData)) 211 } 212 213 func check(err error) { 214 if err != nil { 215 stderrf("%#v\n", err) 216 panic(err) 217 } 218 } 219 220 // Print to stderr. Logging/debug info should go here, so that it is kept 221 // separate from the actual output. 222 func stderrf(f string, v ...any) { 223 _, _ = fmt.Fprintf(os.Stderr, f, v...) 224 } 225 226 // Print to stderr and then exit. 227 func fatalf(f string, v ...any) { 228 stderrf(f, v...) 229 os.Exit(1) 230 }