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
9 changes: 2 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"license": "Apache-2.0",
"name": "r5n-clis",
"packageManager": "bun@1.3.3",
"private": true,
"scripts": {
"build": "bun --filter '*' build",
"clean": "bun --filter '*' clean; rm -rf node_modules; bun install",
Expand All @@ -29,11 +30,5 @@
"type-check:go": "bun --elide-lines=0 --filter '*' type-check:go"
},
"version": "0.0.0",
"workspaces": {
"packages": [
"packages/*",
"tools"
]
},
"private": true
"workspaces": { "packages": ["packages/*", "tools"] }
}
63 changes: 63 additions & 0 deletions packages/core/tests/util/deepMerge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test";
import { deepMerge } from "../../src/util/misc";

describe("deepMerge", () => {
test("merges flat objects", () => {
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
});

test("source overrides target for same key", () => {
expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 });
});

test("deep nested merge", () => {
expect(deepMerge({ a: { b: 1 } }, { a: { c: 2 } })).toEqual({ a: { b: 1, c: 2 } });
});

test("arrays are replaced entirely, not concatenated", () => {
expect(deepMerge({ a: [1, 2] }, { a: [3] })).toEqual({ a: [3] });
});

test("non-object source returns source", () => {
expect(deepMerge({ a: 1 }, "string")).toBe("string");
});

test("non-object target with object source returns source", () => {
expect(deepMerge("string", { a: 1 })).toEqual({ a: 1 });
});

test("null source returns null (source wins)", () => {
expect(deepMerge({ a: 1 }, null)).toBeNull();
});

test("undefined values in source overwrite target", () => {
expect(deepMerge({ a: 1, b: 2 }, { a: undefined })).toEqual({ a: undefined, b: 2 });
});

test("empty objects merge to empty object", () => {
expect(deepMerge({}, {})).toEqual({});
});

test("deep nested 3+ levels", () => {
const target = { a: { b: { c: 1, d: 2 }, e: 3 } };
const source = { a: { b: { c: 10, f: 4 }, g: 5 } };

expect(deepMerge(target, source)).toEqual({ a: { b: { c: 10, d: 2, f: 4 }, e: 3, g: 5 } });
});

test("prototype pollution via constructor key does not pollute Object.prototype", () => {
const malicious = JSON.parse('{"constructor":{"prototype":{"polluted":true}}}');
const result = deepMerge({}, malicious);

expect(({} as Record<string, unknown>).polluted).toBeUndefined();
expect(result).toHaveProperty("constructor");
});

test("mixed types: source number overwrites target object", () => {
expect(deepMerge({ a: { nested: true } }, { a: 42 })).toEqual({ a: 42 });
});

test("target keys not in source are preserved", () => {
expect(deepMerge({ a: 1, b: 2, c: 3 }, { b: 20 })).toEqual({ a: 1, b: 20, c: 3 });
});
});
141 changes: 141 additions & 0 deletions packages/core/tests/util/mri-utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, expect, test } from "bun:test";
import type { ArgDefinition } from "../../src/command/args";
import type { PositionalDefinition } from "../../src/command/positionals";
import { buildMriOptions, convertNumbers, mapPositionals, validatePositionals } from "../../src/util/mri-utils";

describe("buildMriOptions", () => {
test("boolean args go to boolean array", () => {
const defs: Record<string, ArgDefinition> = { debug: { type: "boolean" }, verbose: { type: "boolean" } };

expect(buildMriOptions(defs)).toMatchObject({ boolean: ["debug", "verbose"], string: [] });
});

test("string and number args go to string array", () => {
const defs: Record<string, ArgDefinition> = { count: { type: "number" }, name: { type: "string" } };

expect(buildMriOptions(defs)).toMatchObject({ boolean: [], string: ["count", "name"] });
});

test("aliases registered correctly", () => {
const defs: Record<string, ArgDefinition> = {
output: { alias: "o", type: "string" },
verbose: { alias: "v", type: "boolean" },
};

expect(buildMriOptions(defs).alias).toMatchObject({ o: "output", v: "verbose" });
});

test("camelCase auto-generates kebab-case alias", () => {
const defs: Record<string, ArgDefinition> = { dryRun: { type: "boolean" }, outputDir: { type: "string" } };

expect(buildMriOptions(defs).alias).toMatchObject({ "dry-run": "dryRun", "output-dir": "outputDir" });
});

test("defaults populated", () => {
const defs: Record<string, ArgDefinition> = {
count: { default: 10, type: "number" },
name: { type: "string" },
verbose: { default: false, type: "boolean" },
};
const opts = buildMriOptions(defs);
expect(opts.default).toEqual({ count: 10, verbose: false });
});
});

describe("convertNumbers", () => {
test("converts string '42' to number 42", () => {
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
const result = convertNumbers({ count: "42" }, defs);
expect(result.count).toBe(42);
});

test("throws on invalid number (NaN)", () => {
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
expect(() => convertNumbers({ count: "abc" }, defs)).toThrow('Invalid number for --count: "abc"');
});

test("skips boolean and string types", () => {
const defs: Record<string, ArgDefinition> = { name: { type: "string" }, verbose: { type: "boolean" } };

expect(convertNumbers({ name: "hello", verbose: true }, defs)).toMatchObject({ name: "hello", verbose: true });
});

test("skips undefined values", () => {
const defs: Record<string, ArgDefinition> = { count: { type: "number" } };
const result = convertNumbers({} as Record<string, string | boolean>, defs);
expect(result.count).toBeUndefined();
});
});

describe("mapPositionals", () => {
test("maps single positional correctly", () => {
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
const result = mapPositionals(["foo"], defs);
expect(result.name).toBe("foo");
});

test("maps multiple positionals by index", () => {
const defs: Record<string, PositionalDefinition> = { dest: { required: true }, source: { required: true } };

expect(mapPositionals(["a.txt", "b.txt"], defs)).toMatchObject({ dest: "a.txt", source: "b.txt" });
});

test("variadic positional captures rest as array", () => {
const defs: Record<string, PositionalDefinition> = { first: { required: true }, rest: { variadic: true } };

expect(mapPositionals(["a", "b", "c", "d"], defs)).toMatchObject({ first: "a", rest: ["b", "c", "d"] });
});

test("missing positional returns undefined", () => {
const defs: Record<string, PositionalDefinition> = { extra: {}, name: { required: true } };

expect(mapPositionals(["foo"], defs)).toMatchObject({ extra: "foo", name: undefined });
});

test("extra positionals silently dropped", () => {
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
const result = mapPositionals(["foo", "bar", "baz"], defs);

expect(result).toEqual({ name: "foo" });
});
});

describe("validatePositionals", () => {
test("returns null when all required present", () => {
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
const result = validatePositionals({ name: "foo" }, defs);
expect(result).toBeNull();
});

test("returns error for missing required", () => {
const defs: Record<string, PositionalDefinition> = { name: { required: true } };
const result = validatePositionals({ name: undefined }, defs);
expect(result).toBe("Missing required argument: <name>");
});

test("returns error for empty variadic required", () => {
const defs: Record<string, PositionalDefinition> = { files: { required: true, variadic: true } };
const result = validatePositionals({ files: [] }, defs);
expect(result).toBe("Missing required argument: <files...>");
});

test("skips non-required", () => {
const defs: Record<string, PositionalDefinition> = { optional: { required: false } };
const result = validatePositionals({ optional: undefined }, defs);
expect(result).toBeNull();
});
});

describe("toKebabCase (via buildMriOptions)", () => {
test("camelCase generates kebab-case alias", () => {
const defs: Record<string, ArgDefinition> = { myLongOption: { type: "string" } };
const opts = buildMriOptions(defs);
expect(opts.alias["my-long-option"]).toBe("myLongOption");
});

test("already kebab-case does not produce duplicate alias", () => {
const defs: Record<string, ArgDefinition> = { "already-kebab": { type: "string" } };
const opts = buildMriOptions(defs);
expect(opts.alias["already-kebab"]).toBeUndefined();
});
});
10 changes: 8 additions & 2 deletions packages/sisyphus/src/commands/actions/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@ import { dirname } from "node:path";
import { args, color, confirm, Exit, log, multiselect, note } from "@r5n/cli-core";
import { BaseCommand, type Ctx } from "../../base-command";
import { detectProvider } from "../../providers";
import { createCiGenerator, GitHubCiGenerator, GitLabCiGenerator, WORKFLOW_CONFIGS, type WorkflowType } from "./CiGenerator";
import {
createCiGenerator,
GitHubCiGenerator,
GitLabCiGenerator,
WORKFLOW_CONFIGS,
type WorkflowType,
} from "./CiGenerator";

const PROVIDER_LABELS = { bitbucket: "Bitbucket Pipelines", github: "GitHub Actions", gitlab: "GitLab CI" };

Expand Down Expand Up @@ -80,7 +86,7 @@ export class ActionsInitCommand extends BaseCommand {

if (generator instanceof GitHubCiGenerator) {
log.info(
`\n${color.yellow("Note:")} Enable ${color.bold("\"Allow GitHub Actions to create and approve pull requests\"")}`,
`\n${color.yellow("Note:")} Enable ${color.bold('"Allow GitHub Actions to create and approve pull requests"')}`,
);
log.info(color.dim("Settings → Actions → General → Workflow permissions"));
log.info("");
Expand Down
4 changes: 3 additions & 1 deletion packages/sisyphus/src/domain/Commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ export class Commit {
const commits: Commit[] = [];

for (let i = 0; i < segments.length; i += 2) {
const fields = segments[i]!.split(FIELD_SEPARATOR);
const segment = segments[i];
if (!segment) continue;
const fields = segment.split(FIELD_SEPARATOR);
if (fields.length < 3) continue;

const [hash, subject, author] = fields;
Expand Down
6 changes: 2 additions & 4 deletions packages/sisyphus/src/providers/GitHubProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,7 @@ export class GitHubProvider extends GitProvider {

async getPrCommits(number: number): Promise<string[]> {
try {
const result =
await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet();
const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}/commits --jq '.[].sha'`.quiet();
return result.stdout.toString().trim().split("\n").filter(Boolean);
} catch {
return [];
Expand All @@ -143,8 +142,7 @@ export class GitHubProvider extends GitProvider {

async getPrFiles(number: number): Promise<string[]> {
try {
const result =
await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet();
const result = await Bun.$`gh api ${this.apiPath}/pulls/${number}/files --jq '.[].filename'`.quiet();
return result.stdout.toString().trim().split("\n").filter(Boolean);
} catch {
return [];
Expand Down
49 changes: 15 additions & 34 deletions packages/sisyphus/src/providers/GitLabProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,7 @@ export class GitLabProvider extends GitProvider {
if (options.head) params.set("source_branch", options.head);
if (options.label) params.set("labels", options.label);

const mrs = await this.api<GitLabMrResponse[]>(
`/projects/${this.projectPath}/merge_requests?${params}`,
);
const mrs = await this.api<GitLabMrResponse[]>(`/projects/${this.projectPath}/merge_requests?${params}`);

if (!mrs[0]) return null;
return this.mapMrResponse(mrs[0]);
Expand All @@ -83,10 +81,10 @@ export class GitLabProvider extends GitProvider {
body.labels = options.labels.join(",");
}

const data = await this.api<GitLabMrResponse>(
`/projects/${this.projectPath}/merge_requests`,
{ body: JSON.stringify(body), method: "POST" },
);
const data = await this.api<GitLabMrResponse>(`/projects/${this.projectPath}/merge_requests`, {
body: JSON.stringify(body),
method: "POST",
});

return this.mapMrResponse(data);
}
Expand All @@ -96,17 +94,15 @@ export class GitLabProvider extends GitProvider {
if (options.title) body.title = options.title;
if (options.body) body.description = options.body;

await this.api(
`/projects/${this.projectPath}/merge_requests/${number}`,
{ body: JSON.stringify(body), method: "PUT" },
);
await this.api(`/projects/${this.projectPath}/merge_requests/${number}`, {
body: JSON.stringify(body),
method: "PUT",
});
}

async getPr(number: number): Promise<PullRequest> {
try {
const data = await this.api<GitLabMrResponse>(
`/projects/${this.projectPath}/merge_requests/${number}`,
);
const data = await this.api<GitLabMrResponse>(`/projects/${this.projectPath}/merge_requests/${number}`);
return this.mapMrResponse(data);
} catch {
throw new Exit(`Failed to fetch MR !${number}`, "Make sure the MR exists and you have access");
Expand All @@ -119,9 +115,7 @@ export class GitLabProvider extends GitProvider {
const branch = result.stdout.toString().trim();

const params = new URLSearchParams({ per_page: "1", source_branch: branch, state: "opened" });
const mrs = await this.api<GitLabMrResponse[]>(
`/projects/${this.projectPath}/merge_requests?${params}`,
);
const mrs = await this.api<GitLabMrResponse[]>(`/projects/${this.projectPath}/merge_requests?${params}`);

if (!mrs[0]) throw new Error("No MR found");
return this.mapMrResponse(mrs[0]);
Expand Down Expand Up @@ -158,29 +152,20 @@ export class GitLabProvider extends GitProvider {
if (options?.description) body.description = options.description;
if (options?.color) body.color = `#${options.color}`;

await this.api(`/projects/${this.projectPath}/labels`, {
body: JSON.stringify(body),
method: "POST",
});
await this.api(`/projects/${this.projectPath}/labels`, { body: JSON.stringify(body), method: "POST" });
} catch {}
}

async createRelease(options: CreateReleaseOptions): Promise<void> {
await this.api(`/projects/${this.projectPath}/releases`, {
body: JSON.stringify({
description: options.notes,
name: options.title,
tag_name: options.tag,
}),
body: JSON.stringify({ description: options.notes, name: options.title, tag_name: options.tag }),
method: "POST",
});
}

async deleteRelease(tag: string): Promise<void> {
try {
await this.api(`/projects/${this.projectPath}/releases/${encodeURIComponent(tag)}`, {
method: "DELETE",
});
await this.api(`/projects/${this.projectPath}/releases/${encodeURIComponent(tag)}`, { method: "DELETE" });
} catch {}
}

Expand All @@ -204,11 +189,7 @@ export class GitLabProvider extends GitProvider {
private async api<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${this.apiUrl}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...this.authHeader,
...options?.headers,
},
headers: { "Content-Type": "application/json", ...this.authHeader, ...options?.headers },
});

if (!response.ok) {
Expand Down
4 changes: 1 addition & 3 deletions packages/sisyphus/src/services/ChangelogGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,7 @@ export class ChangelogGenerator {
const date = this.getDate();
const packageList = packages.map((p) => `${p.name}@${p.newVersion ?? p.version}`).join(", ");

return this.config.rootHeader
.replace("{date}", () => date)
.replace("{packages}", () => packageList);
return this.config.rootHeader.replace("{date}", () => date).replace("{packages}", () => packageList);
}

private formatRootStoneContent(stone: Stone): string {
Expand Down
Loading
Loading