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