lgtm-processor.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. /**
  2. * LGTM Command Processor
  3. *
  4. * Processes /lgtm comments on pull requests. Checks if the commenter has the
  5. * required reviewer role(s) based on CODEOWNERS.md, then adds the lgtm label
  6. * and posts a confirmation comment.
  7. *
  8. * @param {object} params
  9. * @param {object} params.core - @actions/core
  10. * @param {object} params.github - @actions/github Octokit instance
  11. * @param {object} params.context - GitHub Actions context
  12. * @param {object} params.fs - Node.js fs module
  13. */
  14. export default async function run({ core, github, context, fs }) {
  15. const organization = 'external-secrets';
  16. const lgtmLabelName = 'lgtm';
  17. const commenter = context.payload.comment.user.login;
  18. const prNumber = context.payload.issue.number;
  19. const owner = context.repo.owner;
  20. const repo = context.repo.repo;
  21. // Parse CODEOWNERS.md file
  22. let codeownersContent;
  23. try {
  24. codeownersContent = fs.readFileSync('CODEOWNERS.md', 'utf8');
  25. } catch (error) {
  26. return;
  27. }
  28. // Extract role mappings from CODEOWNERS.md (including * pattern)
  29. const codeownerMappings = [];
  30. let wildcardRoles = [];
  31. codeownersContent.split('\n').forEach(line => {
  32. const trimmed = line.trim();
  33. if (!trimmed || trimmed.startsWith('#')) return;
  34. const match = trimmed.match(/^(\S+)\s+(.+)$/);
  35. if (match) {
  36. const [, pattern, roles] = match;
  37. const rolesList = roles.split(/\s+/).filter(r => r.startsWith('@'));
  38. if (pattern === '*') {
  39. wildcardRoles = rolesList;
  40. } else {
  41. codeownerMappings.push({
  42. pattern,
  43. roles: rolesList
  44. });
  45. }
  46. }
  47. });
  48. // Extract maintainer roles from wildcardRoles
  49. const maintainerRoles = wildcardRoles.map(role => role.replace(`@${organization}/`, ''));
  50. // Early check: if user is a maintainer, approve immediately
  51. let isMaintainer = false;
  52. for (const role of maintainerRoles) {
  53. try {
  54. const response = await github.rest.teams.getMembershipForUserInOrg({
  55. org: organization,
  56. team_slug: role,
  57. username: commenter
  58. });
  59. if (response.data.state === 'active') {
  60. isMaintainer = true;
  61. break;
  62. }
  63. } catch (error) {
  64. // User not in this team, continue checking others
  65. }
  66. }
  67. if (isMaintainer) {
  68. // Check if LGTM label already exists
  69. const labels = await github.rest.issues.listLabelsOnIssue({
  70. owner, repo, issue_number: prNumber
  71. });
  72. if (!labels.data.some(l => l.name === lgtmLabelName)) {
  73. await github.rest.issues.addLabels({
  74. owner, repo, issue_number: prNumber, labels: [lgtmLabelName]
  75. });
  76. }
  77. // Simple confirmation for maintainers (no role mentions)
  78. await github.rest.issues.createComment({
  79. owner, repo, issue_number: prNumber,
  80. body: `✅ LGTM by @${commenter} (maintainer)`
  81. });
  82. return;
  83. }
  84. // Get changed files in PR
  85. const filesResponse = await github.rest.pulls.listFiles({
  86. owner, repo, pull_number: prNumber
  87. });
  88. const changedFiles = filesResponse.data.map(f => f.filename);
  89. // Find all required reviewer roles for the changed files
  90. // This includes hierarchical matching (e.g., pkg/provider/ matches pkg/provider/aws/file.go)
  91. const requiredReviewerRoles = new Set();
  92. let hasFilesWithoutSpecificOwners = false;
  93. changedFiles.forEach(file => {
  94. let hasSpecificOwner = false;
  95. codeownerMappings.forEach(mapping => {
  96. const { pattern, roles } = mapping;
  97. // Match pattern (handle both exact matches and directory patterns)
  98. // This handles hierarchical matching where broader patterns can cover more specific paths
  99. if (file === pattern ||
  100. file.startsWith(pattern.endsWith('/') ? pattern : pattern + '/')) {
  101. roles.forEach(role => requiredReviewerRoles.add(role));
  102. hasSpecificOwner = true;
  103. }
  104. });
  105. if (!hasSpecificOwner) {
  106. hasFilesWithoutSpecificOwners = true;
  107. }
  108. });
  109. // For files that only match the wildcard pattern, add wildcard roles
  110. if (hasFilesWithoutSpecificOwners) {
  111. wildcardRoles.forEach(role => {
  112. requiredReviewerRoles.add(role);
  113. });
  114. }
  115. // Find commenter's matching reviewer roles
  116. const commenterReviewerRoles = new Set();
  117. for (const role of requiredReviewerRoles) {
  118. const roleSlug = role.replace(`@${organization}/`, '');
  119. try {
  120. const response = await github.rest.teams.getMembershipForUserInOrg({
  121. org: organization,
  122. team_slug: roleSlug,
  123. username: commenter
  124. });
  125. if (response.data.state === 'active') {
  126. commenterReviewerRoles.add(role);
  127. }
  128. } catch (error) {
  129. // User not in this role, continue checking others
  130. }
  131. }
  132. // Check if user has any required reviewer role
  133. if (commenterReviewerRoles.size === 0) {
  134. const rolesList = Array.from(requiredReviewerRoles).map(role => role.replace(`@${organization}/`, '')).join(', ');
  135. await github.rest.issues.createComment({
  136. owner, repo, issue_number: prNumber,
  137. body: `@${commenter} You must be a member of one of the required reviewer roles to use /lgtm.\n\nRequired roles for this PR: ${rolesList}`
  138. });
  139. return;
  140. }
  141. // Check if LGTM label already exists
  142. const labels = await github.rest.issues.listLabelsOnIssue({
  143. owner, repo, issue_number: prNumber
  144. });
  145. if (!labels.data.some(l => l.name === lgtmLabelName)) {
  146. await github.rest.issues.addLabels({
  147. owner, repo, issue_number: prNumber, labels: [lgtmLabelName]
  148. });
  149. // Added LGTM label
  150. }
  151. // Build confirmation message with role coverage info
  152. const mentionRoles = wildcardRoles.join(' ');
  153. let confirmationMessage = `${mentionRoles}\n\n✅ LGTM by @${commenter}`;
  154. // Check for truly uncovered roles using pattern hierarchy
  155. const uncoveredRoles = [];
  156. for (const requiredRole of requiredReviewerRoles) {
  157. let isCovered = commenterReviewerRoles.has(requiredRole);
  158. // If not directly covered, check if any commenter role has a pattern that covers this required role's pattern
  159. if (!isCovered) {
  160. // Find the patterns for this required role
  161. const requiredRolePatterns = codeownerMappings
  162. .filter(mapping => mapping.roles.includes(requiredRole))
  163. .map(mapping => mapping.pattern);
  164. // Check if any commenter role has a pattern that is a parent of any required role pattern
  165. for (const commenterRole of commenterReviewerRoles) {
  166. const commenterRolePatterns = codeownerMappings
  167. .filter(mapping => mapping.roles.includes(commenterRole))
  168. .map(mapping => mapping.pattern);
  169. // Check if any commenter pattern is a parent of any required pattern
  170. for (const commenterPattern of commenterRolePatterns) {
  171. for (const requiredPattern of requiredRolePatterns) {
  172. const commenterPath = commenterPattern.endsWith('/') ? commenterPattern : commenterPattern + '/';
  173. const requiredPath = requiredPattern.endsWith('/') ? requiredPattern : requiredPattern + '/';
  174. // If required pattern starts with commenter pattern, it's hierarchically covered
  175. if (requiredPath.startsWith(commenterPath) && commenterPath !== requiredPath) {
  176. isCovered = true;
  177. break;
  178. }
  179. }
  180. if (isCovered) break;
  181. }
  182. if (isCovered) break;
  183. }
  184. }
  185. if (!isCovered) {
  186. uncoveredRoles.push(requiredRole);
  187. }
  188. }
  189. // Show role coverage analysis
  190. confirmationMessage += `\n\n**Review Coverage:**`;
  191. if (uncoveredRoles.length > 0) {
  192. confirmationMessage += `\n- Commenter has roles:`;
  193. Array.from(commenterReviewerRoles).forEach(role => {
  194. const roleName = role.replace(`@${organization}/`, '');
  195. confirmationMessage += `\n - ${roleName}`;
  196. });
  197. confirmationMessage += `\n- Required roles:`;
  198. Array.from(requiredReviewerRoles).forEach(role => {
  199. const roleName = role.replace(`@${organization}/`, '');
  200. confirmationMessage += `\n - ${roleName}`;
  201. });
  202. confirmationMessage += `\n- ❌ Additional review may be needed by:`;
  203. uncoveredRoles.forEach(role => {
  204. const roleName = role.replace(`@${organization}/`, '');
  205. confirmationMessage += `\n - ${roleName}`;
  206. });
  207. } else {
  208. confirmationMessage += `\n- ✅ All required roles covered`;
  209. }
  210. await github.rest.issues.createComment({
  211. owner, repo, issue_number: prNumber,
  212. body: confirmationMessage
  213. });
  214. }