Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions packages/api-client/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/projects": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Create a project
* @description Create a new project in an account you administer. The authenticated personal access token must be scoped to the target account, and the acting user must be an administrator of it.
*/
post: operations["createProject"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/projects/{owner}/{project}": {
parameters: {
query?: never;
Expand Down Expand Up @@ -2066,6 +2086,71 @@ export interface operations {
};
};
};
createProject: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description Name of the project to create. Must be unique within the account (case-insensitive) and cannot be a reserved name. */
name: string;
/** @description Slug of the account (personal or team) that will own the project. The personal access token must be scoped to this account and the acting user must be one of its administrators. */
accountSlug: string;
};
};
};
responses: {
/** @description Project created successfully — returns the project */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Project"];
};
};
/** @description Invalid parameters */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server error */
500: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
getProject: {
parameters: {
query?: never;
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/commands/create-project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import type { Command } from "commander";
import { createApiClient, unwrap } from "../lib/api";
import { fail } from "../lib/cli-error";
import { formatProject } from "../lib/format";
import { handleCliError, output } from "../lib/run";
import { resolveToken } from "../lib/target";
import { accountOption, jsonOption, tokenOption } from "../options";

type CreateProjectOptions = {
account?: string | undefined;
token?: string | undefined;
json?: boolean | undefined;
};

export function createProjectCommand(program: Command) {
program
.command("create-project")
.argument("<name>", "Name of the project to create")
.description("Create a new project in an account you administer")
.addOption(accountOption)
.addOption(tokenOption)
.addOption(jsonOption)
.action(async (name: string, options: CreateProjectOptions) => {
try {
const accountSlug = options.account || process.env["ARGOS_ACCOUNT"];
if (!accountSlug) {
fail(
"An account is required. Use --account <slug> or set ARGOS_ACCOUNT.",
);
}
const client = createApiClient(await resolveToken(options));
const project = unwrap(
await client.POST("/projects", {
body: { name, accountSlug },
}),
);
output(project, options, formatProject);
} catch (error) {
handleCliError(error, "user");
}
});
}
13 changes: 6 additions & 7 deletions packages/cli/src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@ export function uploadCommand(program: Command) {
"Mode of comparison applied. CI for visual regression testing, monitoring for visual monitoring.",
)
.default("ci")
.choices(["ci", "monitoring"])
.env("ARGOS_MODE"),
.choices(["ci", "monitoring"]),
)
.addOption(
new Option(
Expand All @@ -65,32 +64,32 @@ export function uploadCommand(program: Command) {
new Option(
"--parallel-total <number>",
"The number of parallel nodes being ran",
).env("ARGOS_PARALLEL_TOTAL"),
),
)
.addOption(parallelNonceOption)
.addOption(
new Option(
"--parallel-index <number>",
"The index of the parallel node being ran (must be at least 1)",
).env("ARGOS_PARALLEL_INDEX"),
),
)
.addOption(
new Option(
"--reference-branch <string>",
"Branch used as baseline for screenshot comparison",
).env("ARGOS_REFERENCE_BRANCH"),
),
)
.addOption(
new Option(
"--reference-commit <string>",
"Commit used as baseline for screenshot comparison",
).env("ARGOS_REFERENCE_COMMIT"),
),
)
.addOption(
new Option(
"--threshold <number>",
"Sensitivity threshold between 0 and 1. The higher the threshold, the less sensitive the diff will be. Default to 0.5",
).env("ARGOS_THRESHOLD"),
),
)
.addOption(
new Option(
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { loginCommand } from "./commands/login";
import { logoutCommand } from "./commands/logout";
import { deployCommand } from "./commands/deploy";
import { whoamiCommand } from "./commands/whoami";
import { createProjectCommand } from "./commands/create-project";

const __dirname = fileURLToPath(new URL(".", import.meta.url));

Expand All @@ -35,6 +36,7 @@ loginCommand(program);
logoutCommand(program);
deployCommand(program);
whoamiCommand(program);
createProjectCommand(program);

if (!process.argv.slice(2).length) {
program.outputHelp();
Expand Down
18 changes: 18 additions & 0 deletions packages/cli/src/lib/format.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
formatBuild,
formatComment,
formatComments,
formatProject,
formatReview,
formatReviews,
formatSnapshotSummary,
Expand Down Expand Up @@ -49,6 +50,23 @@ describe("formatValue", () => {
});
});

describe("formatProject", () => {
it("summarizes the created project", () => {
const project = {
id: "project-1",
name: "my-app",
account: { id: "account-1", slug: "acme" },
defaultBaseBranch: "main",
hasRemoteContentAccess: true,
} as ArgosAPISchema.components["schemas"]["Project"];
const output = formatProject(project);
expect(output).toContain("Created project acme/my-app.");
expect(output).toContain("ID: project-1");
expect(output).toContain("Account: acme");
expect(output).toContain("Default base branch: main");
});
});

describe("formatStats", () => {
it("renders a dash when stats are missing", () => {
expect(formatStats(null)).toBe("-");
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ type SnapshotDiffStatus = SnapshotDiff["status"];
type BuildReview = ArgosAPISchema.components["schemas"]["BuildReview"];
type Comment = ArgosAPISchema.components["schemas"]["Comment"];
type User = ArgosAPISchema.components["schemas"]["User"];
type Project = ArgosAPISchema.components["schemas"]["Project"];

/** Render a scalar, using `-` for empty values. */
export function formatValue(value: string | number | null | undefined): string {
Expand All @@ -30,6 +31,16 @@ export function formatMe(user: User): string {
].join("\n");
}

export function formatProject(project: Project): string {
return [
`Created project ${project.account.slug}/${project.name}.`,
`ID: ${project.id}`,
`Name: ${project.name}`,
`Account: ${project.account.slug}`,
`Default base branch: ${formatValue(project.defaultBaseBranch)}`,
].join("\n");
}

export function formatStats(stats: Build["stats"]): string {
if (!stats) {
return "-";
Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/lib/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,9 @@ function explicitProjectPath(
if (reference.owner && reference.project) {
return { owner: reference.owner, project: reference.project };
}
if (options.project) {
return parseProjectPathOrFail(options.project);
const project = options.project || process.env["ARGOS_PROJECT"];
if (project) {
return parseProjectPathOrFail(project);
}
return null;
}
Expand Down
19 changes: 11 additions & 8 deletions packages/cli/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,28 @@ export type ParallelNonceOption = { parallelNonce?: string | undefined };
export const parallelNonceOption = new Option(
"--parallel-nonce <string>",
"A unique ID for this parallel build",
).env("ARGOS_PARALLEL_NONCE");
);

export type TokenOption = { token?: string | undefined };
export const tokenOption = new Option(
"--token <token>",
"Repository token",
).env("ARGOS_TOKEN");
export const tokenOption = new Option("--token <token>", "Repository token");

export type ProjectOption = { project?: string | undefined };
export const projectOption = new Option(
"--project <slug>",
"Argos project slug (account/project), used to disambiguate tokenless authentication when multiple projects are linked to the same repository",
).env("ARGOS_PROJECT");
);

export type BuildNameOption = { buildName?: string | undefined };
export const buildNameOption = new Option(
"--build-name <string>",
"Name of the build, in case you want to run multiple Argos builds in a single CI job",
).env("ARGOS_BUILD_NAME");
);

export type AccountOption = { account?: string | undefined };
export const accountOption = new Option(
"--account <slug>",
"Slug of the account (personal or team) that will own the project",
);

export type JsonOption = { json?: boolean | undefined };
export const jsonOption = new Option(
Expand All @@ -34,4 +37,4 @@ export type ProjectPathOption = { project?: string | undefined };
export const projectPathOption = new Option(
"--project <owner/project>",
"Project path in owner/project format. Required for build-number references on review and comment commands",
).env("ARGOS_PROJECT");
);
13 changes: 13 additions & 0 deletions packages/core/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ describe("#readConfig", () => {
delete process.env.ARGOS_PARALLEL;
});

it("reads threshold from env", async () => {
process.env.ARGOS_THRESHOLD = "0.8";
expect((await readDummyConfig()).threshold).toBe(0.8);
delete process.env.ARGOS_THRESHOLD;
});

it("threshold passed as argument takes priority over env variable", async () => {
process.env.ARGOS_THRESHOLD = "0.8";
const config = await readDummyConfig({ threshold: 0.2 });
expect(config.threshold).toBe(0.2);
delete process.env.ARGOS_THRESHOLD;
});

it("reads merge queue pr numbers from env", async () => {
process.env.ARGOS_MERGE_QUEUE_PRS = "101,102";
const config = await readDummyConfig();
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -457,6 +457,7 @@ export async function readConfig(options: Partial<Config> = {}) {
options.referenceBranch || defaultConfig.referenceBranch || null,
referenceCommit:
options.referenceCommit || defaultConfig.referenceCommit || null,
threshold: options.threshold ?? defaultConfig.threshold ?? null,
repository: ciEnv?.repository || null,
originalRepository: ciEnv?.originalRepository || null,
jobId: ciEnv?.jobId || null,
Expand Down
Loading