1// Copyright 2024 CUE Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package main
16
17import (
18 "bytes"
19 "fmt"
20 "log"
21 "os"
22 "os/exec"
23 "regexp"
24 "slices"
25 "strings"
26)
27
28func main() {
29 wd, err := os.Getwd()
30 if err != nil {
31 log.Fatal(err)
32 }
33 if err := checkCommit(wd); err != nil {
34 log.Fatal(err)
35 }
36}
37
38func checkCommit(dir string) error {
39 body, err := runCmd(dir, "git", "log", "-1", "--format=%B", "HEAD")
40 if err != nil {
41 return err
42 }
43
44 // Ensure that commit messages have a blank second line.
45 // We know that a commit message must be longer than a single
46 // line because each commit must be signed-off.
47 lines := strings.Split(body, "\n")
48 if len(lines) > 1 && lines[1] != "" {
49 return fmt.Errorf("the second line of a commit message must be blank")
50 }
51
52 // All authors, including co-authors, must have a signed-off trailer by email.
53 // Note that trailers are in the form "Name <email>", so grab the email with regexp.
54 // For now, we require the sorted lists of author and signer emails to match.
55 // Note that this also fails if a commit isn't signed-off at all.
56 //
57 // In Gerrit we already enable a form of this via https://gerrit-review.googlesource.com/Documentation/project-configuration.html#require-signed-off-by,
58 // but it does not support co-authors nor can it be used when testing GitHub PRs.
59 authorEmail, err := runCmd(dir, "git", "log", "-1", "--format=%ae")
60 if err != nil {
61 return err
62 }
63 coauthorList, err := runCmd(dir, "git", "log", "-1", "--format=%(trailers:key=Co-authored-by,valueonly)")
64 if err != nil {
65 return err
66 }
67 authors := slices.Concat([]string{authorEmail}, extractEmails(coauthorList))
68 slices.Sort(authors)
69 authors = slices.Compact(authors)
70
71 signerList, err := runCmd(dir, "git", "log", "-1", "--format=%(trailers:key=Signed-off-by,valueonly)")
72 if err != nil {
73 return err
74 }
75 signers := extractEmails(signerList)
76 slices.Sort(signers)
77 signers = slices.Compact(signers)
78
79 if !slices.Equal(authors, signers) {
80 return fmt.Errorf("commit author email addresses %q do not match signed-off-by trailers %q",
81 authors, signers)
82 }
83 return nil
84}
85
86func runCmd(dir string, exe string, args ...string) (string, error) {
87 cmd := exec.Command(exe, args...)
88 cmd.Dir = dir
89 out, err := cmd.CombinedOutput()
90 return string(bytes.TrimSpace(out)), err
91}
92
93var (
94 rxExtractEmail = regexp.MustCompile(`.*<(.*)\>$`)
95 rxUserMention = regexp.MustCompile(`(^|\s)(@[a-z0-9][a-z0-9-]*)`)
96)
97
98func extractEmails(list string) []string {
99 lines := strings.Split(list, "\n")
100 var emails []string
101 for _, line := range lines {
102 m := rxExtractEmail.FindStringSubmatch(line)
103 if m == nil {
104 continue // no match; discard this line
105 }
106 emails = append(emails, m[1])
107 }
108 return emails
109}