| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245 |
- /**
- * LGTM Command Processor
- *
- * Processes /lgtm comments on pull requests. Checks if the commenter has the
- * required reviewer role(s) based on CODEOWNERS.md, then adds the lgtm label
- * and posts a confirmation comment.
- *
- * @param {object} params
- * @param {object} params.core - @actions/core
- * @param {object} params.github - @actions/github Octokit instance
- * @param {object} params.context - GitHub Actions context
- * @param {object} params.fs - Node.js fs module
- */
- export default async function run({ core, github, context, fs }) {
- const organization = 'external-secrets';
- const lgtmLabelName = 'lgtm';
- const commenter = context.payload.comment.user.login;
- const prNumber = context.payload.issue.number;
- const owner = context.repo.owner;
- const repo = context.repo.repo;
- // Parse CODEOWNERS.md file
- let codeownersContent;
- try {
- codeownersContent = fs.readFileSync('CODEOWNERS.md', 'utf8');
- } catch (error) {
- return;
- }
- // Extract role mappings from CODEOWNERS.md (including * pattern)
- const codeownerMappings = [];
- let wildcardRoles = [];
- codeownersContent.split('\n').forEach(line => {
- const trimmed = line.trim();
- if (!trimmed || trimmed.startsWith('#')) return;
- const match = trimmed.match(/^(\S+)\s+(.+)$/);
- if (match) {
- const [, pattern, roles] = match;
- const rolesList = roles.split(/\s+/).filter(r => r.startsWith('@'));
- if (pattern === '*') {
- wildcardRoles = rolesList;
- } else {
- codeownerMappings.push({
- pattern,
- roles: rolesList
- });
- }
- }
- });
- // Extract maintainer roles from wildcardRoles
- const maintainerRoles = wildcardRoles.map(role => role.replace(`@${organization}/`, ''));
- // Early check: if user is a maintainer, approve immediately
- let isMaintainer = false;
- for (const role of maintainerRoles) {
- try {
- const response = await github.rest.teams.getMembershipForUserInOrg({
- org: organization,
- team_slug: role,
- username: commenter
- });
- if (response.data.state === 'active') {
- isMaintainer = true;
- break;
- }
- } catch (error) {
- // User not in this team, continue checking others
- }
- }
- if (isMaintainer) {
- // Check if LGTM label already exists
- const labels = await github.rest.issues.listLabelsOnIssue({
- owner, repo, issue_number: prNumber
- });
- if (!labels.data.some(l => l.name === lgtmLabelName)) {
- await github.rest.issues.addLabels({
- owner, repo, issue_number: prNumber, labels: [lgtmLabelName]
- });
- }
- // Simple confirmation for maintainers (no role mentions)
- await github.rest.issues.createComment({
- owner, repo, issue_number: prNumber,
- body: `✅ LGTM by @${commenter} (maintainer)`
- });
- return;
- }
- // Get changed files in PR
- const filesResponse = await github.rest.pulls.listFiles({
- owner, repo, pull_number: prNumber
- });
- const changedFiles = filesResponse.data.map(f => f.filename);
- // Find all required reviewer roles for the changed files
- // This includes hierarchical matching (e.g., pkg/provider/ matches pkg/provider/aws/file.go)
- const requiredReviewerRoles = new Set();
- let hasFilesWithoutSpecificOwners = false;
- changedFiles.forEach(file => {
- let hasSpecificOwner = false;
- codeownerMappings.forEach(mapping => {
- const { pattern, roles } = mapping;
- // Match pattern (handle both exact matches and directory patterns)
- // This handles hierarchical matching where broader patterns can cover more specific paths
- if (file === pattern ||
- file.startsWith(pattern.endsWith('/') ? pattern : pattern + '/')) {
- roles.forEach(role => requiredReviewerRoles.add(role));
- hasSpecificOwner = true;
- }
- });
- if (!hasSpecificOwner) {
- hasFilesWithoutSpecificOwners = true;
- }
- });
- // For files that only match the wildcard pattern, add wildcard roles
- if (hasFilesWithoutSpecificOwners) {
- wildcardRoles.forEach(role => {
- requiredReviewerRoles.add(role);
- });
- }
- // Find commenter's matching reviewer roles
- const commenterReviewerRoles = new Set();
- for (const role of requiredReviewerRoles) {
- const roleSlug = role.replace(`@${organization}/`, '');
-
- try {
- const response = await github.rest.teams.getMembershipForUserInOrg({
- org: organization,
- team_slug: roleSlug,
- username: commenter
- });
- if (response.data.state === 'active') {
- commenterReviewerRoles.add(role);
- }
- } catch (error) {
- // User not in this role, continue checking others
- }
- }
- // Check if user has any required reviewer role
- if (commenterReviewerRoles.size === 0) {
- const rolesList = Array.from(requiredReviewerRoles).map(role => role.replace(`@${organization}/`, '')).join(', ');
- await github.rest.issues.createComment({
- owner, repo, issue_number: prNumber,
- body: `@${commenter} You must be a member of one of the required reviewer roles to use /lgtm.\n\nRequired roles for this PR: ${rolesList}`
- });
- return;
- }
- // Check if LGTM label already exists
- const labels = await github.rest.issues.listLabelsOnIssue({
- owner, repo, issue_number: prNumber
- });
-
- if (!labels.data.some(l => l.name === lgtmLabelName)) {
- await github.rest.issues.addLabels({
- owner, repo, issue_number: prNumber, labels: [lgtmLabelName]
- });
- // Added LGTM label
- }
- // Build confirmation message with role coverage info
- const mentionRoles = wildcardRoles.join(' ');
- let confirmationMessage = `${mentionRoles}\n\n✅ LGTM by @${commenter}`;
- // Check for truly uncovered roles using pattern hierarchy
- const uncoveredRoles = [];
- for (const requiredRole of requiredReviewerRoles) {
- let isCovered = commenterReviewerRoles.has(requiredRole);
- // If not directly covered, check if any commenter role has a pattern that covers this required role's pattern
- if (!isCovered) {
- // Find the patterns for this required role
- const requiredRolePatterns = codeownerMappings
- .filter(mapping => mapping.roles.includes(requiredRole))
- .map(mapping => mapping.pattern);
- // Check if any commenter role has a pattern that is a parent of any required role pattern
- for (const commenterRole of commenterReviewerRoles) {
- const commenterRolePatterns = codeownerMappings
- .filter(mapping => mapping.roles.includes(commenterRole))
- .map(mapping => mapping.pattern);
- // Check if any commenter pattern is a parent of any required pattern
- for (const commenterPattern of commenterRolePatterns) {
- for (const requiredPattern of requiredRolePatterns) {
- const commenterPath = commenterPattern.endsWith('/') ? commenterPattern : commenterPattern + '/';
- const requiredPath = requiredPattern.endsWith('/') ? requiredPattern : requiredPattern + '/';
- // If required pattern starts with commenter pattern, it's hierarchically covered
- if (requiredPath.startsWith(commenterPath) && commenterPath !== requiredPath) {
- isCovered = true;
- break;
- }
- }
- if (isCovered) break;
- }
- if (isCovered) break;
- }
- }
- if (!isCovered) {
- uncoveredRoles.push(requiredRole);
- }
- }
- // Show role coverage analysis
- confirmationMessage += `\n\n**Review Coverage:**`;
- if (uncoveredRoles.length > 0) {
- confirmationMessage += `\n- Commenter has roles:`;
- Array.from(commenterReviewerRoles).forEach(role => {
- const roleName = role.replace(`@${organization}/`, '');
- confirmationMessage += `\n - ${roleName}`;
- });
- confirmationMessage += `\n- Required roles:`;
- Array.from(requiredReviewerRoles).forEach(role => {
- const roleName = role.replace(`@${organization}/`, '');
- confirmationMessage += `\n - ${roleName}`;
- });
- confirmationMessage += `\n- ❌ Additional review may be needed by:`;
- uncoveredRoles.forEach(role => {
- const roleName = role.replace(`@${organization}/`, '');
- confirmationMessage += `\n - ${roleName}`;
- });
- } else {
- confirmationMessage += `\n- ✅ All required roles covered`;
- }
-
- await github.rest.issues.createComment({
- owner, repo, issue_number: prNumber,
- body: confirmationMessage
- });
- }
|