Repository: mheap/pin-github-action (js)

Issue:
Sometimes jobs don't have tests
When reusing workflows, you can have jobs like this:
```yaml
jobs:
  job:
    permissions:
      contents: read
      packages: write
      id-token: write
    uses: ./.github/workflows/reuse.yaml
    with:
      // ...
    secrets:
      // ...
```

In this case, there is an error:
```
No job.steps found
```

Those jobs should be considered valid ones and just skipped.

Pull request:
Add support for reusable workflows
Resolves #108 

Patch:
diff --git a/extractActions.js b/extractActions.js
index 7d5f7c4..a60b5db 100644
--- a/extractActions.js
+++ b/extractActions.js
@@ -23,7 +23,7 @@ function extractFromComposite(input, allowEmpty) {
   const actions = new Set();
   steps = steps[0].value.items;
   for (let step of steps) {
-    handleStep(actions, step);
+    handleStep(actions, step.items);
   }
   return Array.from(actions);
 }
@@ -41,18 +41,26 @@ function extractFromWorkflow(input, allowEmpty) {
   }
 
   for (let job of jobs) {
-    let steps = job.value.items.filter((n) => n.key == "steps");
+    // Check for
+    let steps = job.value.items.filter(
+      (n) => n.key == "steps" || n.key == "uses"
+    );
     if (!steps.length) {
-      throw new Error("No job.steps found");
+      throw new Error("No job.steps or job.uses found");
     }
 
-    steps = steps[0].value.items;
-    if (!steps.length) {
-      throw new Error("No job.steps found");
-    }
-
-    for (let step of steps) {
-      handleStep(actions, step);
+    // It's a job with steps
+    if (steps[0].value.items) {
+      if (!steps[0].value.items.length) {
+        throw new Error("No job.steps found");
+      }
+
+      for (let step of steps[0].value.items) {
+        handleStep(actions, step.items);
+      }
+    } else {
+      // It's a job that calls a reusable workflow
+      handleStep(actions, steps);
     }
   }
 
@@ -63,8 +71,8 @@ function extractFromWorkflow(input, allowEmpty) {
   return Array.from(actions);
 }
 
-function handleStep(actions, step) {
-  const uses = step.items.filter((n) => n.key == "uses");
+function handleStep(actions, items) {
+  const uses = items.filter((n) => n.key == "uses");
 
   for (let use of uses) {
     const line = use.value.value.toString();
diff --git a/replaceActions.js b/replaceActions.js
index 5886439..116c10b 100644
--- a/replaceActions.js
+++ b/replaceActions.js
@@ -3,10 +3,11 @@ module.exports = function (input, action) {
   if (runs.length) {
     return replaceInComposite(input, action);
   }
-
   return replaceInWorkflow(input, action);
 };
 
+function replaceInReusable(input, action) {}
+
 function replaceInComposite(input, action) {
   let actionString = `${action.owner}/${action.repo}`;
   if (action.path) {
@@ -48,18 +49,41 @@ function replaceInWorkflow(input, action) {
     .items;
 
   for (let job of jobs) {
-    const steps = job.value.items.filter((n) => n.key == "steps")[0].value
-      .items;
-    for (let step of steps) {
-      const uses = step.items.filter((n) => n.key == "uses");
-      for (let use of uses) {
-        if (use.value.value == actionString) {
-          use.value.value = replacement;
-          use.value.comment = ` pin@${action.pinnedVersion}`;
-        }
-      }
+    const stepKeys = job.value.items.filter((n) => n.key == "steps");
+    const usesKeys = job.value.items.filter((n) => n.key == "uses");
+
+    if (stepKeys.length) {
+      replaceStandard(
+        stepKeys[0].value.items,
+        actionString,
+        replacement,
+        action
+      );
+    } else if (usesKeys.length) {
+      replaceReusable(usesKeys, actionString, replacement, action);
     }
   }
 
   return input;
 }
+
+function replaceReusable(uses, actionString, replacement, action) {
+  for (let use of uses) {
+    if (use.value.value == actionString) {
+      use.value.value = replacement;
+      use.value.comment = ` pin@${action.pinnedVersion}`;
+    }
+  }
+}
+
+function replaceStandard(steps, actionString, replacement, action) {
+  for (let step of steps) {
+    const uses = step.items.filter((n) => n.key == "uses");
+    for (let use of uses) {
+      if (use.value.value == actionString) {
+        use.value.value = replacement;
+        use.value.comment = ` pin@${action.pinnedVersion}`;
+      }
+    }
+  }
+}


Test patch:
diff --git a/extractActions.test.js b/extractActions.test.js
index 07e19f7..8c717cd 100644
--- a/extractActions.test.js
+++ b/extractActions.test.js
@@ -245,7 +245,7 @@ test("throws with missing steps", () => {
   });
 
   const actual = () => extractActions(input);
-  expect(actual).toThrow("No job.steps found");
+  expect(actual).toThrow("No job.steps or job.uses found");
 });
 
 test("throws with empty steps", () => {
@@ -367,6 +367,29 @@ test("extracts from composite actions", () => {
   ]);
 });
 
+test("extracts from reusable workflows", () => {
+  const input = convertToAst({
+    name: "Sample Reusable",
+    jobs: {
+      test: {
+        uses: "mheap/test-action@master",
+      },
+    },
+  });
+
+  const actual = extractActions(input);
+
+  expect(actual).toEqual([
+    {
+      owner: "mheap",
+      repo: "test-action",
+      path: "",
+      currentVersion: "master",
+      pinnedVersion: "master",
+    },
+  ]);
+});
+
 function convertToAst(input) {
   return YAML.parseDocument(YAML.stringify(input));
 }
diff --git a/replaceActions.test.js b/replaceActions.test.js
index 403838e..6882b77 100644
--- a/replaceActions.test.js
+++ b/replaceActions.test.js
@@ -51,6 +51,21 @@ test("replaces a single action with a sha (composite)", () => {
   expect(actual).toContain("uses: mheap/test-action@sha-here # pin@master");
 });
 
+test("replaces a single action with a sha (reusable)", () => {
+  const input = convertToAst({
+    name: "Sample Reusable",
+    jobs: {
+      test: {
+        uses: "mheap/test-action@master",
+      },
+    },
+  });
+
+  const actual = replaceActions(input, { ...action }).toString();
+
+  expect(actual).toContain("uses: mheap/test-action@sha-here # pin@master");
+});
+
 test("replaces an existing sha with a different sha, not changing the pinned branch", () => {
   const input = convertToAst({
     name: "PR",