lgtm.yml 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. # If someone with reviewer access comments "/lgtm" on a pull request, add lgtm label
  2. name: LGTM Command
  3. on:
  4. issue_comment:
  5. types: [created]
  6. permissions:
  7. contents: read
  8. jobs:
  9. lgtm-command:
  10. permissions:
  11. pull-requests: write # for peter-evans/slash-command-dispatch to create PR reaction
  12. issues: write # for adding labels and comments
  13. contents: read # for reading CODEOWNERS.md
  14. runs-on: ubuntu-latest
  15. # Only run for PRs, not issue comments
  16. if: ${{ github.event.issue.pull_request }}
  17. steps:
  18. # Checkout repo to access CODEOWNERS.md
  19. - name: Checkout repository
  20. uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
  21. with:
  22. sparse-checkout: |
  23. CODEOWNERS.md
  24. # Generate a GitHub App installation access token
  25. - name: Generate token
  26. id: generate_token
  27. uses: actions/create-github-app-token@67018539274d69449ef7c02e8e71183d1719ab42 # v2.1.4
  28. with:
  29. app-id: ${{ secrets.LGTM_APP_ID }}
  30. private-key: ${{ secrets.LGTM_PRIVATE_KEY }}
  31. owner: ${{ github.repository_owner }}
  32. - name: Slash Command Dispatch
  33. uses: peter-evans/slash-command-dispatch@13bc09769d122a64f75aa5037256f6f2d78be8c4 # v4.0.0
  34. with:
  35. token: ${{ steps.generate_token.outputs.token }}
  36. reaction-token: ${{ secrets.GITHUB_TOKEN }}
  37. issue-type: pull-request
  38. commands: lgtm
  39. permission: none # anyone can use the command, but permissions are checked in the workflow itself.
  40. - name: Process LGTM Command
  41. if: ${{ github.event.comment.body == '/lgtm' }}
  42. uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v7
  43. with:
  44. github-token: ${{ steps.generate_token.outputs.token }}
  45. script: |
  46. const organization = 'external-secrets';
  47. const lgtmLabelName = 'lgtm';
  48. const fs = require('fs');
  49. const commenter = context.payload.comment.user.login;
  50. const prNumber = context.payload.issue.number;
  51. const owner = context.repo.owner;
  52. const repo = context.repo.repo;
  53. // Parse CODEOWNERS.md file
  54. let codeownersContent;
  55. try {
  56. codeownersContent = fs.readFileSync('CODEOWNERS.md', 'utf8');
  57. } catch (error) {
  58. return;
  59. }
  60. // Extract role mappings from CODEOWNERS.md (including * pattern)
  61. const codeownerMappings = [];
  62. let wildcardRoles = [];
  63. codeownersContent.split('\n').forEach(line => {
  64. const trimmed = line.trim();
  65. if (!trimmed || trimmed.startsWith('#')) return;
  66. const match = trimmed.match(/^(\S+)\s+(.+)$/);
  67. if (match) {
  68. const [, pattern, roles] = match;
  69. const rolesList = roles.split(/\s+/).filter(r => r.startsWith('@'));
  70. if (pattern === '*') {
  71. wildcardRoles = rolesList;
  72. } else {
  73. codeownerMappings.push({
  74. pattern,
  75. roles: rolesList
  76. });
  77. }
  78. }
  79. });
  80. // Extract maintainer roles from wildcardRoles
  81. const maintainerRoles = wildcardRoles.map(role => role.replace(`@${organization}/`, ''));
  82. // Early check: if user is a maintainer, approve immediately
  83. let isMaintainer = false;
  84. for (const role of maintainerRoles) {
  85. try {
  86. const response = await github.rest.teams.getMembershipForUserInOrg({
  87. org: organization,
  88. team_slug: role,
  89. username: commenter
  90. });
  91. if (response.data.state === 'active') {
  92. isMaintainer = true;
  93. break;
  94. }
  95. } catch (error) {
  96. // User not in this team, continue checking others
  97. }
  98. }
  99. if (isMaintainer) {
  100. // Check if LGTM label already exists
  101. const labels = await github.rest.issues.listLabelsOnIssue({
  102. owner, repo, issue_number: prNumber
  103. });
  104. if (!labels.data.some(l => l.name === lgtmLabelName)) {
  105. await github.rest.issues.addLabels({
  106. owner, repo, issue_number: prNumber, labels: [lgtmLabelName]
  107. });
  108. }
  109. // Simple confirmation for maintainers (no role mentions)
  110. await github.rest.issues.createComment({
  111. owner, repo, issue_number: prNumber,
  112. body: `✅ LGTM by @${commenter} (maintainer)`
  113. });
  114. return;
  115. }
  116. // Get changed files in PR
  117. const filesResponse = await github.rest.pulls.listFiles({
  118. owner, repo, pull_number: prNumber
  119. });
  120. const changedFiles = filesResponse.data.map(f => f.filename);
  121. // Find all required reviewer roles for the changed files
  122. // This includes hierarchical matching (e.g., pkg/provider/ matches pkg/provider/aws/file.go)
  123. const requiredReviewerRoles = new Set();
  124. let hasFilesWithoutSpecificOwners = false;
  125. changedFiles.forEach(file => {
  126. let hasSpecificOwner = false;
  127. codeownerMappings.forEach(mapping => {
  128. const { pattern, roles } = mapping;
  129. // Match pattern (handle both exact matches and directory patterns)
  130. // This handles hierarchical matching where broader patterns can cover more specific paths
  131. if (file === pattern ||
  132. file.startsWith(pattern.endsWith('/') ? pattern : pattern + '/')) {
  133. roles.forEach(role => requiredReviewerRoles.add(role));
  134. hasSpecificOwner = true;
  135. }
  136. });
  137. if (!hasSpecificOwner) {
  138. hasFilesWithoutSpecificOwners = true;
  139. }
  140. });
  141. // For files that only match the wildcard pattern, add wildcard roles
  142. if (hasFilesWithoutSpecificOwners) {
  143. wildcardRoles.forEach(role => {
  144. requiredReviewerRoles.add(role);
  145. });
  146. }
  147. // Find commenter's matching reviewer roles
  148. const commenterReviewerRoles = new Set();
  149. for (const role of requiredReviewerRoles) {
  150. const roleSlug = role.replace(`@${organization}/`, '');
  151. try {
  152. const response = await github.rest.teams.getMembershipForUserInOrg({
  153. org: organization,
  154. team_slug: roleSlug,
  155. username: commenter
  156. });
  157. if (response.data.state === 'active') {
  158. commenterReviewerRoles.add(role);
  159. }
  160. } catch (error) {
  161. // User not in this role, continue checking others
  162. }
  163. }
  164. // Check if user has any required reviewer role
  165. if (commenterReviewerRoles.size === 0) {
  166. const rolesList = Array.from(requiredReviewerRoles).map(role => role.replace(`@${organization}/`, '')).join(', ');
  167. await github.rest.issues.createComment({
  168. owner, repo, issue_number: prNumber,
  169. body: `@${commenter} You must be a member of one of the required reviewer roles to use /lgtm.\n\nRequired roles for this PR: ${rolesList}`
  170. });
  171. return;
  172. }
  173. // Check if LGTM label already exists
  174. const labels = await github.rest.issues.listLabelsOnIssue({
  175. owner, repo, issue_number: prNumber
  176. });
  177. if (!labels.data.some(l => l.name === lgtmLabelName)) {
  178. await github.rest.issues.addLabels({
  179. owner, repo, issue_number: prNumber, labels: [lgtmLabelName]
  180. });
  181. // Added LGTM label
  182. }
  183. // Build confirmation message with role coverage info
  184. const mentionRoles = wildcardRoles.join(' ');
  185. let confirmationMessage = `${mentionRoles}\n\n✅ LGTM by @${commenter}`;
  186. // Check for truly uncovered roles using pattern hierarchy
  187. const uncoveredRoles = [];
  188. for (const requiredRole of requiredReviewerRoles) {
  189. let isCovered = commenterReviewerRoles.has(requiredRole);
  190. // If not directly covered, check if any commenter role has a pattern that covers this required role's pattern
  191. if (!isCovered) {
  192. // Find the patterns for this required role
  193. const requiredRolePatterns = codeownerMappings
  194. .filter(mapping => mapping.roles.includes(requiredRole))
  195. .map(mapping => mapping.pattern);
  196. // Check if any commenter role has a pattern that is a parent of any required role pattern
  197. for (const commenterRole of commenterReviewerRoles) {
  198. const commenterRolePatterns = codeownerMappings
  199. .filter(mapping => mapping.roles.includes(commenterRole))
  200. .map(mapping => mapping.pattern);
  201. // Check if any commenter pattern is a parent of any required pattern
  202. for (const commenterPattern of commenterRolePatterns) {
  203. for (const requiredPattern of requiredRolePatterns) {
  204. const commenterPath = commenterPattern.endsWith('/') ? commenterPattern : commenterPattern + '/';
  205. const requiredPath = requiredPattern.endsWith('/') ? requiredPattern : requiredPattern + '/';
  206. // If required pattern starts with commenter pattern, it's hierarchically covered
  207. if (requiredPath.startsWith(commenterPath) && commenterPath !== requiredPath) {
  208. isCovered = true;
  209. break;
  210. }
  211. }
  212. if (isCovered) break;
  213. }
  214. if (isCovered) break;
  215. }
  216. }
  217. if (!isCovered) {
  218. uncoveredRoles.push(requiredRole);
  219. }
  220. }
  221. // Show role coverage analysis
  222. confirmationMessage += `\n\n**Review Coverage:**`;
  223. if (uncoveredRoles.length > 0) {
  224. confirmationMessage += `\n- Commenter has roles:`;
  225. Array.from(commenterReviewerRoles).forEach(role => {
  226. const roleName = role.replace(`@${organization}/`, '');
  227. confirmationMessage += `\n - ${roleName}`;
  228. });
  229. confirmationMessage += `\n- Required roles:`;
  230. Array.from(requiredReviewerRoles).forEach(role => {
  231. const roleName = role.replace(`@${organization}/`, '');
  232. confirmationMessage += `\n - ${roleName}`;
  233. });
  234. confirmationMessage += `\n- ❌ Additional review may be needed by:`;
  235. uncoveredRoles.forEach(role => {
  236. const roleName = role.replace(`@${organization}/`, '');
  237. confirmationMessage += `\n - ${roleName}`;
  238. });
  239. } else {
  240. confirmationMessage += `\n- ✅ All required roles covered`;
  241. }
  242. await github.rest.issues.createComment({
  243. owner, repo, issue_number: prNumber,
  244. body: confirmationMessage
  245. });