@devory/github 0.0.1 → 0.3.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.
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @devory/github
2
+
3
+ GitHub integration utilities for [Devory](https://devory.ai) — branch naming, PR metadata, and GitHub Actions helpers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @devory/github
9
+ ```
10
+
11
+ ## API
12
+
13
+ ### Branch helpers
14
+
15
+ ```ts
16
+ import { buildBranchName, branchPrefix, slugify } from '@devory/github'
17
+
18
+ const { branch } = buildBranchName(task)
19
+ // e.g. "feat/task-042-add-login-page"
20
+ ```
21
+
22
+ ### PR helpers
23
+
24
+ ```ts
25
+ import { buildPrMetadata } from '@devory/github'
26
+
27
+ const { title, body } = buildPrMetadata(task)
28
+ ```
29
+
30
+ ### GitHub Actions helpers
31
+
32
+ ```ts
33
+ import { setOutput, setEnv, appendStepSummary, isGitHubActions } from '@devory/github'
34
+
35
+ if (isGitHubActions()) {
36
+ setOutput('branch', branch)
37
+ appendStepSummary('## Run complete')
38
+ }
39
+ ```
40
+
41
+ ### PR creation
42
+
43
+ ```ts
44
+ import { createPr } from '@devory/github'
45
+
46
+ // Requires GITHUB_TOKEN in environment
47
+ const result = await createPr({ task, branch, base: 'main', confirm: true })
48
+ ```
49
+
50
+ ## Requirements
51
+
52
+ - Node.js 18+
53
+ - `GITHUB_TOKEN` env var for PR creation commands
54
+ - A Devory factory workspace — sign up at [devory.ai](https://devory.ai)
package/dist/index.js ADDED
@@ -0,0 +1,295 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ appendStepSummary: () => appendStepSummary,
34
+ branchPrefix: () => branchPrefix,
35
+ buildBranchName: () => buildBranchName,
36
+ buildGhCreateArgs: () => buildGhCreateArgs,
37
+ buildPrBody: () => buildPrBody,
38
+ buildPrMetadata: () => buildPrMetadata,
39
+ buildPrTitle: () => buildPrTitle,
40
+ canCreatePr: () => canCreatePr,
41
+ commitType: () => commitType,
42
+ createPr: () => createPr,
43
+ getRepoSlug: () => getRepoSlug,
44
+ getRunId: () => getRunId,
45
+ isGitHubActions: () => isGitHubActions,
46
+ prCreateBlockedReason: () => prCreateBlockedReason,
47
+ setEnv: () => setEnv,
48
+ setOutput: () => setOutput,
49
+ setOutputs: () => setOutputs,
50
+ slugify: () => slugify,
51
+ taskScope: () => taskScope
52
+ });
53
+ module.exports = __toCommonJS(src_exports);
54
+
55
+ // src/lib/branch-helpers.ts
56
+ var BRANCH_PREFIX_MAP = {
57
+ feature: "feat",
58
+ feat: "feat",
59
+ bugfix: "fix",
60
+ bug: "fix",
61
+ fix: "fix",
62
+ refactor: "refactor",
63
+ chore: "chore",
64
+ documentation: "docs",
65
+ docs: "docs",
66
+ test: "test",
67
+ tests: "test",
68
+ perf: "perf",
69
+ performance: "perf"
70
+ };
71
+ function branchPrefix(taskType) {
72
+ const t = (taskType ?? "").toLowerCase().trim();
73
+ return BRANCH_PREFIX_MAP[t] ?? "task";
74
+ }
75
+ function slugify(s, maxLen = 50) {
76
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLen);
77
+ }
78
+ function buildBranchName(meta) {
79
+ const warnings = [];
80
+ if (typeof meta.branch === "string" && meta.branch.trim()) {
81
+ return { branch: meta.branch.trim(), source: "task-meta", warnings };
82
+ }
83
+ const id = typeof meta.id === "string" ? meta.id.trim() : "";
84
+ const title = typeof meta.title === "string" ? meta.title.trim() : "";
85
+ const prefix = branchPrefix(typeof meta.type === "string" ? meta.type : void 0);
86
+ const titleSlug = slugify(title, 40);
87
+ if (id && titleSlug) {
88
+ const branch = `${prefix}/${id}-${titleSlug}`;
89
+ return { branch, source: "derived", warnings };
90
+ }
91
+ if (id) {
92
+ warnings.push("task title is empty; branch slug uses id only");
93
+ return { branch: `${prefix}/${id}`, source: "derived", warnings };
94
+ }
95
+ warnings.push("task has no id or title; using fallback branch name");
96
+ return { branch: "task/unnamed", source: "derived", warnings };
97
+ }
98
+
99
+ // src/lib/pr-helpers.ts
100
+ var COMMIT_TYPE_MAP = {
101
+ feature: "feat",
102
+ feat: "feat",
103
+ bugfix: "fix",
104
+ bug: "fix",
105
+ fix: "fix",
106
+ refactor: "refactor",
107
+ chore: "chore",
108
+ documentation: "docs",
109
+ docs: "docs",
110
+ test: "test",
111
+ tests: "test",
112
+ perf: "perf",
113
+ performance: "perf",
114
+ subtask: "feat"
115
+ };
116
+ function commitType(taskType) {
117
+ const t = (taskType ?? "").toLowerCase().trim();
118
+ return COMMIT_TYPE_MAP[t] ?? "feat";
119
+ }
120
+ function taskScope(meta) {
121
+ const repoArea = typeof meta.repo_area === "string" ? meta.repo_area.trim() : "";
122
+ const lane = typeof meta.lane === "string" ? meta.lane.trim() : "";
123
+ const project = typeof meta.project === "string" ? meta.project.trim() : "";
124
+ return repoArea || lane || project || "core";
125
+ }
126
+ function buildPrTitle(meta) {
127
+ const type = commitType(typeof meta.type === "string" ? meta.type : void 0);
128
+ const scope = taskScope(meta);
129
+ const title = typeof meta.title === "string" ? meta.title.trim() : "(untitled)";
130
+ const line = `${type}(${scope}): ${title}`;
131
+ return line.length > 72 ? line.slice(0, 71) + "\u2026" : line;
132
+ }
133
+ function buildPrBody(meta, taskBody) {
134
+ const lines = [];
135
+ lines.push("## Summary", "");
136
+ lines.push(`- **Task:** ${meta.id ?? "(unknown)"} \u2014 ${meta.title ?? "(untitled)"}`);
137
+ lines.push(`- **Project:** ${meta.project ?? "(unknown)"}`);
138
+ lines.push(`- **Type:** ${meta.type ?? "feature"}`);
139
+ lines.push(`- **Priority:** ${meta.priority ?? "medium"}`);
140
+ lines.push(`- **Agent:** ${meta.agent ?? "(none)"}`);
141
+ const deps = Array.isArray(meta.depends_on) ? meta.depends_on : [];
142
+ if (deps.length > 0) {
143
+ lines.push(`- **Depends on:** ${deps.join(", ")}`);
144
+ }
145
+ lines.push("");
146
+ const trimmedBody = (taskBody ?? "").trim();
147
+ if (trimmedBody) {
148
+ lines.push("## Context", "");
149
+ const bodyLines = trimmedBody.split("\n").slice(0, 30);
150
+ lines.push(...bodyLines);
151
+ lines.push("");
152
+ }
153
+ const verification = Array.isArray(meta.verification) ? meta.verification : [];
154
+ if (verification.length > 0) {
155
+ lines.push("## Verification", "");
156
+ for (const cmd of verification) {
157
+ lines.push(`- [ ] \`${cmd}\``);
158
+ }
159
+ lines.push("");
160
+ }
161
+ lines.push("---");
162
+ lines.push("*Generated by Devory \xB7 ai-dev-factory \u2014 human review required before merge.*");
163
+ return lines.join("\n");
164
+ }
165
+ function buildPrMetadata(meta, taskBody) {
166
+ return {
167
+ title: buildPrTitle(meta),
168
+ body: buildPrBody(meta, taskBody)
169
+ };
170
+ }
171
+
172
+ // src/lib/action-helpers.ts
173
+ var fs = __toESM(require("fs"));
174
+ function setOutput(name, value) {
175
+ const line = `${name}=${value}`;
176
+ const outputFile = process.env.GITHUB_OUTPUT;
177
+ if (outputFile) {
178
+ fs.appendFileSync(outputFile, line + "\n", "utf-8");
179
+ }
180
+ return line;
181
+ }
182
+ function setOutputs(pairs) {
183
+ return Object.entries(pairs).map(([name, value]) => setOutput(name, value));
184
+ }
185
+ function setEnv(name, value) {
186
+ const line = `${name}=${value}`;
187
+ const envFile = process.env.GITHUB_ENV;
188
+ if (envFile) {
189
+ fs.appendFileSync(envFile, line + "\n", "utf-8");
190
+ }
191
+ return line;
192
+ }
193
+ function appendStepSummary(markdown) {
194
+ const summaryFile = process.env.GITHUB_STEP_SUMMARY;
195
+ if (summaryFile) {
196
+ fs.appendFileSync(summaryFile, markdown + "\n", "utf-8");
197
+ }
198
+ return markdown;
199
+ }
200
+ function isGitHubActions() {
201
+ return process.env.GITHUB_ACTIONS === "true";
202
+ }
203
+ function getRunId() {
204
+ return process.env.GITHUB_RUN_ID ?? null;
205
+ }
206
+ function getRepoSlug() {
207
+ return process.env.GITHUB_REPOSITORY ?? null;
208
+ }
209
+
210
+ // src/lib/pr-create.ts
211
+ var import_child_process = require("child_process");
212
+ function canCreatePr(env = process.env) {
213
+ const token = env["GITHUB_TOKEN"];
214
+ return typeof token === "string" && token.trim().length > 0;
215
+ }
216
+ function prCreateBlockedReason(options, env = process.env) {
217
+ if (!options.confirm) {
218
+ return "PR creation requires --confirm flag";
219
+ }
220
+ if (!options.branch || options.branch.trim().length === 0) {
221
+ return "PR creation requires a branch name (--branch)";
222
+ }
223
+ if (!canCreatePr(env)) {
224
+ return "GITHUB_TOKEN is not set \u2014 cannot create PR";
225
+ }
226
+ return null;
227
+ }
228
+ function buildGhCreateArgs(meta, taskBody, options) {
229
+ const title = buildPrTitle(meta);
230
+ const body = buildPrBody(meta, taskBody);
231
+ const base = options.base?.trim() || "main";
232
+ return [
233
+ "pr",
234
+ "create",
235
+ "--title",
236
+ title,
237
+ "--body",
238
+ body,
239
+ "--head",
240
+ options.branch,
241
+ "--base",
242
+ base
243
+ ];
244
+ }
245
+ function createPr(meta, taskBody, options) {
246
+ const env = options.env ?? process.env;
247
+ const blockedReason = prCreateBlockedReason(options, env);
248
+ if (blockedReason) {
249
+ return { ok: false, skipped: true, error: blockedReason };
250
+ }
251
+ const args = buildGhCreateArgs(meta, taskBody, options);
252
+ const result = (0, import_child_process.spawnSync)("gh", args, {
253
+ encoding: "utf-8",
254
+ env,
255
+ timeout: 3e4
256
+ });
257
+ if (result.error) {
258
+ return {
259
+ ok: false,
260
+ error: `Failed to spawn gh: ${result.error.message}. Is the gh CLI installed?`
261
+ };
262
+ }
263
+ if (result.status !== 0) {
264
+ const stderr = (result.stderr ?? "").trim();
265
+ const stdout = (result.stdout ?? "").trim();
266
+ return {
267
+ ok: false,
268
+ error: (stderr || stdout || `gh pr create exited with code ${result.status}`).slice(0, 500)
269
+ };
270
+ }
271
+ const prUrl = (result.stdout ?? "").trim();
272
+ return { ok: true, prUrl: prUrl || void 0 };
273
+ }
274
+ // Annotate the CommonJS export names for ESM import in node:
275
+ 0 && (module.exports = {
276
+ appendStepSummary,
277
+ branchPrefix,
278
+ buildBranchName,
279
+ buildGhCreateArgs,
280
+ buildPrBody,
281
+ buildPrMetadata,
282
+ buildPrTitle,
283
+ canCreatePr,
284
+ commitType,
285
+ createPr,
286
+ getRepoSlug,
287
+ getRunId,
288
+ isGitHubActions,
289
+ prCreateBlockedReason,
290
+ setEnv,
291
+ setOutput,
292
+ setOutputs,
293
+ slugify,
294
+ taskScope
295
+ });
package/package.json CHANGED
@@ -1,7 +1,33 @@
1
1
  {
2
2
  "name": "@devory/github",
3
- "version": "0.0.1",
4
- "description": "Devory GitHub platform",
5
- "main": "index.js",
6
- "license": "MIT"
3
+ "version": "0.3.0",
4
+ "description": "Devory GitHub integration — branch naming, PR helpers, and GitHub Actions support",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/devoryai/devory"
8
+ },
9
+ "main": "./dist/index.js",
10
+ "types": "./src/index.ts",
11
+ "exports": {
12
+ ".": "./dist/index.js"
13
+ },
14
+ "files": [
15
+ "dist/",
16
+ "src/",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "esbuild src/index.ts --bundle --outfile=dist/index.js --platform=node --format=cjs",
21
+ "prepublishOnly": "npm run build",
22
+ "test": "tsx --test src/test/branch-helpers.test.ts src/test/pr-helpers.test.ts src/test/action-helpers.test.ts"
23
+ },
24
+ "dependencies": {
25
+ "@devory/cli": "0.3.0",
26
+ "@devory/core": "0.3.0"
27
+ },
28
+ "devDependencies": {
29
+ "esbuild": "^0.20.0",
30
+ "tsx": "^4.19.2",
31
+ "typescript": "^5.7.3"
32
+ }
7
33
  }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * packages/github/src/index.ts
3
+ *
4
+ * Public API surface of @devory/github.
5
+ *
6
+ * All exports are pure functions or typed constants — no filesystem side
7
+ * effects except where explicitly documented (action-helpers.ts writes to
8
+ * GITHUB_OUTPUT / GITHUB_ENV / GITHUB_STEP_SUMMARY).
9
+ */
10
+
11
+ // ── Branch helpers ──────────────────────────────────────────────────────────
12
+ export {
13
+ buildBranchName,
14
+ branchPrefix,
15
+ slugify,
16
+ } from "./lib/branch-helpers.js";
17
+ export type { BranchResult } from "./lib/branch-helpers.js";
18
+
19
+ // ── PR helpers ──────────────────────────────────────────────────────────────
20
+ export {
21
+ buildPrTitle,
22
+ buildPrBody,
23
+ buildPrMetadata,
24
+ commitType,
25
+ taskScope,
26
+ } from "./lib/pr-helpers.js";
27
+ export type { PrMetadata } from "./lib/pr-helpers.js";
28
+
29
+ // ── GitHub Actions helpers ──────────────────────────────────────────────────
30
+ export {
31
+ setOutput,
32
+ setOutputs,
33
+ setEnv,
34
+ appendStepSummary,
35
+ isGitHubActions,
36
+ getRunId,
37
+ getRepoSlug,
38
+ } from "./lib/action-helpers.js";
39
+
40
+ // ── PR creation (gated) ──────────────────────────────────────────────────────
41
+ export {
42
+ canCreatePr,
43
+ prCreateBlockedReason,
44
+ buildGhCreateArgs,
45
+ createPr,
46
+ } from "./lib/pr-create.js";
47
+ export type { PrCreateOptions, PrCreateResult } from "./lib/pr-create.js";
@@ -0,0 +1,99 @@
1
+ /**
2
+ * packages/github/src/lib/action-helpers.ts
3
+ *
4
+ * Helpers for emitting outputs and environment variables in a GitHub Actions
5
+ * step context.
6
+ *
7
+ * Two modes:
8
+ * - Live (default): writes to GITHUB_OUTPUT / GITHUB_ENV files per the
9
+ * Actions protocol.
10
+ * - Dry-run / test: returns the lines that *would* be written without any
11
+ * filesystem side effects.
12
+ *
13
+ * Reference:
14
+ * https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions
15
+ */
16
+
17
+ import * as fs from "fs";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Output writing (GITHUB_OUTPUT)
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Set a step output value. The `name=value` line is appended to the file
25
+ * pointed to by the GITHUB_OUTPUT environment variable.
26
+ *
27
+ * @returns The line that was (or would be) written.
28
+ */
29
+ export function setOutput(name: string, value: string): string {
30
+ const line = `${name}=${value}`;
31
+ const outputFile = process.env.GITHUB_OUTPUT;
32
+ if (outputFile) {
33
+ fs.appendFileSync(outputFile, line + "\n", "utf-8");
34
+ }
35
+ return line;
36
+ }
37
+
38
+ /**
39
+ * Set multiple step outputs at once.
40
+ * @returns Array of `name=value` lines written.
41
+ */
42
+ export function setOutputs(pairs: Record<string, string>): string[] {
43
+ return Object.entries(pairs).map(([name, value]) => setOutput(name, value));
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Environment variable writing (GITHUB_ENV)
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Export an environment variable for subsequent steps.
52
+ * Appends `NAME=VALUE` to the GITHUB_ENV file.
53
+ *
54
+ * @returns The line that was (or would be) written.
55
+ */
56
+ export function setEnv(name: string, value: string): string {
57
+ const line = `${name}=${value}`;
58
+ const envFile = process.env.GITHUB_ENV;
59
+ if (envFile) {
60
+ fs.appendFileSync(envFile, line + "\n", "utf-8");
61
+ }
62
+ return line;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Step summary (GITHUB_STEP_SUMMARY)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Append markdown content to the GitHub Actions step summary page.
71
+ *
72
+ * @returns The content that was (or would be) written.
73
+ */
74
+ export function appendStepSummary(markdown: string): string {
75
+ const summaryFile = process.env.GITHUB_STEP_SUMMARY;
76
+ if (summaryFile) {
77
+ fs.appendFileSync(summaryFile, markdown + "\n", "utf-8");
78
+ }
79
+ return markdown;
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Detection
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /** Returns true when running inside a GitHub Actions runner. */
87
+ export function isGitHubActions(): boolean {
88
+ return process.env.GITHUB_ACTIONS === "true";
89
+ }
90
+
91
+ /** Returns the current GitHub Actions run ID, or null when not in Actions. */
92
+ export function getRunId(): string | null {
93
+ return process.env.GITHUB_RUN_ID ?? null;
94
+ }
95
+
96
+ /** Returns the repository slug (`owner/repo`), or null when not in Actions. */
97
+ export function getRepoSlug(): string | null {
98
+ return process.env.GITHUB_REPOSITORY ?? null;
99
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * packages/github/src/lib/branch-helpers.ts
3
+ *
4
+ * Pure helpers for deriving branch names from factory task metadata.
5
+ * No filesystem access. Depends only on @devory/core types.
6
+ *
7
+ * Design rule: if the task already declares a `branch` field, use it.
8
+ * Otherwise derive deterministically from id + title so names are
9
+ * stable across invocations.
10
+ */
11
+
12
+ import type { TaskMeta } from "@devory/core";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Types
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface BranchResult {
19
+ /** The derived or confirmed branch name */
20
+ branch: string;
21
+ /**
22
+ * "task-meta" — the task's `branch` field was used as-is.
23
+ * "derived" — branch was constructed from id + title slug.
24
+ */
25
+ source: "task-meta" | "derived";
26
+ /** Advisory messages, e.g. unusual characters that were stripped */
27
+ warnings: string[];
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Branch-prefix table
32
+ // Maps task `type` to the branch prefix segment.
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const BRANCH_PREFIX_MAP: Record<string, string> = {
36
+ feature: "feat",
37
+ feat: "feat",
38
+ bugfix: "fix",
39
+ bug: "fix",
40
+ fix: "fix",
41
+ refactor: "refactor",
42
+ chore: "chore",
43
+ documentation: "docs",
44
+ docs: "docs",
45
+ test: "test",
46
+ tests: "test",
47
+ perf: "perf",
48
+ performance: "perf",
49
+ };
50
+
51
+ /** Map a task type to its branch prefix. Unknown types default to "task". */
52
+ export function branchPrefix(taskType: string | undefined): string {
53
+ const t = (taskType ?? "").toLowerCase().trim();
54
+ return BRANCH_PREFIX_MAP[t] ?? "task";
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Slug helpers
59
+ // ---------------------------------------------------------------------------
60
+
61
+ /**
62
+ * Convert an arbitrary string to a branch-safe slug.
63
+ * - Lowercased
64
+ * - Non-alphanumeric runs replaced with single hyphens
65
+ * - Leading/trailing hyphens stripped
66
+ * - Truncated to maxLen characters
67
+ */
68
+ export function slugify(s: string, maxLen = 50): string {
69
+ return s
70
+ .toLowerCase()
71
+ .replace(/[^a-z0-9]+/g, "-")
72
+ .replace(/^-+|-+$/g, "")
73
+ .slice(0, maxLen);
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Main export
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Derive a branch name from task metadata.
82
+ *
83
+ * Resolution order:
84
+ * 1. `meta.branch` — used as-is if present and non-empty
85
+ * 2. `task/<prefix>/<id>-<title-slug>` derived from id + title
86
+ * 3. `task/<id>` if title is empty
87
+ */
88
+ export function buildBranchName(meta: Partial<TaskMeta>): BranchResult {
89
+ const warnings: string[] = [];
90
+
91
+ // Case 1: task declares its own branch name
92
+ if (typeof meta.branch === "string" && meta.branch.trim()) {
93
+ return { branch: meta.branch.trim(), source: "task-meta", warnings };
94
+ }
95
+
96
+ const id = typeof meta.id === "string" ? meta.id.trim() : "";
97
+ const title = typeof meta.title === "string" ? meta.title.trim() : "";
98
+ const prefix = branchPrefix(typeof meta.type === "string" ? meta.type : undefined);
99
+
100
+ const titleSlug = slugify(title, 40);
101
+
102
+ // Case 2: id + title slug
103
+ if (id && titleSlug) {
104
+ const branch = `${prefix}/${id}-${titleSlug}`;
105
+ return { branch, source: "derived", warnings };
106
+ }
107
+
108
+ // Case 3: id only (unusual — warn)
109
+ if (id) {
110
+ warnings.push("task title is empty; branch slug uses id only");
111
+ return { branch: `${prefix}/${id}`, source: "derived", warnings };
112
+ }
113
+
114
+ // Fallback: should not happen in a valid task
115
+ warnings.push("task has no id or title; using fallback branch name");
116
+ return { branch: "task/unnamed", source: "derived", warnings };
117
+ }