@devory/github 0.3.0 → 0.4.5
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/dist/index.js +140 -0
- package/package.json +4 -4
- package/src/index.ts +14 -7
- package/src/lib/action-helpers.ts +0 -99
- package/src/lib/branch-helpers.ts +0 -117
- package/src/lib/pr-create.ts +0 -167
- package/src/lib/pr-helpers.ts +0 -141
- package/src/test/action-helpers.test.ts +0 -158
- package/src/test/branch-helpers.test.ts +0 -168
- package/src/test/pr-create.test.ts +0 -223
- package/src/test/pr-helpers.test.ts +0 -187
package/dist/index.js
CHANGED
|
@@ -40,9 +40,13 @@ __export(src_exports, {
|
|
|
40
40
|
canCreatePr: () => canCreatePr,
|
|
41
41
|
commitType: () => commitType,
|
|
42
42
|
createPr: () => createPr,
|
|
43
|
+
extractAcceptanceCriteria: () => extractAcceptanceCriteria,
|
|
44
|
+
fetchGitHubIssue: () => fetchGitHubIssue,
|
|
45
|
+
filterPlanningComments: () => filterPlanningComments,
|
|
43
46
|
getRepoSlug: () => getRepoSlug,
|
|
44
47
|
getRunId: () => getRunId,
|
|
45
48
|
isGitHubActions: () => isGitHubActions,
|
|
49
|
+
isGitHubIssueUrl: () => isGitHubIssueUrl,
|
|
46
50
|
prCreateBlockedReason: () => prCreateBlockedReason,
|
|
47
51
|
setEnv: () => setEnv,
|
|
48
52
|
setOutput: () => setOutput,
|
|
@@ -271,6 +275,138 @@ function createPr(meta, taskBody, options) {
|
|
|
271
275
|
const prUrl = (result.stdout ?? "").trim();
|
|
272
276
|
return { ok: true, prUrl: prUrl || void 0 };
|
|
273
277
|
}
|
|
278
|
+
|
|
279
|
+
// src/lib/issue-content-extractor.ts
|
|
280
|
+
var AC_SECTION_HEADER = /^##\s*(acceptance criteria|ac|definition of done|dod)\s*$/i;
|
|
281
|
+
var NEXT_SECTION_HEADER = /^##\s+/i;
|
|
282
|
+
function cleanBullet(line) {
|
|
283
|
+
const trimmed = line.trim();
|
|
284
|
+
if (!/^[-*]\s+/.test(trimmed))
|
|
285
|
+
return null;
|
|
286
|
+
const cleaned = trimmed.replace(/^[-*]\s+/, "").trim();
|
|
287
|
+
return cleaned.length > 0 ? cleaned : null;
|
|
288
|
+
}
|
|
289
|
+
function isBadgeOnly(body) {
|
|
290
|
+
const normalized = body.trim();
|
|
291
|
+
if (normalized === "")
|
|
292
|
+
return true;
|
|
293
|
+
return /^\[!\[[^\]]*\]\([^)]*\)\]\([^)]*\)$/i.test(normalized);
|
|
294
|
+
}
|
|
295
|
+
function extractAcceptanceCriteria(body) {
|
|
296
|
+
if (!body || typeof body !== "string")
|
|
297
|
+
return [];
|
|
298
|
+
const lines = body.split(/\r?\n/);
|
|
299
|
+
let inAcSection = false;
|
|
300
|
+
const items = [];
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
if (!inAcSection && AC_SECTION_HEADER.test(line.trim())) {
|
|
303
|
+
inAcSection = true;
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (inAcSection && NEXT_SECTION_HEADER.test(line.trim())) {
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
if (inAcSection) {
|
|
310
|
+
const bullet = cleanBullet(line);
|
|
311
|
+
if (bullet)
|
|
312
|
+
items.push(bullet);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return items;
|
|
316
|
+
}
|
|
317
|
+
function filterPlanningComments(comments) {
|
|
318
|
+
return comments.filter((comment) => {
|
|
319
|
+
const login = (comment.user?.login ?? "").toLowerCase();
|
|
320
|
+
if (login.endsWith("[bot]"))
|
|
321
|
+
return false;
|
|
322
|
+
const body = typeof comment.body === "string" ? comment.body : "";
|
|
323
|
+
if (isBadgeOnly(body))
|
|
324
|
+
return false;
|
|
325
|
+
return true;
|
|
326
|
+
}).map((comment) => typeof comment.body === "string" ? comment.body.trim() : "").filter((body) => body.length > 0);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/lib/issue-adapter.ts
|
|
330
|
+
var GITHUB_ISSUE_URL = /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)(?:\/)?(?:\?.*)?(?:#.*)?$/i;
|
|
331
|
+
function normalizeDescription(body) {
|
|
332
|
+
if (typeof body !== "string")
|
|
333
|
+
return "";
|
|
334
|
+
return body.replace(/<[^>]+>/g, " ").replace(/\r\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
335
|
+
}
|
|
336
|
+
function parseIssueUrl(url) {
|
|
337
|
+
const match = url.trim().match(GITHUB_ISSUE_URL);
|
|
338
|
+
if (!match)
|
|
339
|
+
return null;
|
|
340
|
+
return {
|
|
341
|
+
owner: match[1],
|
|
342
|
+
repo: match[2],
|
|
343
|
+
number: match[3]
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
function isGitHubIssueUrl(url) {
|
|
347
|
+
return parseIssueUrl(url) !== null;
|
|
348
|
+
}
|
|
349
|
+
async function fetchGitHubIssue(url) {
|
|
350
|
+
const parsed = parseIssueUrl(url);
|
|
351
|
+
if (!parsed) {
|
|
352
|
+
throw new Error(`GitHub issue URL is invalid: ${url}`);
|
|
353
|
+
}
|
|
354
|
+
const token = process.env.GITHUB_TOKEN;
|
|
355
|
+
if (typeof token !== "string" || token.trim() === "") {
|
|
356
|
+
throw new Error("GITHUB_TOKEN is required to fetch GitHub issues");
|
|
357
|
+
}
|
|
358
|
+
const apiUrl = `https://api.github.com/repos/${parsed.owner}/${parsed.repo}/issues/${parsed.number}`;
|
|
359
|
+
const response = await fetch(apiUrl, {
|
|
360
|
+
headers: {
|
|
361
|
+
Authorization: `Bearer ${token}`,
|
|
362
|
+
Accept: "application/vnd.github+json",
|
|
363
|
+
"User-Agent": "devory-intake"
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
if (response.status === 404) {
|
|
367
|
+
throw new Error(`GitHub issue not found: ${url}`);
|
|
368
|
+
}
|
|
369
|
+
if (response.status === 401) {
|
|
370
|
+
throw new Error("GitHub authentication failed \u2014 check GITHUB_TOKEN");
|
|
371
|
+
}
|
|
372
|
+
if (!response.ok) {
|
|
373
|
+
throw new Error(`GitHub issue fetch failed (${response.status})`);
|
|
374
|
+
}
|
|
375
|
+
const payload = await response.json();
|
|
376
|
+
let planningComments = [];
|
|
377
|
+
if (typeof payload.comments_url === "string" && payload.comments_url.trim() !== "") {
|
|
378
|
+
try {
|
|
379
|
+
const commentsResponse = await fetch(payload.comments_url, {
|
|
380
|
+
headers: {
|
|
381
|
+
Authorization: `Bearer ${token}`,
|
|
382
|
+
Accept: "application/vnd.github+json",
|
|
383
|
+
"User-Agent": "devory-intake"
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
if (commentsResponse.ok) {
|
|
387
|
+
const commentsPayload = await commentsResponse.json();
|
|
388
|
+
planningComments = filterPlanningComments(commentsPayload);
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
const normalizedBody = normalizeDescription(payload.body);
|
|
394
|
+
const acceptanceCriteria = extractAcceptanceCriteria(normalizedBody);
|
|
395
|
+
const description = planningComments.length > 0 ? `${normalizedBody}
|
|
396
|
+
|
|
397
|
+
Planning comments:
|
|
398
|
+
${planningComments.map((entry) => `- ${entry}`).join("\n")}` : normalizedBody;
|
|
399
|
+
return {
|
|
400
|
+
source: "github-issue",
|
|
401
|
+
key: `${parsed.owner}/${parsed.repo}#${parsed.number}`,
|
|
402
|
+
url: payload.html_url ?? url,
|
|
403
|
+
title: typeof payload.title === "string" && payload.title.trim() !== "" ? payload.title.trim() : `Issue ${parsed.number}`,
|
|
404
|
+
description,
|
|
405
|
+
acceptance_criteria: acceptanceCriteria,
|
|
406
|
+
labels: Array.isArray(payload.labels) ? payload.labels.map((entry) => typeof entry?.name === "string" ? entry.name.trim() : "").filter((entry) => entry !== "") : [],
|
|
407
|
+
repo: `${parsed.owner}/${parsed.repo}`
|
|
408
|
+
};
|
|
409
|
+
}
|
|
274
410
|
// Annotate the CommonJS export names for ESM import in node:
|
|
275
411
|
0 && (module.exports = {
|
|
276
412
|
appendStepSummary,
|
|
@@ -283,9 +419,13 @@ function createPr(meta, taskBody, options) {
|
|
|
283
419
|
canCreatePr,
|
|
284
420
|
commitType,
|
|
285
421
|
createPr,
|
|
422
|
+
extractAcceptanceCriteria,
|
|
423
|
+
fetchGitHubIssue,
|
|
424
|
+
filterPlanningComments,
|
|
286
425
|
getRepoSlug,
|
|
287
426
|
getRunId,
|
|
288
427
|
isGitHubActions,
|
|
428
|
+
isGitHubIssueUrl,
|
|
289
429
|
prCreateBlockedReason,
|
|
290
430
|
setEnv,
|
|
291
431
|
setOutput,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devory/github",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "Devory GitHub integration — branch naming, PR helpers, and GitHub Actions support",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"dist/",
|
|
16
|
-
"src/",
|
|
16
|
+
"src/index.ts",
|
|
17
17
|
"README.md"
|
|
18
18
|
],
|
|
19
19
|
"scripts": {
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"test": "tsx --test src/test/branch-helpers.test.ts src/test/pr-helpers.test.ts src/test/action-helpers.test.ts"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@devory/cli": "0.
|
|
26
|
-
"@devory/core": "0.
|
|
25
|
+
"@devory/cli": "0.4.5",
|
|
26
|
+
"@devory/core": "0.4.5"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"esbuild": "^0.20.0",
|
package/src/index.ts
CHANGED
|
@@ -13,8 +13,8 @@ export {
|
|
|
13
13
|
buildBranchName,
|
|
14
14
|
branchPrefix,
|
|
15
15
|
slugify,
|
|
16
|
-
} from "./lib/branch-helpers.
|
|
17
|
-
export type { BranchResult } from "./lib/branch-helpers.
|
|
16
|
+
} from "./lib/branch-helpers.ts";
|
|
17
|
+
export type { BranchResult } from "./lib/branch-helpers.ts";
|
|
18
18
|
|
|
19
19
|
// ── PR helpers ──────────────────────────────────────────────────────────────
|
|
20
20
|
export {
|
|
@@ -23,8 +23,8 @@ export {
|
|
|
23
23
|
buildPrMetadata,
|
|
24
24
|
commitType,
|
|
25
25
|
taskScope,
|
|
26
|
-
} from "./lib/pr-helpers.
|
|
27
|
-
export type { PrMetadata } from "./lib/pr-helpers.
|
|
26
|
+
} from "./lib/pr-helpers.ts";
|
|
27
|
+
export type { PrMetadata } from "./lib/pr-helpers.ts";
|
|
28
28
|
|
|
29
29
|
// ── GitHub Actions helpers ──────────────────────────────────────────────────
|
|
30
30
|
export {
|
|
@@ -35,7 +35,7 @@ export {
|
|
|
35
35
|
isGitHubActions,
|
|
36
36
|
getRunId,
|
|
37
37
|
getRepoSlug,
|
|
38
|
-
} from "./lib/action-helpers.
|
|
38
|
+
} from "./lib/action-helpers.ts";
|
|
39
39
|
|
|
40
40
|
// ── PR creation (gated) ──────────────────────────────────────────────────────
|
|
41
41
|
export {
|
|
@@ -43,5 +43,12 @@ export {
|
|
|
43
43
|
prCreateBlockedReason,
|
|
44
44
|
buildGhCreateArgs,
|
|
45
45
|
createPr,
|
|
46
|
-
} from "./lib/pr-create.
|
|
47
|
-
export type { PrCreateOptions, PrCreateResult } from "./lib/pr-create.
|
|
46
|
+
} from "./lib/pr-create.ts";
|
|
47
|
+
export type { PrCreateOptions, PrCreateResult } from "./lib/pr-create.ts";
|
|
48
|
+
|
|
49
|
+
// ── External intake adapters ────────────────────────────────────────────────
|
|
50
|
+
export { fetchGitHubIssue, isGitHubIssueUrl } from "./lib/issue-adapter.ts";
|
|
51
|
+
export {
|
|
52
|
+
extractAcceptanceCriteria,
|
|
53
|
+
filterPlanningComments,
|
|
54
|
+
} from "./lib/issue-content-extractor.ts";
|
|
@@ -1,99 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,117 +0,0 @@
|
|
|
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
|
-
}
|
package/src/lib/pr-create.ts
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
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
|
-
}
|