@devory/github 0.1.0 → 0.3.1
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 +3 -3
- package/package.json +9 -5
- 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
|
@@ -28,8 +28,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
28
28
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
29
|
|
|
30
30
|
// src/index.ts
|
|
31
|
-
var
|
|
32
|
-
__export(
|
|
31
|
+
var src_exports = {};
|
|
32
|
+
__export(src_exports, {
|
|
33
33
|
appendStepSummary: () => appendStepSummary,
|
|
34
34
|
branchPrefix: () => branchPrefix,
|
|
35
35
|
buildBranchName: () => buildBranchName,
|
|
@@ -50,7 +50,7 @@ __export(index_exports, {
|
|
|
50
50
|
slugify: () => slugify,
|
|
51
51
|
taskScope: () => taskScope
|
|
52
52
|
});
|
|
53
|
-
module.exports = __toCommonJS(
|
|
53
|
+
module.exports = __toCommonJS(src_exports);
|
|
54
54
|
|
|
55
55
|
// src/lib/branch-helpers.ts
|
|
56
56
|
var BRANCH_PREFIX_MAP = {
|
package/package.json
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@devory/github",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Devory GitHub integration
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Devory GitHub integration \u2014 branch naming, PR helpers, and GitHub Actions support",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/devoryai/devory"
|
|
8
|
+
},
|
|
5
9
|
"main": "./dist/index.js",
|
|
6
10
|
"types": "./src/index.ts",
|
|
7
11
|
"exports": {
|
|
@@ -9,7 +13,7 @@
|
|
|
9
13
|
},
|
|
10
14
|
"files": [
|
|
11
15
|
"dist/",
|
|
12
|
-
"src/",
|
|
16
|
+
"src/index.ts",
|
|
13
17
|
"README.md"
|
|
14
18
|
],
|
|
15
19
|
"scripts": {
|
|
@@ -18,8 +22,8 @@
|
|
|
18
22
|
"test": "tsx --test src/test/branch-helpers.test.ts src/test/pr-helpers.test.ts src/test/action-helpers.test.ts"
|
|
19
23
|
},
|
|
20
24
|
"dependencies": {
|
|
21
|
-
"@devory/cli": "0.
|
|
22
|
-
"@devory/core": "0.1
|
|
25
|
+
"@devory/cli": "0.3.1",
|
|
26
|
+
"@devory/core": "0.3.1"
|
|
23
27
|
},
|
|
24
28
|
"devDependencies": {
|
|
25
29
|
"esbuild": "^0.20.0",
|
|
@@ -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
|
-
}
|
package/src/lib/pr-helpers.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,158 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,168 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,223 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* packages/github/src/test/pr-create.test.ts
|
|
3
|
-
*
|
|
4
|
-
* Tests for src/lib/pr-create.ts — gated PR creation helper.
|
|
5
|
-
* All tests use pure functions; no process spawning.
|
|
6
|
-
*
|
|
7
|
-
* Run: tsx --test packages/github/src/test/pr-create.test.ts
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { test, describe } from "node:test";
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
canCreatePr,
|
|
15
|
-
prCreateBlockedReason,
|
|
16
|
-
buildGhCreateArgs,
|
|
17
|
-
createPr,
|
|
18
|
-
} from "../lib/pr-create.js";
|
|
19
|
-
|
|
20
|
-
const META = {
|
|
21
|
-
id: "factory-071",
|
|
22
|
-
title: "GitHub PR creation (gated automation)",
|
|
23
|
-
project: "ai-dev-factory",
|
|
24
|
-
type: "feature" as const,
|
|
25
|
-
priority: "high" as const,
|
|
26
|
-
status: "review" as const,
|
|
27
|
-
repo: ".",
|
|
28
|
-
branch: "task/factory-071-github-pr-creation",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
const BODY = "## Goal\n\nAdd gated PR creation to the factory.";
|
|
32
|
-
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
// canCreatePr
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
|
|
37
|
-
describe("canCreatePr", () => {
|
|
38
|
-
test("returns true when GITHUB_TOKEN is set", () => {
|
|
39
|
-
assert.equal(canCreatePr({ GITHUB_TOKEN: "ghp_test_token" }), true);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("returns false when GITHUB_TOKEN is absent", () => {
|
|
43
|
-
assert.equal(canCreatePr({}), false);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test("returns false when GITHUB_TOKEN is empty string", () => {
|
|
47
|
-
assert.equal(canCreatePr({ GITHUB_TOKEN: "" }), false);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test("returns false when GITHUB_TOKEN is whitespace only", () => {
|
|
51
|
-
assert.equal(canCreatePr({ GITHUB_TOKEN: " " }), false);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("returns true for non-empty token with whitespace padding", () => {
|
|
55
|
-
// Token itself has content — only the full value is checked
|
|
56
|
-
assert.equal(canCreatePr({ GITHUB_TOKEN: " ghp_abc " }), true);
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
// ---------------------------------------------------------------------------
|
|
61
|
-
// prCreateBlockedReason
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
describe("prCreateBlockedReason", () => {
|
|
65
|
-
const validOptions = {
|
|
66
|
-
confirm: true,
|
|
67
|
-
branch: "task/factory-071",
|
|
68
|
-
env: { GITHUB_TOKEN: "ghp_test" },
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
test("returns null when all guards pass", () => {
|
|
72
|
-
assert.equal(
|
|
73
|
-
prCreateBlockedReason(validOptions, { GITHUB_TOKEN: "ghp_test" }),
|
|
74
|
-
null
|
|
75
|
-
);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("blocks when confirm is false", () => {
|
|
79
|
-
const reason = prCreateBlockedReason(
|
|
80
|
-
{ ...validOptions, confirm: false },
|
|
81
|
-
{ GITHUB_TOKEN: "ghp_test" }
|
|
82
|
-
);
|
|
83
|
-
assert.ok(reason !== null);
|
|
84
|
-
assert.ok(reason!.includes("--confirm"));
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
test("blocks when branch is empty string", () => {
|
|
88
|
-
const reason = prCreateBlockedReason(
|
|
89
|
-
{ ...validOptions, branch: "" },
|
|
90
|
-
{ GITHUB_TOKEN: "ghp_test" }
|
|
91
|
-
);
|
|
92
|
-
assert.ok(reason !== null);
|
|
93
|
-
assert.ok(reason!.includes("branch"));
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("blocks when branch is whitespace only", () => {
|
|
97
|
-
const reason = prCreateBlockedReason(
|
|
98
|
-
{ ...validOptions, branch: " " },
|
|
99
|
-
{ GITHUB_TOKEN: "ghp_test" }
|
|
100
|
-
);
|
|
101
|
-
assert.ok(reason !== null);
|
|
102
|
-
assert.ok(reason!.includes("branch"));
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
test("blocks when GITHUB_TOKEN is absent", () => {
|
|
106
|
-
const reason = prCreateBlockedReason(validOptions, {});
|
|
107
|
-
assert.ok(reason !== null);
|
|
108
|
-
assert.ok(reason!.includes("GITHUB_TOKEN"));
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
test("confirm guard takes priority over token guard", () => {
|
|
112
|
-
const reason = prCreateBlockedReason(
|
|
113
|
-
{ ...validOptions, confirm: false },
|
|
114
|
-
{} // no token
|
|
115
|
-
);
|
|
116
|
-
assert.ok(reason !== null);
|
|
117
|
-
assert.ok(reason!.includes("--confirm"));
|
|
118
|
-
});
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// ---------------------------------------------------------------------------
|
|
122
|
-
// buildGhCreateArgs
|
|
123
|
-
// ---------------------------------------------------------------------------
|
|
124
|
-
|
|
125
|
-
describe("buildGhCreateArgs", () => {
|
|
126
|
-
test("includes 'pr create' subcommand", () => {
|
|
127
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071" });
|
|
128
|
-
assert.equal(args[0], "pr");
|
|
129
|
-
assert.equal(args[1], "create");
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
test("includes --title derived from meta", () => {
|
|
133
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071" });
|
|
134
|
-
const i = args.indexOf("--title");
|
|
135
|
-
assert.ok(i >= 0);
|
|
136
|
-
assert.ok(args[i + 1].includes("GitHub PR creation"));
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("includes --body derived from meta + task body", () => {
|
|
140
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071" });
|
|
141
|
-
const i = args.indexOf("--body");
|
|
142
|
-
assert.ok(i >= 0);
|
|
143
|
-
assert.ok(args[i + 1].includes("factory-071"));
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
test("includes --head with branch name", () => {
|
|
147
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071" });
|
|
148
|
-
const i = args.indexOf("--head");
|
|
149
|
-
assert.ok(i >= 0 && args[i + 1] === "task/factory-071");
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("defaults --base to main", () => {
|
|
153
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071" });
|
|
154
|
-
const i = args.indexOf("--base");
|
|
155
|
-
assert.ok(i >= 0 && args[i + 1] === "main");
|
|
156
|
-
});
|
|
157
|
-
|
|
158
|
-
test("uses custom --base when provided", () => {
|
|
159
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071", base: "develop" });
|
|
160
|
-
const i = args.indexOf("--base");
|
|
161
|
-
assert.ok(i >= 0 && args[i + 1] === "develop");
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
test("title follows conventional-commit format", () => {
|
|
165
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071" });
|
|
166
|
-
const i = args.indexOf("--title");
|
|
167
|
-
assert.ok(args[i + 1].startsWith("feat("));
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
test("body contains summary section", () => {
|
|
171
|
-
const args = buildGhCreateArgs(META, BODY, { branch: "task/factory-071" });
|
|
172
|
-
const i = args.indexOf("--body");
|
|
173
|
-
assert.ok(args[i + 1].includes("## Summary"));
|
|
174
|
-
});
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
// ---------------------------------------------------------------------------
|
|
178
|
-
// createPr — guard paths (no process spawning)
|
|
179
|
-
// ---------------------------------------------------------------------------
|
|
180
|
-
|
|
181
|
-
describe("createPr — guard paths", () => {
|
|
182
|
-
test("returns skipped when confirm is false", () => {
|
|
183
|
-
const result = createPr(META, BODY, {
|
|
184
|
-
confirm: false,
|
|
185
|
-
branch: "task/factory-071",
|
|
186
|
-
env: { GITHUB_TOKEN: "ghp_test" },
|
|
187
|
-
});
|
|
188
|
-
assert.equal(result.ok, false);
|
|
189
|
-
assert.equal(result.skipped, true);
|
|
190
|
-
assert.ok(result.error?.includes("--confirm"));
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
test("returns skipped when GITHUB_TOKEN is absent", () => {
|
|
194
|
-
const result = createPr(META, BODY, {
|
|
195
|
-
confirm: true,
|
|
196
|
-
branch: "task/factory-071",
|
|
197
|
-
env: {},
|
|
198
|
-
});
|
|
199
|
-
assert.equal(result.ok, false);
|
|
200
|
-
assert.equal(result.skipped, true);
|
|
201
|
-
assert.ok(result.error?.includes("GITHUB_TOKEN"));
|
|
202
|
-
});
|
|
203
|
-
|
|
204
|
-
test("returns skipped when branch is empty", () => {
|
|
205
|
-
const result = createPr(META, BODY, {
|
|
206
|
-
confirm: true,
|
|
207
|
-
branch: "",
|
|
208
|
-
env: { GITHUB_TOKEN: "ghp_test" },
|
|
209
|
-
});
|
|
210
|
-
assert.equal(result.ok, false);
|
|
211
|
-
assert.equal(result.skipped, true);
|
|
212
|
-
assert.ok(result.error?.includes("branch"));
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test("skipped result has no prUrl", () => {
|
|
216
|
-
const result = createPr(META, BODY, {
|
|
217
|
-
confirm: false,
|
|
218
|
-
branch: "task/factory-071",
|
|
219
|
-
env: { GITHUB_TOKEN: "ghp_test" },
|
|
220
|
-
});
|
|
221
|
-
assert.equal(result.prUrl, undefined);
|
|
222
|
-
});
|
|
223
|
-
});
|
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* packages/github/src/test/pr-helpers.test.ts
|
|
3
|
-
*
|
|
4
|
-
* Tests for src/lib/pr-helpers.ts.
|
|
5
|
-
* Run: tsx --test packages/github/src/test/pr-helpers.test.ts
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { test, describe } from "node:test";
|
|
9
|
-
import assert from "node:assert/strict";
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
commitType,
|
|
13
|
-
taskScope,
|
|
14
|
-
buildPrTitle,
|
|
15
|
-
buildPrBody,
|
|
16
|
-
buildPrMetadata,
|
|
17
|
-
} from "../lib/pr-helpers.js";
|
|
18
|
-
|
|
19
|
-
// ── commitType ─────────────────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
describe("commitType", () => {
|
|
22
|
-
test("feature → feat", () => assert.equal(commitType("feature"), "feat"));
|
|
23
|
-
test("feat → feat", () => assert.equal(commitType("feat"), "feat"));
|
|
24
|
-
test("bugfix → fix", () => assert.equal(commitType("bugfix"), "fix"));
|
|
25
|
-
test("bug → fix", () => assert.equal(commitType("bug"), "fix"));
|
|
26
|
-
test("refactor → refactor", () => assert.equal(commitType("refactor"), "refactor"));
|
|
27
|
-
test("chore → chore", () => assert.equal(commitType("chore"), "chore"));
|
|
28
|
-
test("docs → docs", () => assert.equal(commitType("docs"), "docs"));
|
|
29
|
-
test("documentation → docs", () => assert.equal(commitType("documentation"), "docs"));
|
|
30
|
-
test("test → test", () => assert.equal(commitType("test"), "test"));
|
|
31
|
-
test("perf → perf", () => assert.equal(commitType("perf"), "perf"));
|
|
32
|
-
test("subtask → feat", () => assert.equal(commitType("subtask"), "feat"));
|
|
33
|
-
test("unknown → feat", () => assert.equal(commitType("unknown"), "feat"));
|
|
34
|
-
test("undefined → feat", () => assert.equal(commitType(undefined), "feat"));
|
|
35
|
-
test("case-insensitive", () => assert.equal(commitType("FEATURE"), "feat"));
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
// ── taskScope ──────────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
describe("taskScope", () => {
|
|
41
|
-
test("uses repo_area first", () => {
|
|
42
|
-
assert.equal(taskScope({ repo_area: "api", lane: "infra", project: "proj" }), "api");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("falls back to lane when no repo_area", () => {
|
|
46
|
-
assert.equal(taskScope({ lane: "infra", project: "proj" }), "infra");
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
test("falls back to project when no repo_area or lane", () => {
|
|
50
|
-
assert.equal(taskScope({ project: "ai-dev-factory" }), "ai-dev-factory");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("defaults to 'core' when all empty", () => {
|
|
54
|
-
assert.equal(taskScope({}), "core");
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
test("trims whitespace", () => {
|
|
58
|
-
assert.equal(taskScope({ repo_area: " api " }), "api");
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// ── buildPrTitle ───────────────────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
describe("buildPrTitle", () => {
|
|
65
|
-
test("builds conventional-commit title", () => {
|
|
66
|
-
const title = buildPrTitle({
|
|
67
|
-
type: "feature",
|
|
68
|
-
project: "ai-dev-factory",
|
|
69
|
-
title: "GitHub Integration MVP",
|
|
70
|
-
});
|
|
71
|
-
assert.equal(title, "feat(ai-dev-factory): GitHub Integration MVP");
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test("uses repo_area as scope when present", () => {
|
|
75
|
-
const title = buildPrTitle({
|
|
76
|
-
type: "bugfix",
|
|
77
|
-
repo_area: "api",
|
|
78
|
-
title: "Fix null pointer",
|
|
79
|
-
});
|
|
80
|
-
assert.ok(title.startsWith("fix(api):"));
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
test("truncates to 72 chars with ellipsis", () => {
|
|
84
|
-
const longTitle = "A".repeat(100);
|
|
85
|
-
const title = buildPrTitle({ type: "feature", project: "p", title: longTitle });
|
|
86
|
-
assert.ok(title.length <= 72);
|
|
87
|
-
assert.ok(title.endsWith("…"));
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
test("does not truncate short titles", () => {
|
|
91
|
-
const title = buildPrTitle({ type: "chore", project: "proj", title: "short" });
|
|
92
|
-
assert.ok(title.length < 72);
|
|
93
|
-
assert.ok(!title.endsWith("…"));
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("handles missing type (defaults to feat)", () => {
|
|
97
|
-
const title = buildPrTitle({ project: "p", title: "My task" });
|
|
98
|
-
assert.ok(title.startsWith("feat("));
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test("handles missing title", () => {
|
|
102
|
-
const title = buildPrTitle({ type: "feature", project: "p" });
|
|
103
|
-
assert.ok(title.includes("(untitled)"));
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
// ── buildPrBody ────────────────────────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
describe("buildPrBody", () => {
|
|
110
|
-
const meta = {
|
|
111
|
-
id: "factory-066",
|
|
112
|
-
title: "GitHub Integration MVP",
|
|
113
|
-
project: "ai-dev-factory",
|
|
114
|
-
type: "feature",
|
|
115
|
-
priority: "medium",
|
|
116
|
-
agent: "fullstack-builder",
|
|
117
|
-
depends_on: ["factory-054", "factory-063"],
|
|
118
|
-
verification: ["npm run test", "npm run validate:task -- tasks/backlog/factory-066.md"],
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
test("includes task id in body", () => {
|
|
122
|
-
const body = buildPrBody(meta, "");
|
|
123
|
-
assert.ok(body.includes("factory-066"));
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
test("includes task title in body", () => {
|
|
127
|
-
const body = buildPrBody(meta, "");
|
|
128
|
-
assert.ok(body.includes("GitHub Integration MVP"));
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("includes project in body", () => {
|
|
132
|
-
const body = buildPrBody(meta, "");
|
|
133
|
-
assert.ok(body.includes("ai-dev-factory"));
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
test("includes depends_on when present", () => {
|
|
137
|
-
const body = buildPrBody(meta, "");
|
|
138
|
-
assert.ok(body.includes("factory-054"));
|
|
139
|
-
assert.ok(body.includes("factory-063"));
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
test("includes verification commands as checklist items", () => {
|
|
143
|
-
const body = buildPrBody(meta, "");
|
|
144
|
-
assert.ok(body.includes("- [ ] `npm run test`"));
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
test("includes task body content when provided", () => {
|
|
148
|
-
const body = buildPrBody(meta, "## Goal\nDo the thing.");
|
|
149
|
-
assert.ok(body.includes("Do the thing."));
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
test("omits context section when body is empty", () => {
|
|
153
|
-
const body = buildPrBody(meta, "");
|
|
154
|
-
assert.ok(!body.includes("## Context"));
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
test("includes safety footer", () => {
|
|
158
|
-
const body = buildPrBody(meta, "");
|
|
159
|
-
assert.ok(body.includes("human review required before merge"));
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test("includes Summary section header", () => {
|
|
163
|
-
const body = buildPrBody(meta, "");
|
|
164
|
-
assert.ok(body.includes("## Summary"));
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
test("omits verification section when verification is empty", () => {
|
|
168
|
-
const body = buildPrBody({ ...meta, verification: [] }, "");
|
|
169
|
-
assert.ok(!body.includes("## Verification"));
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
// ── buildPrMetadata ────────────────────────────────────────────────────────
|
|
174
|
-
|
|
175
|
-
describe("buildPrMetadata", () => {
|
|
176
|
-
test("returns object with title and body", () => {
|
|
177
|
-
const result = buildPrMetadata({ type: "feature", project: "p", title: "T" }, "");
|
|
178
|
-
assert.ok(typeof result.title === "string" && result.title.length > 0);
|
|
179
|
-
assert.ok(typeof result.body === "string" && result.body.length > 0);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("title matches buildPrTitle output", () => {
|
|
183
|
-
const meta = { type: "bugfix", project: "proj", title: "Fix bug" };
|
|
184
|
-
const result = buildPrMetadata(meta, "");
|
|
185
|
-
assert.equal(result.title, buildPrTitle(meta));
|
|
186
|
-
});
|
|
187
|
-
});
|