diff --git a/packages/cli/__tests__/lib/detect-env.test.ts b/packages/cli/__tests__/lib/detect-env.test.ts new file mode 100644 index 000000000..a14049d0e --- /dev/null +++ b/packages/cli/__tests__/lib/detect-env.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("../../src/lib/shell.js", () => ({ + git: vi.fn(), + gh: vi.fn(), + execSilent: vi.fn(), +})); + +vi.mock("../../src/lib/git-utils.js", () => ({ + detectDefaultBranch: vi.fn().mockResolvedValue("main"), +})); + +import { detectEnvironment } from "../../src/lib/detect-env.js"; +import { git, execSilent } from "../../src/lib/shell.js"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(execSilent).mockResolvedValue(null); +}); + +describe("detectEnvironment", () => { + describe("ownerRepo extraction", () => { + it("extracts owner/repo from GitHub HTTPS remote", async () => { + vi.mocked(git) + .mockResolvedValueOnce(".git") // rev-parse + .mockResolvedValueOnce("https://github.com/acme/my-app.git") // remote + .mockResolvedValueOnce("main"); // branch + + const env = await detectEnvironment("/tmp/test"); + expect(env.ownerRepo).toBe("acme/my-app"); + }); + + it("extracts owner/repo from GitHub SSH remote", async () => { + vi.mocked(git) + .mockResolvedValueOnce(".git") + .mockResolvedValueOnce("git@github.com:acme/my-app.git") + .mockResolvedValueOnce("main"); + + const env = await detectEnvironment("/tmp/test"); + expect(env.ownerRepo).toBe("acme/my-app"); + }); + + it("extracts owner/repo from GitLab HTTPS remote", async () => { + vi.mocked(git) + .mockResolvedValueOnce(".git") + .mockResolvedValueOnce("https://gitlab.com/org/repo.git") + .mockResolvedValueOnce("main"); + + const env = await detectEnvironment("/tmp/test"); + expect(env.ownerRepo).toBe("org/repo"); + }); + + it("extracts owner/repo from GitLab SSH remote", async () => { + vi.mocked(git) + .mockResolvedValueOnce(".git") + .mockResolvedValueOnce("git@gitlab.com:org/repo.git") + .mockResolvedValueOnce("main"); + + const env = await detectEnvironment("/tmp/test"); + expect(env.ownerRepo).toBe("org/repo"); + }); + + it("returns null for self-hosted / unknown git hosts", async () => { + vi.mocked(git) + .mockResolvedValueOnce(".git") + .mockResolvedValueOnce("git@git.corp.com:team/project.git") + .mockResolvedValueOnce("main"); + + const env = await detectEnvironment("/tmp/test"); + expect(env.ownerRepo).toBeNull(); + }); + + it("returns null when no remote is configured", async () => { + vi.mocked(git) + .mockResolvedValueOnce(".git") + .mockResolvedValueOnce(null) // no remote + .mockResolvedValueOnce("main"); + + const env = await detectEnvironment("/tmp/test"); + expect(env.ownerRepo).toBeNull(); + expect(env.gitRemote).toBeNull(); + }); + + it("returns null when not a git repo", async () => { + vi.mocked(git).mockResolvedValueOnce(null); // not a git repo + + const env = await detectEnvironment("/tmp/test"); + expect(env.isGitRepo).toBe(false); + expect(env.ownerRepo).toBeNull(); + }); + }); +}); diff --git a/packages/cli/__tests__/lib/repo-validation.test.ts b/packages/cli/__tests__/lib/repo-validation.test.ts new file mode 100644 index 000000000..5a3ee7e89 --- /dev/null +++ b/packages/cli/__tests__/lib/repo-validation.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; + +/** + * Tests for the repo validation regex used in autoCreateConfig and addProjectToConfig. + * The regex is inlined in start.ts — this test validates the pattern independently. + */ +const REPO_REGEX = /^[^\s/]+\/[^\s/]+$/; + +describe("repo validation regex", () => { + it("accepts valid owner/repo", () => { + expect(REPO_REGEX.test("acme/my-app")).toBe(true); + expect(REPO_REGEX.test("ComposioHQ/agent-orchestrator")).toBe(true); + expect(REPO_REGEX.test("org/repo")).toBe(true); + }); + + it("rejects empty string", () => { + expect(REPO_REGEX.test("")).toBe(false); + }); + + it("rejects lone slash", () => { + expect(REPO_REGEX.test("/")).toBe(false); + }); + + it("rejects missing owner", () => { + expect(REPO_REGEX.test("/repo")).toBe(false); + }); + + it("rejects missing repo name", () => { + expect(REPO_REGEX.test("owner/")).toBe(false); + }); + + it("rejects strings with whitespace", () => { + expect(REPO_REGEX.test("acme/repo extra")).toBe(false); + expect(REPO_REGEX.test("acme /repo")).toBe(false); + }); + + it("rejects nested paths (more than one slash)", () => { + expect(REPO_REGEX.test("acme/repo/extra")).toBe(false); + }); + + it("rejects strings without a slash", () => { + expect(REPO_REGEX.test("notaslash")).toBe(false); + }); + + it("rejects strings with spaces in segments", () => { + expect(REPO_REGEX.test("my org/repo")).toBe(false); + expect(REPO_REGEX.test("org/my repo")).toBe(false); + }); +}); diff --git a/packages/core/src/__tests__/config-validation.test.ts b/packages/core/src/__tests__/config-validation.test.ts index 00833e0f5..01ed0f7ba 100644 --- a/packages/core/src/__tests__/config-validation.test.ts +++ b/packages/core/src/__tests__/config-validation.test.ts @@ -379,6 +379,55 @@ describe("Config Schema Validation", () => { expect(() => validateConfig(missingBranch)).not.toThrow(); }); + it("does not infer SCM or tracker when repo is missing", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + defaultBranch: "main", + // No repo — SCM and tracker should not be inferred + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.repo).toBeUndefined(); + expect(validated.projects.proj1.scm).toBeUndefined(); + expect(validated.projects.proj1.tracker).toBeUndefined(); + }); + + it("infers SCM and tracker when repo has owner/repo format", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "org/test", + defaultBranch: "main", + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.scm).toEqual({ plugin: "github" }); + expect(validated.projects.proj1.tracker).toEqual({ plugin: "github" }); + }); + + it("does not infer SCM or tracker when repo has no slash", () => { + const config = { + projects: { + proj1: { + path: "/repos/test", + repo: "notaslash", + defaultBranch: "main", + }, + }, + }; + + const validated = validateConfig(config); + expect(validated.projects.proj1.scm).toBeUndefined(); + expect(validated.projects.proj1.tracker).toBeUndefined(); + }); + it("sessionPrefix is optional", () => { const config = { projects: { diff --git a/packages/web/src/lib/scm-webhooks.test.ts b/packages/web/src/lib/scm-webhooks.test.ts index 7f7dee2e2..9f0cd97e6 100644 --- a/packages/web/src/lib/scm-webhooks.test.ts +++ b/packages/web/src/lib/scm-webhooks.test.ts @@ -48,6 +48,20 @@ describe("eventMatchesProject", () => { expect(eventMatchesProject(event, project)).toBe(false); }); + + it("returns false when project has no repo configured", () => { + const noRepoProject: ProjectConfig = { ...project, repo: undefined }; + const event: SCMWebhookEvent = { + provider: "github", + kind: "pull_request", + action: "opened", + rawEventType: "pull_request", + repository: { owner: "acme", name: "my-app" }, + data: {}, + }; + + expect(eventMatchesProject(event, noRepoProject)).toBe(false); + }); }); describe("findAffectedSessions", () => {