|
|
@@ -0,0 +1,245 @@
|
|
|
+/**
|
|
|
+ * 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
|
|
|
+ });
|
|
|
+}
|