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  }