xref: /freebsd/.github/workflows/checklist.yml (revision b1bebaaba9b9c0ddfe503c43ca8e9e3917ee2c57)
1name: Checklist
2
3# Produce a list of things that need to be changed
4# for the submission to align with CONTRIBUTING.md
5
6on:
7  pull_request_target:
8    types: [ opened, reopened, edited, synchronize ]
9
10permissions:
11  pull-requests: write
12
13jobs:
14  checklist:
15    name: commit
16    if: github.repository == 'freebsd/freebsd-src'
17    runs-on: ubuntu-latest
18    steps:
19      - uses: actions/github-script@v7
20        with:
21          # An asynchronous javascript function
22          script: |
23            /*
24             * Github's API returns the results in pages of 30, so
25             * pass the function we want, along with it's arguments,
26             * to paginate() which will handle gathering all the results.
27             */
28            const comments = await github.paginate(github.rest.issues.listComments, {
29              owner: context.repo.owner,
30              repo: context.repo.repo,
31              issue_number: context.issue.number
32            });
33
34            const commits = await github.paginate(github.rest.pulls.listCommits, {
35              owner: context.repo.owner,
36              repo: context.repo.repo,
37              pull_number: context.issue.number
38            });
39
40            /* Get owners */
41
42            let owners = [];
43            const { data: ownerData } = await github.rest.repos.getContent({
44              owner: context.repo.owner,
45              repo: context.repo.repo,
46              path: '.github/CODEOWNERS',
47              ref: context.payload.pull_request.base.ref // Or a specific branch
48            });
49            const oc = Buffer.from(ownerData.content, 'base64').toString();
50            owners = oc.split(/\r?\n/)
51              .map(line => line.trim())
52              // Filter out comments and empty lines
53              .filter(line => line && !line.startsWith('#'))
54              .map(line => {
55                // Split by the first block of whitespace to separate path and message
56                 const [path, ...ownerParts] = line.substring(1).split(/\s+/);
57                 return { path, owner: ownerParts.join(' ') };
58            });
59
60            /* Get rules -- maybe refactor to a function for ownerPath too */
61            let rules = [];
62            const { data: rulesData } = await github.rest.repos.getContent({
63              owner: context.repo.owner,
64              repo: context.repo.repo,
65              path: '.github/path-rules.txt',
66              ref: context.payload.pull_request.base.ref // Or a specific branch
67            });
68            const rc = Buffer.from(rulesData.content, 'base64').toString();
69            rules = rc.split(/\r?\n/)
70              .map(line => line.trim())
71              // Filter out comments and empty lines
72              .filter(line => line && !line.startsWith('#'))
73              .map(line => {
74                // Split by the first block of whitespace to separate path and message
75                const [path, ...messageParts] = line.split(/\s+/);
76                return { path, message: messageParts.join(' ') };
77            });
78
79            let checklist = {};
80            let checklist_len = 0;
81            let comment_id = -1;
82
83            const addToChecklist = (msg, sha) => {
84              if (!checklist[msg]) {
85                checklist[msg] = [];
86                checklist_len++;
87              }
88              checklist[msg].push(sha);
89            }
90
91            for (const commit of commits) {
92              const sob_lines = commit.commit.message.match(/^[^\S\r\n]*signed-off-by:.*/gim);
93
94              if (sob_lines == null && !commit.commit.author.email.toLowerCase().endsWith("freebsd.org"))
95                addToChecklist("Missing Signed-off-by lines", commit.sha);
96              else if (sob_lines != null) {
97                let author_signed = false;
98                for (const line of sob_lines) {
99                  if (!line.includes("Signed-off-by: "))
100                    /* Only display the part we care about. */
101                    addToChecklist("Expected `Signed-off-by: `, got `" + line.match(/^[^\S\r\n]*signed-off-by:./i) + "`", commit.sha);
102                  if (line.includes(commit.commit.author.email))
103                    author_signed = true;
104                }
105
106                if (!author_signed)
107                  console.log("::warning title=Missing-Author-Signature::Missing Signed-off-by from author");
108              }
109
110              if (commit.commit.author.email.toLowerCase().includes("noreply"))
111                addToChecklist("Real email address is needed", commit.sha);
112            }
113
114            /* Check for different paths that have issues and/or owners */
115            const { data: files } = await github.rest.pulls.listFiles({
116              owner: context.repo.owner,
117              repo: context.repo.repo,
118              pull_number: context.payload.pull_request.number,
119            });
120
121            let infolist = {};
122            let infolist_len = 0;
123            const addToInfolist = (msg) => {
124              if (!infolist[msg]) {
125                infolist[msg] = [];
126                infolist_len++;
127              }
128            }
129
130            /* Give advice based on what's in the commit */
131            for (const file of files) {
132              for (const owner of owners) {
133                if (file.filename.startsWith(owner.path)) {
134                  addToInfolist("> [!IMPORTANT]\n> " + owner.owner + " wants to review changes to " + owner.path + "\n");
135                }
136              }
137              for (const rule of rules) {
138                // Consider regexp in the future maybe?
139                if (file.filename.startsWith(rule.path)) {
140                  if (rule.message.startsWith(":caution: ")) {
141                    addToInfolist("> [!CAUTION]\n> " + rule.path + ": " + rule.message.substring(10) + "\n");
142                  } else if (rule.message.startsWith(":warning: ")) {
143                    addToInfolist("> [!WARNING]\n> " + rule.path + ": " + rule.message.substring(10) + "\n");
144                  } else {
145                    addToInfolist("> [!IMPORTANT]\n> " + rule.path + ": " + rule.message + "\n");
146                  }
147                }
148              }
149            }
150
151            /* Check if we've commented before. */
152            for (const comment of comments) {
153              if (comment.user.login == "github-actions[bot]") {
154                comment_id = comment.id;
155                break;
156              }
157            }
158
159            const msg_prefix = "Thank you for taking the time to contribute to FreeBSD!\n\n";
160            if (checklist_len != 0 || infolist_len != 0) {
161              let msg = msg_prefix;
162              let comment_func = comment_id == -1 ? github.rest.issues.createComment : github.rest.issues.updateComment;
163              if (checklist_len != 0) {
164                msg +=
165                  "There " + (checklist_len > 1 ? "are a few issues that need " : "is an issue that needs ") +
166                  "to be resolved:\n";
167
168                /* Loop for each key in "checklist". */
169                for (const c in checklist)
170                  msg += "- " + c + " (" + checklist[c].join(", ") + ")\n";
171                msg += "\n> [!NOTE]\n> Please review [CONTRIBUTING.md](https://github.com/freebsd/freebsd-src/blob/main/CONTRIBUTING.md), then update and push your branch again.\n\n"
172              } else {
173                let msg = "No Issues found.\n\n";
174              }
175              if (infolist_len != 0) {
176                msg += "Some of files have special handling:\n"
177                for (const i in infolist)
178                  msg += i + "\n";
179                msg += "\n\n";
180              }
181              comment_func({
182                owner: context.repo.owner,
183                repo: context.repo.repo,
184                body: msg,
185                ...(comment_id == -1 ? {issue_number: context.issue.number} : {comment_id: comment_id})
186              });
187            } else if (comment_id != -1) {
188              github.rest.issues.updateComment({
189                owner: context.repo.owner,
190                repo: context.repo.repo,
191                comment_id: comment_id,
192                body: msg_prefix + "All issues resolved."
193              });
194            }
195