Repository: firecow/gitlab-ci-local (ts)

Issue:
Manually triggered jobs must not run if they're not specifically called via CLI
**Minimal .gitlab-ci.yml illustrating the issue**
```yml
---
stages:
  - plan
  - apply

plan:
  stage: plan
  environment:
    name: $CI_COMMIT_BRANCH
  script:
    - echo "i'm planning"
   rules:
    - if: '$CI_COMMIT_BRANCH =~ /dev/'
      changes:
        - folder
    - if: '$CI_COMMIT_BRANCH =~ /main/'
      changes:
        - folder
    - if: '$CI_PIPELINE_SOURCE == "web"'
 
apply :
  stage: apply
  environment:
    name: $CI_COMMIT_BRANCH
  script:
    - echo "i'm applying"
    #- terraform apply -input=false PLAN
  when: manual
  dependencies:
    - plan
  rules:
    - if: '$CI_COMMIT_BRANCH =~ /dev/'
      changes:
        - folder
    - if: '$CI_COMMIT_BRANCH =~ /main/'
      changes:
        - folder
    - if: '$CI_PIPELINE_SOURCE == "web"'
```

**Expected behavior**
Apply job must not run without recieving a manual confirmation of the user, i had it applied and build some unwanted infrastructure

**Host information**
Ubuntu 20.04
gitlab-ci-local 4.46.0

**Containerd binary**
I'm using docker

**Additional context**
This issue is related to [this other issue](https://github.com/firecow/gitlab-ci-local/issues/680), that had the exact same problem. I hope that i provided a most detailed example, but please let me know if you need more help with that. The tool is awesome BTW!


Pull request description:
Fix: manual job rule bug
fixes #1073

Patch:
diff --git a/src/argv.ts b/src/argv.ts
index 38da56f..8185f17 100644
--- a/src/argv.ts
+++ b/src/argv.ts
@@ -3,12 +3,54 @@ import * as fs from "fs-extra";
 import * as dotenv from "dotenv";
 import * as path from "path";
 import camelCase from "camelcase";
+import {Utils} from "./utils";
+import {WriteStreams} from "./write-streams";
+import chalk from "chalk";
+
+
+async function isInGitRepository () {
+    try {
+        const {exitCode} = await Utils.bash("git rev-parse --is-inside-work-tree");
+        return exitCode === 0;
+    } catch (err: any) {
+        if (err.stderr === "fatal: not a git repository (or any of the parent directories): .git") {
+            return false;
+        }
+        throw err;
+    }
+}
+
+async function gitRootPath () {
+    const {stdout} = await Utils.bash("git rev-parse --show-toplevel");
+    return stdout;
+}
 
 export class Argv {
 
     private map: Map<string, any> = new Map<string, any>();
+    private writeStreams: WriteStreams | undefined;
+
+    private async fallbackCwd (args: any) {
+        if (args.cwd !== undefined || args.file !== undefined) return;
+        if (fs.existsSync(`${process.cwd()}/.gitlab-ci.yml`)) return;
+        if (!(await isInGitRepository())) return;
+
+        this.writeStreams?.stderr(chalk`{yellow .gitlab-ci.yml not found in cwd, falling back to git root directory}\n`);
+        this.map.set("cwd", path.relative(process.cwd(), await gitRootPath()));
+    }
+
+    static async build (args: any, writeStreams?: WriteStreams) {
+        const argv = new Argv(args, writeStreams);
+        await argv.init(args);
+        return argv;
+    }
+
+    async init (args: any) {
+        await this.fallbackCwd(args);
+    }
 
-    constructor (argv: any) {
+    private constructor (argv: any, writeStreams?: WriteStreams) {
+        this.writeStreams = writeStreams;
         for (const [key, value] of Object.entries(argv)) {
             this.map.set(key, value);
         }
@@ -32,7 +74,7 @@ export class Argv {
     get cwd (): string {
         let cwd = this.map.get("cwd") ?? ".";
         assert(typeof cwd != "object", "--cwd option cannot be an array");
-        assert(!cwd.startsWith("/") && !cwd.startsWith("~"), "Please use relative path for the --cwd option");
+        assert(!path.isAbsolute(cwd), "Please use relative path for the --cwd option");
         cwd = path.normalize(`${process.cwd()}/${cwd}`);
         cwd = cwd.replace(/\/$/, "");
         assert(fs.pathExistsSync(cwd), `${cwd} is not a directory`);
diff --git a/src/handler.ts b/src/handler.ts
index 6baba8f..1faef22 100644
--- a/src/handler.ts
+++ b/src/handler.ts
@@ -1,5 +1,6 @@
 import * as yaml from "js-yaml";
 import chalk from "chalk";
+import path from "path";
 import * as fs from "fs-extra";
 import * as yargs from "yargs";
 import {Commander} from "./commander";
@@ -21,7 +22,7 @@ const generateGitIgnore = (cwd: string, stateDir: string) => {
 };
 
 export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[] = []) {
-    const argv = new Argv(args);
+    const argv = await Argv.build(args, writeStreams);
     const cwd = argv.cwd;
     const stateDir = argv.stateDir;
     const file = argv.file;
@@ -32,7 +33,7 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
         return [];
     }
 
-    assert(fs.existsSync(`${cwd}/${file}`), `${cwd}/${file} could not be found`);
+    assert(fs.existsSync(`${cwd}/${file}`), `${path.resolve(cwd)}/${file} could not be found`);
 
     if (argv.fetchIncludes) {
         await Parser.create(argv, writeStreams, 0, jobs);
@@ -102,4 +103,3 @@ export async function handler (args: any, writeStreams: WriteStreams, jobs: Job[
 
     return cleanupJobResources(jobs);
 }
-
diff --git a/src/index.ts b/src/index.ts
index 9a4b0fb..4300a13 100755
--- a/src/index.ts
+++ b/src/index.ts
@@ -243,13 +243,13 @@ process.on("SIGUSR2", async () => await cleanupJobResources(jobs));
                 if (current.startsWith("-")) {
                     completionFilter();
                 } else {
-                    const argv = new Argv({...yargsArgv, autoCompleting: true});
-                    state.getPipelineIid(argv.cwd, argv.stateDir).then((pipelineIid) => {
-                        Parser.create(argv, new WriteStreamsMock(), pipelineIid, []).then((parser) => {
+                    Argv.build({...yargsArgv, autoCompleting: true})
+                        .then(argv => state.getPipelineIid(argv.cwd, argv.stateDir).then(pipelineIid => ({argv, pipelineIid})))
+                        .then(({argv, pipelineIid}) => Parser.create(argv, new WriteStreamsMock(), pipelineIid, []))
+                        .then((parser) => {
                             const jobNames = [...parser.jobs.values()].filter((j) => j.when != "never").map((j) => j.name);
                             done(jobNames);
                         });
-                    });
                 }
             } catch (e) {
                 return ["Parser-Failed!"];
diff --git a/src/job.ts b/src/job.ts
index 12a94df..87cfb53 100644
--- a/src/job.ts
+++ b/src/job.ts
@@ -184,7 +184,7 @@ export class Job {
 
         // Set {when, allowFailure} based on rules result
         if (this.rules) {
-            const ruleResult = Utils.getRulesResult({cwd, rules: this.rules, variables: this._variables});
+            const ruleResult = Utils.getRulesResult({cwd, rules: this.rules, variables: this._variables}, this.when);
             this.when = ruleResult.when;
             this.allowFailure = ruleResult.allowFailure;
             this._variables = {...globalVariables, ...jobVariables, ...ruleResult.variables, ...matrixVariables, ...predefinedVariables, ...envMatchedVariables, ...argvVariables};
diff --git a/src/parser-includes.ts b/src/parser-includes.ts
index ea7e7eb..e9556b2 100644
--- a/src/parser-includes.ts
+++ b/src/parser-includes.ts
@@ -37,7 +37,7 @@ export class ParserIncludes {
         const promises = [];
         const {stateDir, cwd, fetchIncludes, gitData, expandVariables} = opts;
 
-        const include = this.expandInclude(gitlabData["include"], opts.variables);
+        const include = this.expandInclude(gitlabData?.include, opts.variables);
 
         // Find files to fetch from remote and place in .gitlab-ci-local/includes
         for (const value of include) {
diff --git a/src/parser.ts b/src/parser.ts
index e973e70..4040ea2 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -50,8 +50,7 @@ export class Parser {
     }
 
     get jobNamePad (): number {
-        assert(this._jobNamePad != null, "jobNamePad is uninitialized");
-        return this._jobNamePad;
+        return this._jobNamePad ?? 0;
     }
 
     static async create (argv: Argv, writeStreams: WriteStreams, pipelineIid: number, jobs: Job[], expandVariables: boolean = true) {
@@ -278,7 +277,7 @@ export class Parser {
             }
         }
 
-        if (fileData.length == 1) return fileData[0];
+        if (fileData.length <= 1) return fileData[0];
 
         if (isGitlabSpecFile(fileData[0])) {
             const inputsSpecification: any = fileData[0];
diff --git a/src/utils.ts b/src/utils.ts
index 520ec57..6fe2cd5 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -172,20 +172,22 @@ export class Utils {
         return envMatchedVariables;
     }
 
-    static getRulesResult (opt: RuleResultOpt): {when: string; allowFailure: boolean; variables?: {[name: string]: string}} {
+    static getRulesResult (opt: RuleResultOpt, jobWhen: string = "on_success"): {when: string; allowFailure: boolean; variables?: {[name: string]: string}} {
         let when = "never";
-        let allowFailure = false;
+
+        // optional manual jobs allowFailure defaults to true https://docs.gitlab.com/ee/ci/jobs/job_control.html#types-of-manual-jobs
+        let allowFailure = jobWhen === "manual";
         let ruleVariable: {[name: string]: string} | undefined;
         let ruleExists;
 
         for (const rule of opt.rules) {
             if (Utils.evaluateRuleIf(rule.if || "true", opt.variables)) {
-                when = rule.when ? rule.when : "on_success";
-                allowFailure = rule.allow_failure ?? false;
+                when = rule.when ? rule.when : jobWhen;
+                allowFailure = rule.allow_failure ?? allowFailure;
                 ruleVariable = rule.variables;
                 ruleExists = rule.exists;
 
-                if (ruleExists && !Utils.evaludateRuleExist(opt.cwd, ruleExists)) {
+                if (ruleExists && !Utils.evaluateRuleExist(opt.cwd, ruleExists)) {
                     when = "never";
                 }
 
@@ -217,11 +219,10 @@ export class Utils {
         evalStr = evalStr.replace(/null.match\(.+?\) != null/g, "false");
         evalStr = evalStr.replace(/null.match\(.+?\) == null/g, "false");
 
-        // noinspection BadExpressionStatementJS
-        return eval(`if (${evalStr}) { true } else { false }`);
+        return Boolean(eval(evalStr));
     }
 
-    static evaludateRuleExist (cwd: string, ruleExists: string[]): boolean {
+    static evaluateRuleExist (cwd: string, ruleExists: string[]): boolean {
         for (const pattern of ruleExists) {
             if (globby.sync(pattern, {dot: true, cwd}).length > 0) {
                 return true;


Test patch:
diff --git a/tests/cases.test.ts b/tests/cases.test.ts
index c196d7f..e548b21 100644
--- a/tests/cases.test.ts
+++ b/tests/cases.test.ts
@@ -3,6 +3,16 @@ import chalk from "chalk";
 import {handler} from "../src/handler";
 import assert from "assert";
 import {AssertionError} from "assert";
+import fs from "fs-extra";
+
+const dummyGitlabCiYmlPath = "tests/test-cases/.empty-gitlab-ci.yml";
+beforeAll(() => {
+    fs.createFileSync(dummyGitlabCiYmlPath);
+});
+
+afterAll(() => {
+    fs.removeSync(dummyGitlabCiYmlPath);
+});
 
 test("--completion", async () => {
     const spy = jest.spyOn(console, "log").mockImplementation();
@@ -39,3 +49,10 @@ test("docs (no .gitlab-ci.yml)", async () => {
         expect(e.message).toBe(chalk`${process.cwd()}/docs/.gitlab-ci.yml could not be found`);
     }
 });
+
+test("empty .gitlab-ci.yml", async () => {
+    const writeStreams = new WriteStreamsMock();
+    await handler({
+        file: dummyGitlabCiYmlPath,
+    }, writeStreams);
+});
diff --git a/tests/rules.test.ts b/tests/rules.test.ts
index dcd733a..b8fe223 100644
--- a/tests/rules.test.ts
+++ b/tests/rules.test.ts
@@ -278,3 +278,13 @@ test("https://github.com/firecow/gitlab-ci-local/issues/609", () => {
     const rulesResult = Utils.getRulesResult({cwd: "", rules, variables});
     expect(rulesResult).toEqual({when: "manual", allowFailure: false, variables: undefined});
 });
+
+test("optional manual job", () => {
+    const jobWhen = "manual";
+    const rules = [
+        {if: "$GITLAB_CI == 'false'"},
+    ];
+    const variables = {GITLAB_CI: "false"};
+    const rulesResult = Utils.getRulesResult({cwd: "", rules, variables}, jobWhen);
+    expect(rulesResult).toEqual({when: "manual", allowFailure: true, variables: undefined});
+});
diff --git a/tests/test-cases/argv-cwd/.gitlab-ci-2.yml b/tests/test-cases/argv-cwd/.gitlab-ci-2.yml
new file mode 100644
index 0000000..d988aa1
--- /dev/null
+++ b/tests/test-cases/argv-cwd/.gitlab-ci-2.yml
@@ -0,0 +1,4 @@
+---
+job:
+  script:
+    - echo dlrow olleh
diff --git a/tests/test-cases/argv-cwd/.gitlab-ci.yml b/tests/test-cases/argv-cwd/.gitlab-ci.yml
new file mode 100644
index 0000000..d933ef3
--- /dev/null
+++ b/tests/test-cases/argv-cwd/.gitlab-ci.yml
@@ -0,0 +1,4 @@
+---
+job:
+  script:
+    - echo hello world
diff --git a/tests/test-cases/argv-cwd/integration.artifacts-after-afterscript.test.ts b/tests/test-cases/argv-cwd/integration.artifacts-after-afterscript.test.ts
new file mode 100644
index 0000000..db7f7e0
--- /dev/null
+++ b/tests/test-cases/argv-cwd/integration.artifacts-after-afterscript.test.ts
@@ -0,0 +1,121 @@
+import {WriteStreamsMock} from "../../../src/write-streams";
+import {handler} from "../../../src/handler";
+import {initSpawnSpy} from "../../mocks/utils.mock";
+import {WhenStatics} from "../../mocks/when-statics";
+import {Utils} from "../../../src/utils";
+import fs from "fs-extra";
+import chalk from "chalk";
+import assert, {AssertionError} from "assert";
+
+const originalDir = process.cwd();
+const currentRelativeDir = "tests/test-cases/argv-cwd/dummy";
+
+beforeAll(() => {
+    initSpawnSpy(WhenStatics.all);
+});
+
+describe("fallback cwd", () => {
+    beforeEach(() => {
+        fs.mkdirSync(currentRelativeDir);
+    });
+
+    afterEach(() => {
+        process.chdir(originalDir);
+        fs.rmSync("tests/test-cases/argv-cwd/.git", {recursive: true, force: true});
+        fs.rmSync(currentRelativeDir, {recursive: true});
+    });
+
+    test("fallbacks to git root directory", async () => {
+        process.chdir(currentRelativeDir);
+        await Utils.bash("cd .. && git init");
+
+        const writeStreams = new WriteStreamsMock();
+        await handler({
+            preview: true,
+        }, writeStreams);
+
+        const expected = `---
+stages:
+  - .pre
+  - build
+  - test
+  - deploy
+  - .post
+job:
+  script:
+    - echo hello world`;
+        expect(writeStreams.stdoutLines[0]).toEqual(expected);
+    });
+
+    test("won't fallback if cwd has .gitlab-ci.yml", async () => {
+        process.chdir(currentRelativeDir);
+        await Utils.bash("cd .. && git init");
+        await Utils.bash("cp ../.gitlab-ci-2.yml .gitlab-ci.yml");
+
+        const writeStreams = new WriteStreamsMock();
+        await handler({
+            preview: true,
+        }, writeStreams);
+
+        const expected = `---
+stages:
+  - .pre
+  - build
+  - test
+  - deploy
+  - .post
+job:
+  script:
+    - echo dlrow olleh`;
+
+        expect(writeStreams.stdoutLines[0]).toEqual(expected);
+    });
+
+    test("won't fallback if not inside git repository", async () => {
+        const {stdout: tmpDir} = await Utils.bash("mktemp -d");
+        process.chdir(tmpDir);
+
+        try {
+            const writeStreams = new WriteStreamsMock();
+            await handler({
+                preview: true,
+            }, writeStreams);
+        } catch (e: any) {
+            assert(e instanceof AssertionError, "e is not instanceof AssertionError");
+            expect(e.message).toContain(chalk`${tmpDir}/.gitlab-ci.yml could not be found`);
+        }
+    });
+
+    test("won't fallback if --file flag is set", async () => {
+        process.chdir(currentRelativeDir);
+        await Utils.bash("cd .. && git init");
+
+        try {
+            const writeStreams = new WriteStreamsMock();
+            await handler({
+                file: ".gitlab-ci.yml",
+                preview: true,
+            }, writeStreams);
+        } catch (e: any) {
+            assert(e instanceof AssertionError, "e is not instanceof AssertionError");
+            expect(e.message).toEqual(chalk`${originalDir}/${currentRelativeDir}/.gitlab-ci.yml could not be found`);
+        }
+    });
+
+    test("won't fallback if --cwd flag is set", async () => {
+        process.chdir(currentRelativeDir);
+        await Utils.bash("cd .. && git init");
+
+        try {
+            const writeStreams = new WriteStreamsMock();
+            await handler({
+                cwd: "",
+                preview: true,
+            }, writeStreams);
+        } catch (e: any) {
+            assert(e instanceof AssertionError, "e is not instanceof AssertionError");
+            expect(e.message).toEqual(chalk`${originalDir}/${currentRelativeDir}/.gitlab-ci.yml could not be found`);
+        }
+    });
+
+});