BCR reviewer: Add `diff_module` action type (#2158)

This action will diff new module versions in a BCR PR against its
previous version. Making it much easier to review changes between module
versions.

Test runs:
-
https://github.com/meteorcloudy/bazel-central-registry/actions/runs/12792158335/job/35663185312?pr=21
-
https://github.com/meteorcloudy/bazel-central-registry/actions/runs/12792705316/job/35663784300?pr=21
diff --git a/actions/bcr-pr-reviewer/index.js b/actions/bcr-pr-reviewer/index.js
index 0c00436..e84ef5c 100644
--- a/actions/bcr-pr-reviewer/index.js
+++ b/actions/bcr-pr-reviewer/index.js
@@ -17,9 +17,9 @@
     });
 
     response.data.forEach(file => {
-      const match = file.filename.match(/^modules\/([^\/]+)\//);
+      const match = file.filename.match(/^modules\/([^\/]+)\/([^\/]+)\//);
       if (match) {
-        accumulate.add(match[1]);
+        accumulate.add(`${match[1]}@${match[2]}`);
       }
     });
 
@@ -108,7 +108,8 @@
     }
     const maintainersList = Array.from(maintainersCopy).join(', ');
     console.log(`Notifying ${maintainersList} for modules: ${modulesList}`);
-    const commentBody = `Hello ${maintainersList}, modules you maintain (${modulesList}) have been updated in this PR. Please review the changes.`;
+    const commentBody = `Hello ${maintainersList}, modules you maintain (${modulesList}) have been updated in this PR.
+      Please review the changes. You can view a diff against the previous version in the "Generate module diff" check.`;
     await postComment(octokit, owner, repo, prNumber, commentBody);
   }
 }
@@ -249,7 +250,8 @@
   }
 
   // Fetch modified modules
-  const modifiedModules = await fetchAllModifiedModules(octokit, owner, repo, prNumber);
+  const modifiedModuleVersions = await fetchAllModifiedModules(octokit, owner, repo, prNumber);
+  const modifiedModules = new Set(Array.from(modifiedModuleVersions).map(module => module.split('@')[0]));
   console.log(`Modified modules: ${Array.from(modifiedModules).join(', ')}`);
   if (modifiedModules.size === 0) {
     console.log('No modules are modified in this PR');
@@ -329,7 +331,8 @@
   const { owner, repo } = context.repo;
 
   // Fetch modified modules
-  const modifiedModules = await fetchAllModifiedModules(octokit, owner, repo, prNumber);
+  const modifiedModuleVersions = await fetchAllModifiedModules(octokit, owner, repo, prNumber);
+  const modifiedModules = new Set(Array.from(modifiedModuleVersions).map(module => module.split('@')[0]));
   console.log(`Modified modules: ${Array.from(modifiedModules).join(', ')}`);
 
   // Figure out maintainers for each modified module
@@ -342,7 +345,9 @@
   if (modulesWithoutGithubMaintainers.size > 0) {
     const modulesList = Array.from(modulesWithoutGithubMaintainers).join(', ');
     console.log(`Notifying @bazelbuild/bcr-maintainers for modules: ${modulesList}`);
-    await postComment(octokit, owner, repo, prNumber, `Hello @bazelbuild/bcr-maintainers, modules without existing maintainers (${modulesList}) have been updated in this PR. Please review the changes.`);
+    await postComment(octokit, owner, repo, prNumber,
+      `Hello @bazelbuild/bcr-maintainers, modules without existing maintainers (${modulesList}) have been updated in this PR.
+      Please review the changes. You can view a diff against the previous version in the "Generate module diff" check.`);
   }
 }
 
@@ -477,6 +482,81 @@
   }
 }
 
+async function runDiffModule(octokit) {
+  const prNumber = context.issue.number;
+  if (!prNumber) {
+    console.log('Could not get pull request number from context, exiting');
+    return;
+  }
+  console.log(`Diffing modules in PR #${prNumber}`);
+
+  const { owner, repo } = context.repo;
+
+  // Fetch modified modules
+  const modifiedModuleVersions = await fetchAllModifiedModules(octokit, owner, repo, prNumber);
+  console.log(`Modified modules: ${Array.from(modifiedModuleVersions).join(', ')}`);
+
+  // Use group if more than one module are modified
+  const groupStart = modifiedModuleVersions.size === 1 ? "" : "::group::";
+  const groupEnd = modifiedModuleVersions.size === 1 ? "" : "::endgroup::";
+
+  for (const moduleVersion of modifiedModuleVersions) {
+    const [moduleName, versionName] = moduleVersion.split('@');
+    try {
+      const { data: metadataContent } = await octokit.rest.repos.getContent({
+        owner,
+        repo,
+        path: `modules/${moduleName}/metadata.json`,
+        ref: `refs/pull/${prNumber}/head`,
+      });
+      const metadata = JSON.parse(Buffer.from(metadataContent.content, 'base64').toString('utf-8'));
+
+      // Assuming metadata.versions is sorted in ascending order, otherwise bcr_validation.py checks will fail anyway.
+      let versionIndex = metadata.versions.findIndex(version => version === versionName);
+
+      if (versionIndex === -1) {
+        console.error(`Version ${versionName} not found in metadata.json for module ${moduleName}`);
+        console.log(`Metadata content for module ${moduleName}@${versionName}: ${JSON.stringify(metadata, null, 2)}`);
+        setFailed(`Failed to generate diff for module ${moduleName}@${versionName}`);
+        return;
+      }
+
+      if (versionIndex === 0) {
+        console.log(`No previous version to diff for module ${moduleName}@${versionName}`);
+        continue;
+      }
+
+      const previousVersion = metadata.versions[versionIndex - 1];
+      console.log(`${groupStart}Generating diff for module ${moduleName}@${versionName} against version ${previousVersion}`);
+
+      const diffCommand = `diff --color=always -urN modules/${moduleName}/${previousVersion} modules/${moduleName}/${versionName}`;
+      console.log(`Running command: ${diffCommand}`);
+      const { execSync } = require('child_process');
+
+      try {
+        await execSync(diffCommand, { stdio: 'inherit' });
+        console.log(`No differences found!`);
+      } catch (error) {
+        if (error.status === 1) {
+          // diff command returns 1 when differences are found
+        } else {
+          setFailed(`Failed to generate diff for module ${moduleName}@${versionName}`);
+          throw error;
+        }
+      }
+
+      console.log(`${groupEnd}`);
+    } catch (error) {
+      if (error.status === 404) {
+        console.log(`Module ${moduleName} does not have a metadata.json file on the PR branch.`);
+      } else {
+        console.error(`Error processing module ${moduleName}: ${error}`);
+        setFailed(`Failed to generate diff for module ${moduleName}@${versionName}`);
+      }
+    }
+  }
+}
+
 async function run() {
   const action_type = getInput("action-type");
   const token = getInput("token");
@@ -490,6 +570,8 @@
     await runDismissApproval(octokit);
   } else if (action_type === "skip_check") {
     await runSkipCheck(octokit);
+  } else if (action_type === "diff_module") {
+    await runDiffModule(octokit);
   } else {
     console.log(`Unknown action type: ${action_type}`);
   }