@devory/github 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,167 @@
1
+ /**
2
+ * packages/github/src/lib/pr-create.ts
3
+ *
4
+ * factory-071: Gated PR creation helper.
5
+ *
6
+ * Creates a GitHub PR via the `gh` CLI (https://cli.github.com/).
7
+ * The factory is read-only by default — this module only executes when
8
+ * the caller explicitly passes `options.confirm: true` AND GITHUB_TOKEN
9
+ * is present in the environment.
10
+ *
11
+ * Design constraints:
12
+ * - No side effects unless `options.confirm === true`
13
+ * - `GITHUB_TOKEN` must be present; absent token → PrCreateResult.ok false
14
+ * - `options.branch` must be supplied; the caller is responsible for
15
+ * ensuring the branch exists before calling createPr
16
+ * - Uses `gh pr create` — no direct GitHub API calls
17
+ * - All pure helper functions (canCreatePr, buildGhCreateArgs) are
18
+ * independently testable with no process spawning
19
+ */
20
+
21
+ import { spawnSync } from "child_process";
22
+ import type { TaskMeta } from "@devory/core";
23
+ import { buildPrTitle, buildPrBody } from "./pr-helpers.js";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface PrCreateOptions {
30
+ /**
31
+ * Must be explicitly true to create the PR.
32
+ * When false or absent, createPr() is a no-op and returns ok: false.
33
+ */
34
+ confirm: boolean;
35
+ /** Branch name to create the PR from. Required. */
36
+ branch: string;
37
+ /** Base branch. Defaults to "main". */
38
+ base?: string;
39
+ /** Override environment (injected for testing; defaults to process.env). */
40
+ env?: NodeJS.ProcessEnv;
41
+ }
42
+
43
+ export interface PrCreateResult {
44
+ ok: boolean;
45
+ /** URL of the created PR, if successful. */
46
+ prUrl?: string;
47
+ /** Human-readable reason when ok is false. */
48
+ error?: string;
49
+ /** True when createPr was called without confirm: true (safe no-op). */
50
+ skipped?: boolean;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Guards
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Returns true when GITHUB_TOKEN is present in the given environment.
59
+ * Does not verify the token is valid — only that it is non-empty.
60
+ */
61
+ export function canCreatePr(env: NodeJS.ProcessEnv = process.env): boolean {
62
+ const token = env["GITHUB_TOKEN"];
63
+ return typeof token === "string" && token.trim().length > 0;
64
+ }
65
+
66
+ /**
67
+ * Returns a human-readable reason why PR creation is not possible,
68
+ * or null if creation should be allowed.
69
+ */
70
+ export function prCreateBlockedReason(
71
+ options: PrCreateOptions,
72
+ env: NodeJS.ProcessEnv = process.env
73
+ ): string | null {
74
+ if (!options.confirm) {
75
+ return "PR creation requires --confirm flag";
76
+ }
77
+ if (!options.branch || options.branch.trim().length === 0) {
78
+ return "PR creation requires a branch name (--branch)";
79
+ }
80
+ if (!canCreatePr(env)) {
81
+ return "GITHUB_TOKEN is not set — cannot create PR";
82
+ }
83
+ return null;
84
+ }
85
+
86
+ // ---------------------------------------------------------------------------
87
+ // Invocation building
88
+ // ---------------------------------------------------------------------------
89
+
90
+ /**
91
+ * Build the argument list for `gh pr create`.
92
+ * Pure function — no side effects.
93
+ */
94
+ export function buildGhCreateArgs(
95
+ meta: Partial<TaskMeta>,
96
+ taskBody: string,
97
+ options: Pick<PrCreateOptions, "branch" | "base">
98
+ ): string[] {
99
+ const title = buildPrTitle(meta);
100
+ const body = buildPrBody(meta, taskBody);
101
+ const base = options.base?.trim() || "main";
102
+
103
+ return [
104
+ "pr",
105
+ "create",
106
+ "--title", title,
107
+ "--body", body,
108
+ "--head", options.branch,
109
+ "--base", base,
110
+ ];
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Execution
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Create a GitHub PR via `gh pr create`.
119
+ *
120
+ * Only executes when:
121
+ * 1. `options.confirm === true`
122
+ * 2. GITHUB_TOKEN is present in the environment
123
+ * 3. `options.branch` is non-empty
124
+ *
125
+ * Returns `{ ok: false, skipped: true }` when any guard fails.
126
+ * Returns `{ ok: true, prUrl }` on success.
127
+ * Returns `{ ok: false, error }` on gh execution failure.
128
+ */
129
+ export function createPr(
130
+ meta: Partial<TaskMeta>,
131
+ taskBody: string,
132
+ options: PrCreateOptions
133
+ ): PrCreateResult {
134
+ const env = options.env ?? process.env;
135
+
136
+ const blockedReason = prCreateBlockedReason(options, env);
137
+ if (blockedReason) {
138
+ return { ok: false, skipped: true, error: blockedReason };
139
+ }
140
+
141
+ const args = buildGhCreateArgs(meta, taskBody, options);
142
+
143
+ const result = spawnSync("gh", args, {
144
+ encoding: "utf-8",
145
+ env,
146
+ timeout: 30_000,
147
+ });
148
+
149
+ if (result.error) {
150
+ return {
151
+ ok: false,
152
+ error: `Failed to spawn gh: ${result.error.message}. Is the gh CLI installed?`,
153
+ };
154
+ }
155
+
156
+ if (result.status !== 0) {
157
+ const stderr = (result.stderr ?? "").trim();
158
+ const stdout = (result.stdout ?? "").trim();
159
+ return {
160
+ ok: false,
161
+ error: (stderr || stdout || `gh pr create exited with code ${result.status}`).slice(0, 500),
162
+ };
163
+ }
164
+
165
+ const prUrl = (result.stdout ?? "").trim();
166
+ return { ok: true, prUrl: prUrl || undefined };
167
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * packages/github/src/lib/pr-helpers.ts
3
+ *
4
+ * Pure helpers for generating GitHub PR titles and body text from factory
5
+ * task metadata and body content.
6
+ *
7
+ * No filesystem access. Depends only on @devory/core types.
8
+ *
9
+ * All functions are deterministic and side-effect free so they can be tested
10
+ * in isolation and composed into GitHub Actions, CLI commands, or UI flows.
11
+ */
12
+
13
+ import type { TaskMeta } from "@devory/core";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Conventional-commit type table (reused for PR titles)
17
+ // ---------------------------------------------------------------------------
18
+
19
+ const COMMIT_TYPE_MAP: Record<string, string> = {
20
+ feature: "feat",
21
+ feat: "feat",
22
+ bugfix: "fix",
23
+ bug: "fix",
24
+ fix: "fix",
25
+ refactor: "refactor",
26
+ chore: "chore",
27
+ documentation: "docs",
28
+ docs: "docs",
29
+ test: "test",
30
+ tests: "test",
31
+ perf: "perf",
32
+ performance: "perf",
33
+ subtask: "feat",
34
+ };
35
+
36
+ /** Map task type to a conventional-commit prefix. Defaults to "feat". */
37
+ export function commitType(taskType: string | undefined): string {
38
+ const t = (taskType ?? "").toLowerCase().trim();
39
+ return COMMIT_TYPE_MAP[t] ?? "feat";
40
+ }
41
+
42
+ /** Derive a short scope label. Priority: repo_area → lane → project → "core". */
43
+ export function taskScope(meta: Partial<TaskMeta>): string {
44
+ const repoArea = typeof meta.repo_area === "string" ? meta.repo_area.trim() : "";
45
+ const lane = typeof meta.lane === "string" ? meta.lane.trim() : "";
46
+ const project = typeof meta.project === "string" ? meta.project.trim() : "";
47
+ return repoArea || lane || project || "core";
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // PR title
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Build a PR title in conventional-commit format:
56
+ * `<type>(<scope>): <task title>`
57
+ *
58
+ * Truncated to 72 characters to fit GitHub's recommended title length.
59
+ */
60
+ export function buildPrTitle(meta: Partial<TaskMeta>): string {
61
+ const type = commitType(typeof meta.type === "string" ? meta.type : undefined);
62
+ const scope = taskScope(meta);
63
+ const title = typeof meta.title === "string" ? meta.title.trim() : "(untitled)";
64
+ const line = `${type}(${scope}): ${title}`;
65
+ return line.length > 72 ? line.slice(0, 71) + "…" : line;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // PR body
70
+ // ---------------------------------------------------------------------------
71
+
72
+ export interface PrMetadata {
73
+ title: string;
74
+ body: string;
75
+ }
76
+
77
+ /**
78
+ * Build a structured PR description from task metadata and task body text.
79
+ *
80
+ * The body follows GitHub's recommended PR template structure:
81
+ * - Summary section (task fields)
82
+ * - Context section (task body if present)
83
+ * - Verification checklist (from task's verification field)
84
+ * - Factory metadata footer
85
+ */
86
+ export function buildPrBody(meta: Partial<TaskMeta>, taskBody: string): string {
87
+ const lines: string[] = [];
88
+
89
+ // ── Summary ─────────────────────────────────────────────────────────────
90
+ lines.push("## Summary", "");
91
+ lines.push(`- **Task:** ${meta.id ?? "(unknown)"} — ${meta.title ?? "(untitled)"}`);
92
+ lines.push(`- **Project:** ${meta.project ?? "(unknown)"}`);
93
+ lines.push(`- **Type:** ${meta.type ?? "feature"}`);
94
+ lines.push(`- **Priority:** ${meta.priority ?? "medium"}`);
95
+ lines.push(`- **Agent:** ${meta.agent ?? "(none)"}`);
96
+
97
+ const deps = Array.isArray(meta.depends_on) ? meta.depends_on : [];
98
+ if (deps.length > 0) {
99
+ lines.push(`- **Depends on:** ${deps.join(", ")}`);
100
+ }
101
+ lines.push("");
102
+
103
+ // ── Context ──────────────────────────────────────────────────────────────
104
+ const trimmedBody = (taskBody ?? "").trim();
105
+ if (trimmedBody) {
106
+ lines.push("## Context", "");
107
+ // Include up to ~30 lines of the task body to keep the PR description focused
108
+ const bodyLines = trimmedBody.split("\n").slice(0, 30);
109
+ lines.push(...bodyLines);
110
+ lines.push("");
111
+ }
112
+
113
+ // ── Verification ──────────────────────────────────────────────────────────
114
+ const verification = Array.isArray(meta.verification) ? meta.verification : [];
115
+ if (verification.length > 0) {
116
+ lines.push("## Verification", "");
117
+ for (const cmd of verification) {
118
+ lines.push(`- [ ] \`${cmd}\``);
119
+ }
120
+ lines.push("");
121
+ }
122
+
123
+ // ── Footer ───────────────────────────────────────────────────────────────
124
+ lines.push("---");
125
+ lines.push("*Generated by Devory · ai-dev-factory — human review required before merge.*");
126
+
127
+ return lines.join("\n");
128
+ }
129
+
130
+ /**
131
+ * Build a complete PR metadata object (title + body) from task metadata.
132
+ */
133
+ export function buildPrMetadata(
134
+ meta: Partial<TaskMeta>,
135
+ taskBody: string
136
+ ): PrMetadata {
137
+ return {
138
+ title: buildPrTitle(meta),
139
+ body: buildPrBody(meta, taskBody),
140
+ };
141
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * packages/github/src/test/action-helpers.test.ts
3
+ *
4
+ * Tests for src/lib/action-helpers.ts.
5
+ * Verifies the correct output format without actually writing to env files.
6
+ *
7
+ * Run: tsx --test packages/github/src/test/action-helpers.test.ts
8
+ */
9
+
10
+ import { test, describe, before, after } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import * as fs from "fs";
13
+ import * as os from "os";
14
+ import * as path from "path";
15
+
16
+ import {
17
+ setOutput,
18
+ setOutputs,
19
+ setEnv,
20
+ appendStepSummary,
21
+ isGitHubActions,
22
+ getRunId,
23
+ getRepoSlug,
24
+ } from "../lib/action-helpers.js";
25
+
26
+ // ── Return value tests (no env files) ─────────────────────────────────────
27
+
28
+ describe("setOutput return value", () => {
29
+ test("returns name=value string", () => {
30
+ const line = setOutput("branch", "feat/factory-066");
31
+ assert.equal(line, "branch=feat/factory-066");
32
+ });
33
+
34
+ test("handles values with spaces", () => {
35
+ const line = setOutput("title", "feat(core): My Title");
36
+ assert.equal(line, "title=feat(core): My Title");
37
+ });
38
+ });
39
+
40
+ describe("setOutputs return value", () => {
41
+ test("returns array of name=value strings", () => {
42
+ const lines = setOutputs({ branch: "feat/x", title: "feat(p): T" });
43
+ assert.equal(lines.length, 2);
44
+ assert.ok(lines.includes("branch=feat/x"));
45
+ assert.ok(lines.includes("title=feat(p): T"));
46
+ });
47
+
48
+ test("returns empty array for empty input", () => {
49
+ const lines = setOutputs({});
50
+ assert.deepEqual(lines, []);
51
+ });
52
+ });
53
+
54
+ describe("setEnv return value", () => {
55
+ test("returns NAME=value string", () => {
56
+ const line = setEnv("DEVORY_BRANCH", "feat/my-branch");
57
+ assert.equal(line, "DEVORY_BRANCH=feat/my-branch");
58
+ });
59
+ });
60
+
61
+ describe("appendStepSummary return value", () => {
62
+ test("returns the markdown passed in", () => {
63
+ const md = "# My Summary\nSome content.";
64
+ const result = appendStepSummary(md);
65
+ assert.equal(result, md);
66
+ });
67
+ });
68
+
69
+ // ── Detection helpers ──────────────────────────────────────────────────────
70
+
71
+ describe("isGitHubActions", () => {
72
+ test("returns false when GITHUB_ACTIONS is not set", () => {
73
+ const saved = process.env.GITHUB_ACTIONS;
74
+ delete process.env.GITHUB_ACTIONS;
75
+ assert.equal(isGitHubActions(), false);
76
+ if (saved !== undefined) process.env.GITHUB_ACTIONS = saved;
77
+ });
78
+
79
+ test("returns true when GITHUB_ACTIONS=true", () => {
80
+ const saved = process.env.GITHUB_ACTIONS;
81
+ process.env.GITHUB_ACTIONS = "true";
82
+ assert.equal(isGitHubActions(), true);
83
+ if (saved !== undefined) process.env.GITHUB_ACTIONS = saved;
84
+ else delete process.env.GITHUB_ACTIONS;
85
+ });
86
+ });
87
+
88
+ describe("getRunId", () => {
89
+ test("returns null when GITHUB_RUN_ID not set", () => {
90
+ const saved = process.env.GITHUB_RUN_ID;
91
+ delete process.env.GITHUB_RUN_ID;
92
+ assert.equal(getRunId(), null);
93
+ if (saved !== undefined) process.env.GITHUB_RUN_ID = saved;
94
+ });
95
+
96
+ test("returns run ID string when set", () => {
97
+ const saved = process.env.GITHUB_RUN_ID;
98
+ process.env.GITHUB_RUN_ID = "12345";
99
+ assert.equal(getRunId(), "12345");
100
+ if (saved !== undefined) process.env.GITHUB_RUN_ID = saved;
101
+ else delete process.env.GITHUB_RUN_ID;
102
+ });
103
+ });
104
+
105
+ describe("getRepoSlug", () => {
106
+ test("returns null when GITHUB_REPOSITORY not set", () => {
107
+ const saved = process.env.GITHUB_REPOSITORY;
108
+ delete process.env.GITHUB_REPOSITORY;
109
+ assert.equal(getRepoSlug(), null);
110
+ if (saved !== undefined) process.env.GITHUB_REPOSITORY = saved;
111
+ });
112
+
113
+ test("returns owner/repo when set", () => {
114
+ const saved = process.env.GITHUB_REPOSITORY;
115
+ process.env.GITHUB_REPOSITORY = "devory/ai-dev-factory";
116
+ assert.equal(getRepoSlug(), "devory/ai-dev-factory");
117
+ if (saved !== undefined) process.env.GITHUB_REPOSITORY = saved;
118
+ else delete process.env.GITHUB_REPOSITORY;
119
+ });
120
+ });
121
+
122
+ // ── Live file writing (with GITHUB_OUTPUT set) ─────────────────────────────
123
+
124
+ describe("setOutput file writing", () => {
125
+ let tmpDir: string;
126
+ let outputFile: string;
127
+ const originalEnv = process.env.GITHUB_OUTPUT;
128
+
129
+ before(() => {
130
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "devory-gh-test-"));
131
+ outputFile = path.join(tmpDir, "output");
132
+ fs.writeFileSync(outputFile, "", "utf-8");
133
+ process.env.GITHUB_OUTPUT = outputFile;
134
+ });
135
+
136
+ after(() => {
137
+ fs.rmSync(tmpDir, { recursive: true, force: true });
138
+ if (originalEnv !== undefined) {
139
+ process.env.GITHUB_OUTPUT = originalEnv;
140
+ } else {
141
+ delete process.env.GITHUB_OUTPUT;
142
+ }
143
+ });
144
+
145
+ test("appends name=value to GITHUB_OUTPUT file", () => {
146
+ setOutput("my-key", "my-value");
147
+ const content = fs.readFileSync(outputFile, "utf-8");
148
+ assert.ok(content.includes("my-key=my-value"));
149
+ });
150
+
151
+ test("multiple outputs are each on their own line", () => {
152
+ fs.writeFileSync(outputFile, "", "utf-8"); // reset
153
+ setOutput("a", "1");
154
+ setOutput("b", "2");
155
+ const lines = fs.readFileSync(outputFile, "utf-8").trim().split("\n");
156
+ assert.equal(lines.length, 2);
157
+ });
158
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * packages/github/src/test/branch-helpers.test.ts
3
+ *
4
+ * Tests for src/lib/branch-helpers.ts.
5
+ * Run: tsx --test packages/github/src/test/branch-helpers.test.ts
6
+ */
7
+
8
+ import { test, describe } from "node:test";
9
+ import assert from "node:assert/strict";
10
+
11
+ import {
12
+ slugify,
13
+ branchPrefix,
14
+ buildBranchName,
15
+ } from "../lib/branch-helpers.js";
16
+
17
+ // ── slugify ────────────────────────────────────────────────────────────────
18
+
19
+ describe("slugify", () => {
20
+ test("lowercases input", () => {
21
+ assert.equal(slugify("Hello World"), "hello-world");
22
+ });
23
+
24
+ test("replaces spaces with hyphens", () => {
25
+ assert.equal(slugify("foo bar baz"), "foo-bar-baz");
26
+ });
27
+
28
+ test("collapses multiple non-alphanumeric chars to one hyphen", () => {
29
+ assert.equal(slugify("foo -- bar"), "foo-bar");
30
+ });
31
+
32
+ test("strips leading and trailing hyphens", () => {
33
+ assert.equal(slugify(" hello "), "hello");
34
+ });
35
+
36
+ test("handles numbers", () => {
37
+ assert.equal(slugify("Factory 066 MVP"), "factory-066-mvp");
38
+ });
39
+
40
+ test("truncates at maxLen", () => {
41
+ const long = "a".repeat(100);
42
+ assert.equal(slugify(long, 10).length, 10);
43
+ });
44
+
45
+ test("empty string returns empty string", () => {
46
+ assert.equal(slugify(""), "");
47
+ });
48
+
49
+ test("strips special characters", () => {
50
+ assert.equal(slugify("feat: add @devory/core"), "feat-add-devory-core");
51
+ });
52
+ });
53
+
54
+ // ── branchPrefix ──────────────────────────────────────────────────────────
55
+
56
+ describe("branchPrefix", () => {
57
+ test("feature → feat", () => assert.equal(branchPrefix("feature"), "feat"));
58
+ test("feat → feat", () => assert.equal(branchPrefix("feat"), "feat"));
59
+ test("bugfix → fix", () => assert.equal(branchPrefix("bugfix"), "fix"));
60
+ test("bug → fix", () => assert.equal(branchPrefix("bug"), "fix"));
61
+ test("refactor → refactor", () => assert.equal(branchPrefix("refactor"), "refactor"));
62
+ test("chore → chore", () => assert.equal(branchPrefix("chore"), "chore"));
63
+ test("docs → docs", () => assert.equal(branchPrefix("docs"), "docs"));
64
+ test("documentation → docs", () => assert.equal(branchPrefix("documentation"), "docs"));
65
+ test("unknown type defaults to task", () => assert.equal(branchPrefix("unknown"), "task"));
66
+ test("undefined defaults to task", () => assert.equal(branchPrefix(undefined), "task"));
67
+ test("case-insensitive", () => assert.equal(branchPrefix("FEATURE"), "feat"));
68
+ });
69
+
70
+ // ── buildBranchName ────────────────────────────────────────────────────────
71
+
72
+ describe("buildBranchName — task-meta source", () => {
73
+ test("uses meta.branch when present", () => {
74
+ const r = buildBranchName({ branch: "task/factory-066-my-feature" });
75
+ assert.equal(r.branch, "task/factory-066-my-feature");
76
+ assert.equal(r.source, "task-meta");
77
+ assert.equal(r.warnings.length, 0);
78
+ });
79
+
80
+ test("uses meta.branch even if type/id/title are present", () => {
81
+ const r = buildBranchName({
82
+ branch: "custom/my-branch",
83
+ id: "factory-001",
84
+ title: "Something else",
85
+ type: "feature",
86
+ });
87
+ assert.equal(r.branch, "custom/my-branch");
88
+ assert.equal(r.source, "task-meta");
89
+ });
90
+
91
+ test("trims whitespace from meta.branch", () => {
92
+ const r = buildBranchName({ branch: " feat/some-branch " });
93
+ assert.equal(r.branch, "feat/some-branch");
94
+ });
95
+ });
96
+
97
+ describe("buildBranchName — derived source", () => {
98
+ test("derives from id and title when no branch field", () => {
99
+ const r = buildBranchName({
100
+ id: "factory-066",
101
+ title: "GitHub Integration MVP",
102
+ type: "feature",
103
+ });
104
+ assert.equal(r.source, "derived");
105
+ assert.ok(r.branch.includes("factory-066"));
106
+ assert.ok(r.branch.includes("github-integration-mvp"));
107
+ });
108
+
109
+ test("branch starts with type-based prefix", () => {
110
+ const r = buildBranchName({ id: "x", title: "y", type: "bugfix" });
111
+ assert.ok(r.branch.startsWith("fix/"));
112
+ });
113
+
114
+ test("branch starts with 'task/' for unknown type", () => {
115
+ const r = buildBranchName({ id: "x", title: "y", type: "unknown" });
116
+ assert.ok(r.branch.startsWith("task/"));
117
+ });
118
+
119
+ test("branch starts with 'task/' when type is missing", () => {
120
+ const r = buildBranchName({ id: "x", title: "y" });
121
+ assert.ok(r.branch.startsWith("task/"));
122
+ });
123
+
124
+ test("branch contains id", () => {
125
+ const r = buildBranchName({ id: "factory-099", title: "My Task" });
126
+ assert.ok(r.branch.includes("factory-099"));
127
+ });
128
+
129
+ test("branch slug is lowercase", () => {
130
+ const r = buildBranchName({ id: "x", title: "Hello World" });
131
+ assert.equal(r.branch, r.branch.toLowerCase());
132
+ });
133
+
134
+ test("handles title with special chars", () => {
135
+ const r = buildBranchName({ id: "f-001", title: "Add @devory/core to repo" });
136
+ assert.ok(!r.branch.includes("@"));
137
+ assert.ok(!r.branch.includes("/devory"));
138
+ });
139
+
140
+ test("falls back to id-only when title is empty", () => {
141
+ const r = buildBranchName({ id: "factory-001", title: "" });
142
+ assert.equal(r.source, "derived");
143
+ assert.ok(r.branch.includes("factory-001"));
144
+ assert.equal(r.warnings.length, 1);
145
+ });
146
+
147
+ test("returns fallback branch when id and title both empty", () => {
148
+ const r = buildBranchName({});
149
+ assert.equal(r.branch, "task/unnamed");
150
+ assert.ok(r.warnings.length > 0);
151
+ });
152
+ });
153
+
154
+ describe("buildBranchName — branch name format", () => {
155
+ test("branch contains no whitespace", () => {
156
+ const r = buildBranchName({ id: "factory-066", title: "Multi Word Title Here" });
157
+ assert.ok(!/\s/.test(r.branch));
158
+ });
159
+
160
+ test("branch length is reasonable (under 80 chars)", () => {
161
+ const r = buildBranchName({
162
+ id: "factory-066",
163
+ title: "A very long task title that might overflow the branch name limit",
164
+ type: "feature",
165
+ });
166
+ assert.ok(r.branch.length <= 80);
167
+ });
168
+ });