@gh-symphony/cli 0.0.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/LICENSE +21 -0
- package/dist/commands/config-cmd.d.ts +3 -0
- package/dist/commands/config-cmd.js +106 -0
- package/dist/commands/help.d.ts +3 -0
- package/dist/commands/help.js +47 -0
- package/dist/commands/init.d.ts +26 -0
- package/dist/commands/init.js +508 -0
- package/dist/commands/logs.d.ts +3 -0
- package/dist/commands/logs.js +123 -0
- package/dist/commands/project.d.ts +3 -0
- package/dist/commands/project.js +101 -0
- package/dist/commands/recover.d.ts +3 -0
- package/dist/commands/recover.js +117 -0
- package/dist/commands/repo.d.ts +3 -0
- package/dist/commands/repo.js +103 -0
- package/dist/commands/run.d.ts +3 -0
- package/dist/commands/run.js +69 -0
- package/dist/commands/start.d.ts +3 -0
- package/dist/commands/start.js +210 -0
- package/dist/commands/status.d.ts +3 -0
- package/dist/commands/status.js +218 -0
- package/dist/commands/stop.d.ts +3 -0
- package/dist/commands/stop.js +46 -0
- package/dist/commands/version.d.ts +3 -0
- package/dist/commands/version.js +21 -0
- package/dist/config.d.ts +36 -0
- package/dist/config.js +81 -0
- package/dist/github/client.d.ts +60 -0
- package/dist/github/client.js +300 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +88 -0
- package/dist/mapping/smart-defaults.d.ts +33 -0
- package/dist/mapping/smart-defaults.js +159 -0
- package/dist/orchestrator-runtime.d.ts +5 -0
- package/dist/orchestrator-runtime.js +26 -0
- package/package.json +49 -0
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
const DEFAULT_API_URL = "https://api.github.com/graphql";
|
|
2
|
+
const REST_API_URL = "https://api.github.com";
|
|
3
|
+
export class GitHubApiError extends Error {
|
|
4
|
+
status;
|
|
5
|
+
constructor(message, status) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.name = "GitHubApiError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function createClient(token, options) {
|
|
12
|
+
return {
|
|
13
|
+
token,
|
|
14
|
+
apiUrl: options?.apiUrl ?? DEFAULT_API_URL,
|
|
15
|
+
fetchImpl: options?.fetchImpl ?? fetch,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
// ── 2.1: Token validation & scope check ──────────────────────────────────────
|
|
19
|
+
export async function validateToken(client) {
|
|
20
|
+
// Use REST to get X-OAuth-Scopes header (GraphQL doesn't expose scopes)
|
|
21
|
+
const restUrl = client.apiUrl.replace("/graphql", "");
|
|
22
|
+
const baseUrl = restUrl === client.apiUrl ? REST_API_URL : restUrl;
|
|
23
|
+
const response = await client.fetchImpl(`${baseUrl}/user`, {
|
|
24
|
+
headers: {
|
|
25
|
+
authorization: `Bearer ${client.token}`,
|
|
26
|
+
accept: "application/vnd.github+json",
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
if (response.status === 401) {
|
|
31
|
+
throw new GitHubApiError("Invalid token: authentication failed.", 401);
|
|
32
|
+
}
|
|
33
|
+
throw new GitHubApiError(`GitHub API error: ${response.status} ${response.statusText}`, response.status);
|
|
34
|
+
}
|
|
35
|
+
const scopes = response.headers
|
|
36
|
+
.get("x-oauth-scopes")
|
|
37
|
+
?.split(",")
|
|
38
|
+
.map((s) => s.trim())
|
|
39
|
+
.filter(Boolean) ?? [];
|
|
40
|
+
const user = (await response.json());
|
|
41
|
+
return {
|
|
42
|
+
login: user.login,
|
|
43
|
+
name: user.name,
|
|
44
|
+
scopes,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function checkRequiredScopes(scopes) {
|
|
48
|
+
const required = ["repo", "read:org", "project"];
|
|
49
|
+
const normalizedScopes = scopes.map((s) => s.toLowerCase());
|
|
50
|
+
const missing = required.filter((r) => !normalizedScopes.includes(r));
|
|
51
|
+
return { valid: missing.length === 0, missing };
|
|
52
|
+
}
|
|
53
|
+
// ── 2.2: Projects v2 list ────────────────────────────────────────────────────
|
|
54
|
+
export async function listUserProjects(client) {
|
|
55
|
+
const data = await graphql(client, VIEWER_PROJECTS_QUERY);
|
|
56
|
+
const projects = [];
|
|
57
|
+
for (const node of data.viewer.projectsV2?.nodes ?? []) {
|
|
58
|
+
if (!node)
|
|
59
|
+
continue;
|
|
60
|
+
projects.push(normalizeProjectSummary(node, {
|
|
61
|
+
login: data.viewer.login,
|
|
62
|
+
type: "User",
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
for (const orgNode of data.viewer.organizations?.nodes ?? []) {
|
|
66
|
+
if (!orgNode)
|
|
67
|
+
continue;
|
|
68
|
+
for (const projNode of orgNode.projectsV2?.nodes ?? []) {
|
|
69
|
+
if (!projNode)
|
|
70
|
+
continue;
|
|
71
|
+
projects.push(normalizeProjectSummary(projNode, {
|
|
72
|
+
login: orgNode.login,
|
|
73
|
+
type: "Organization",
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return projects;
|
|
78
|
+
}
|
|
79
|
+
function normalizeProjectSummary(node, owner) {
|
|
80
|
+
return {
|
|
81
|
+
id: node.id,
|
|
82
|
+
title: node.title,
|
|
83
|
+
shortDescription: node.shortDescription ?? "",
|
|
84
|
+
url: node.url,
|
|
85
|
+
openItemCount: node.items?.totalCount ?? 0,
|
|
86
|
+
owner,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// ── 2.3: Project detail (status fields + linked repos) ───────────────────────
|
|
90
|
+
export async function getProjectDetail(client, projectId) {
|
|
91
|
+
const data = await graphql(client, PROJECT_DETAIL_QUERY, { projectId });
|
|
92
|
+
const project = data.node;
|
|
93
|
+
if (!project || project.__typename !== "ProjectV2") {
|
|
94
|
+
throw new GitHubApiError(`Project not found: ${projectId}`);
|
|
95
|
+
}
|
|
96
|
+
const statusFields = [];
|
|
97
|
+
for (const field of project.fields?.nodes ?? []) {
|
|
98
|
+
if (!field || field.__typename !== "ProjectV2SingleSelectField")
|
|
99
|
+
continue;
|
|
100
|
+
statusFields.push({
|
|
101
|
+
id: field.id,
|
|
102
|
+
name: field.name,
|
|
103
|
+
options: (field.options ?? []).map((opt) => ({
|
|
104
|
+
id: opt.id,
|
|
105
|
+
name: opt.name,
|
|
106
|
+
description: opt.description ?? null,
|
|
107
|
+
color: opt.color ?? null,
|
|
108
|
+
})),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const repoMap = new Map();
|
|
112
|
+
let cursor = null;
|
|
113
|
+
let hasMore = true;
|
|
114
|
+
// Use initial page from the detail query
|
|
115
|
+
for (const item of project.items?.nodes ?? []) {
|
|
116
|
+
const repo = item?.content?.repository;
|
|
117
|
+
if (!repo)
|
|
118
|
+
continue;
|
|
119
|
+
const key = `${repo.owner.login}/${repo.name}`;
|
|
120
|
+
if (!repoMap.has(key)) {
|
|
121
|
+
repoMap.set(key, {
|
|
122
|
+
owner: repo.owner.login,
|
|
123
|
+
name: repo.name,
|
|
124
|
+
url: repo.url,
|
|
125
|
+
cloneUrl: repo.url.endsWith(".git") ? repo.url : `${repo.url}.git`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
hasMore = project.items?.pageInfo?.hasNextPage ?? false;
|
|
130
|
+
cursor = project.items?.pageInfo?.endCursor ?? null;
|
|
131
|
+
// Paginate remaining items for linked repos
|
|
132
|
+
while (hasMore && cursor) {
|
|
133
|
+
const pageData = await graphql(client, PROJECT_ITEMS_PAGE_QUERY, { projectId, cursor });
|
|
134
|
+
const items = pageData.node?.items;
|
|
135
|
+
if (!items)
|
|
136
|
+
break;
|
|
137
|
+
for (const item of items.nodes ?? []) {
|
|
138
|
+
const repo = item?.content?.repository;
|
|
139
|
+
if (!repo)
|
|
140
|
+
continue;
|
|
141
|
+
const key = `${repo.owner.login}/${repo.name}`;
|
|
142
|
+
if (!repoMap.has(key)) {
|
|
143
|
+
repoMap.set(key, {
|
|
144
|
+
owner: repo.owner.login,
|
|
145
|
+
name: repo.name,
|
|
146
|
+
url: repo.url,
|
|
147
|
+
cloneUrl: repo.url.endsWith(".git") ? repo.url : `${repo.url}.git`,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
hasMore = items.pageInfo?.hasNextPage ?? false;
|
|
152
|
+
cursor = items.pageInfo?.endCursor ?? null;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
id: project.id,
|
|
156
|
+
title: project.title,
|
|
157
|
+
url: project.url,
|
|
158
|
+
statusFields,
|
|
159
|
+
linkedRepositories: [...repoMap.values()],
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
// ── GraphQL helpers ──────────────────────────────────────────────────────────
|
|
163
|
+
async function graphql(client, query, variables) {
|
|
164
|
+
const response = await client.fetchImpl(client.apiUrl, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: {
|
|
167
|
+
"content-type": "application/json",
|
|
168
|
+
authorization: `Bearer ${client.token}`,
|
|
169
|
+
},
|
|
170
|
+
body: JSON.stringify({ query, variables }),
|
|
171
|
+
});
|
|
172
|
+
if (!response.ok) {
|
|
173
|
+
const text = await response.text().catch(() => "");
|
|
174
|
+
throw new GitHubApiError(`GitHub GraphQL request failed: ${response.status} ${response.statusText}. ${text}`, response.status);
|
|
175
|
+
}
|
|
176
|
+
const payload = (await response.json());
|
|
177
|
+
if (payload.errors?.length) {
|
|
178
|
+
throw new GitHubApiError(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`);
|
|
179
|
+
}
|
|
180
|
+
if (!payload.data) {
|
|
181
|
+
throw new GitHubApiError("GraphQL response missing data.");
|
|
182
|
+
}
|
|
183
|
+
return payload.data;
|
|
184
|
+
}
|
|
185
|
+
// ── GraphQL queries ──────────────────────────────────────────────────────────
|
|
186
|
+
const VIEWER_PROJECTS_QUERY = `
|
|
187
|
+
query ViewerProjects {
|
|
188
|
+
viewer {
|
|
189
|
+
login
|
|
190
|
+
projectsV2(first: 50) {
|
|
191
|
+
nodes {
|
|
192
|
+
id
|
|
193
|
+
title
|
|
194
|
+
shortDescription
|
|
195
|
+
url
|
|
196
|
+
items { totalCount }
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
organizations(first: 20) {
|
|
200
|
+
nodes {
|
|
201
|
+
login
|
|
202
|
+
projectsV2(first: 50) {
|
|
203
|
+
nodes {
|
|
204
|
+
id
|
|
205
|
+
title
|
|
206
|
+
shortDescription
|
|
207
|
+
url
|
|
208
|
+
items { totalCount }
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
`;
|
|
216
|
+
const PROJECT_DETAIL_QUERY = `
|
|
217
|
+
query ProjectDetail($projectId: ID!) {
|
|
218
|
+
node(id: $projectId) {
|
|
219
|
+
__typename
|
|
220
|
+
... on ProjectV2 {
|
|
221
|
+
id
|
|
222
|
+
title
|
|
223
|
+
url
|
|
224
|
+
fields(first: 50) {
|
|
225
|
+
nodes {
|
|
226
|
+
__typename
|
|
227
|
+
... on ProjectV2SingleSelectField {
|
|
228
|
+
id
|
|
229
|
+
name
|
|
230
|
+
options {
|
|
231
|
+
id
|
|
232
|
+
name
|
|
233
|
+
description
|
|
234
|
+
color
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
items(first: 100) {
|
|
240
|
+
nodes {
|
|
241
|
+
content {
|
|
242
|
+
__typename
|
|
243
|
+
... on Issue {
|
|
244
|
+
repository {
|
|
245
|
+
name
|
|
246
|
+
url
|
|
247
|
+
owner { login }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
... on PullRequest {
|
|
251
|
+
repository {
|
|
252
|
+
name
|
|
253
|
+
url
|
|
254
|
+
owner { login }
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
pageInfo {
|
|
260
|
+
endCursor
|
|
261
|
+
hasNextPage
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
const PROJECT_ITEMS_PAGE_QUERY = `
|
|
269
|
+
query ProjectItemsPage($projectId: ID!, $cursor: String) {
|
|
270
|
+
node(id: $projectId) {
|
|
271
|
+
... on ProjectV2 {
|
|
272
|
+
items(first: 100, after: $cursor) {
|
|
273
|
+
nodes {
|
|
274
|
+
content {
|
|
275
|
+
__typename
|
|
276
|
+
... on Issue {
|
|
277
|
+
repository {
|
|
278
|
+
name
|
|
279
|
+
url
|
|
280
|
+
owner { login }
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
... on PullRequest {
|
|
284
|
+
repository {
|
|
285
|
+
name
|
|
286
|
+
url
|
|
287
|
+
owner { login }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
pageInfo {
|
|
293
|
+
endCursor
|
|
294
|
+
hasNextPage
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
`;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export type GlobalOptions = {
|
|
3
|
+
configDir: string;
|
|
4
|
+
verbose: boolean;
|
|
5
|
+
json: boolean;
|
|
6
|
+
noColor: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function parseGlobalOptions(argv: string[]): {
|
|
9
|
+
options: GlobalOptions;
|
|
10
|
+
command: string;
|
|
11
|
+
args: string[];
|
|
12
|
+
};
|
|
13
|
+
export type CommandHandler = (args: string[], options: GlobalOptions) => Promise<void>;
|
|
14
|
+
export declare function runCli(argv: string[]): Promise<void>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { resolveConfigDir } from "./config.js";
|
|
4
|
+
export function parseGlobalOptions(argv) {
|
|
5
|
+
const globalFlags = {
|
|
6
|
+
configDir: resolveConfigDir(),
|
|
7
|
+
verbose: false,
|
|
8
|
+
json: false,
|
|
9
|
+
noColor: false,
|
|
10
|
+
};
|
|
11
|
+
const remaining = [];
|
|
12
|
+
let i = 0;
|
|
13
|
+
while (i < argv.length) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
if (arg === "--config" || arg === "--config-dir") {
|
|
16
|
+
globalFlags.configDir = resolveConfigDir(argv[i + 1]);
|
|
17
|
+
i += 2;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (arg === "--verbose" || arg === "-v") {
|
|
21
|
+
globalFlags.verbose = true;
|
|
22
|
+
i += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (arg === "--json") {
|
|
26
|
+
globalFlags.json = true;
|
|
27
|
+
i += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === "--no-color") {
|
|
31
|
+
globalFlags.noColor = true;
|
|
32
|
+
i += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
remaining.push(arg);
|
|
36
|
+
i += 1;
|
|
37
|
+
}
|
|
38
|
+
const [command = "help", ...args] = remaining;
|
|
39
|
+
return { options: globalFlags, command, args };
|
|
40
|
+
}
|
|
41
|
+
const COMMANDS = {
|
|
42
|
+
init: () => import("./commands/init.js"),
|
|
43
|
+
start: () => import("./commands/start.js"),
|
|
44
|
+
stop: () => import("./commands/stop.js"),
|
|
45
|
+
status: () => import("./commands/status.js"),
|
|
46
|
+
run: () => import("./commands/run.js"),
|
|
47
|
+
recover: () => import("./commands/recover.js"),
|
|
48
|
+
logs: () => import("./commands/logs.js"),
|
|
49
|
+
project: () => import("./commands/project.js"),
|
|
50
|
+
repo: () => import("./commands/repo.js"),
|
|
51
|
+
config: () => import("./commands/config-cmd.js"),
|
|
52
|
+
help: () => import("./commands/help.js"),
|
|
53
|
+
version: () => import("./commands/version.js"),
|
|
54
|
+
};
|
|
55
|
+
export async function runCli(argv) {
|
|
56
|
+
const { options, command, args } = parseGlobalOptions(argv);
|
|
57
|
+
if (options.noColor) {
|
|
58
|
+
process.env.NO_COLOR = "1";
|
|
59
|
+
}
|
|
60
|
+
if (command === "--help" || command === "-h") {
|
|
61
|
+
const helpModule = await COMMANDS.help();
|
|
62
|
+
await helpModule.default(args, options);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (command === "--version" || command === "-V") {
|
|
66
|
+
const versionModule = await COMMANDS.version();
|
|
67
|
+
await versionModule.default(args, options);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const loader = COMMANDS[command];
|
|
71
|
+
if (!loader) {
|
|
72
|
+
process.stderr.write(`Unknown command: ${command}\nRun 'gh-symphony help' for usage.\n`);
|
|
73
|
+
process.exitCode = 2;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const module = await loader();
|
|
77
|
+
await module.default(args, options);
|
|
78
|
+
}
|
|
79
|
+
async function main() {
|
|
80
|
+
await runCli(process.argv.slice(2));
|
|
81
|
+
}
|
|
82
|
+
if (process.argv[1] &&
|
|
83
|
+
import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
84
|
+
main().catch((error) => {
|
|
85
|
+
process.stderr.write(`${error instanceof Error ? error.message : "Unknown error"}\n`);
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { WorkflowLifecycleConfig } from "@hojinzs/gh-symphony-core";
|
|
2
|
+
import type { ColumnRole, HumanReviewMode } from "../config.js";
|
|
3
|
+
export type ColumnMapping = {
|
|
4
|
+
columnName: string;
|
|
5
|
+
role: ColumnRole | null;
|
|
6
|
+
confidence: "high" | "low";
|
|
7
|
+
};
|
|
8
|
+
export declare function inferColumnRole(columnName: string): ColumnMapping;
|
|
9
|
+
export declare function inferAllColumnRoles(columnNames: string[]): ColumnMapping[];
|
|
10
|
+
export type PhaseMapping = {
|
|
11
|
+
planningStates: string[];
|
|
12
|
+
humanReviewStates: string[];
|
|
13
|
+
implementationStates: string[];
|
|
14
|
+
awaitingMergeStates: string[];
|
|
15
|
+
completedStates: string[];
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Map column roles to Symphony execution phases based on the human-review mode.
|
|
19
|
+
*
|
|
20
|
+
* Modes:
|
|
21
|
+
* - plan-and-pr: Human reviews both plans and PRs (full review pipeline)
|
|
22
|
+
* - plan-only: Human reviews plans, PRs auto-merge
|
|
23
|
+
* - pr-only: No plan review, human reviews PRs
|
|
24
|
+
* - none: No human review at all (full auto)
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildPhaseMapping(roles: Record<string, ColumnRole>, mode: HumanReviewMode): PhaseMapping;
|
|
27
|
+
export declare function toWorkflowLifecycleConfig(stateFieldName: string, roles: Record<string, ColumnRole>, mode: HumanReviewMode): WorkflowLifecycleConfig;
|
|
28
|
+
export type MappingValidationResult = {
|
|
29
|
+
valid: boolean;
|
|
30
|
+
errors: string[];
|
|
31
|
+
warnings: string[];
|
|
32
|
+
};
|
|
33
|
+
export declare function validateMapping(roles: Record<string, ColumnRole>): MappingValidationResult;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// ── 3.1: Smart defaults pattern matching ─────────────────────────────────────
|
|
2
|
+
const ROLE_PATTERNS = [
|
|
3
|
+
{
|
|
4
|
+
role: "trigger",
|
|
5
|
+
pattern: /^(todo|to.do|to-do|ready|queued|open|new|triage)$/i,
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
role: "working",
|
|
9
|
+
pattern: /^(in.progress|working|active|doing|in.development|developing|wip)$/i,
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
role: "human-review",
|
|
13
|
+
pattern: /^(review|in.review|pr.review|needs.review|plan.review|awaiting.review|code.review)$/i,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
role: "done",
|
|
17
|
+
pattern: /^(done|completed?|closed|merged|shipped|resolved|finished)$/i,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
role: "ignored",
|
|
21
|
+
pattern: /^(icebox|someday|later|blocked|on.hold|paused|won.?t.do|cancelled|deferred|draft|backlog)$/i,
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
export function inferColumnRole(columnName) {
|
|
25
|
+
const normalized = columnName.trim();
|
|
26
|
+
for (const { role, pattern } of ROLE_PATTERNS) {
|
|
27
|
+
if (pattern.test(normalized)) {
|
|
28
|
+
return { columnName: normalized, role, confidence: "high" };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { columnName: normalized, role: null, confidence: "low" };
|
|
32
|
+
}
|
|
33
|
+
export function inferAllColumnRoles(columnNames) {
|
|
34
|
+
return columnNames.map(inferColumnRole);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Map column roles to Symphony execution phases based on the human-review mode.
|
|
38
|
+
*
|
|
39
|
+
* Modes:
|
|
40
|
+
* - plan-and-pr: Human reviews both plans and PRs (full review pipeline)
|
|
41
|
+
* - plan-only: Human reviews plans, PRs auto-merge
|
|
42
|
+
* - pr-only: No plan review, human reviews PRs
|
|
43
|
+
* - none: No human review at all (full auto)
|
|
44
|
+
*/
|
|
45
|
+
export function buildPhaseMapping(roles, mode) {
|
|
46
|
+
const planningStates = [];
|
|
47
|
+
const humanReviewStates = [];
|
|
48
|
+
const implementationStates = [];
|
|
49
|
+
const awaitingMergeStates = [];
|
|
50
|
+
const completedStates = [];
|
|
51
|
+
for (const [columnName, role] of Object.entries(roles)) {
|
|
52
|
+
switch (role) {
|
|
53
|
+
case "trigger":
|
|
54
|
+
planningStates.push(columnName);
|
|
55
|
+
break;
|
|
56
|
+
case "working":
|
|
57
|
+
implementationStates.push(columnName);
|
|
58
|
+
break;
|
|
59
|
+
case "human-review":
|
|
60
|
+
switch (mode) {
|
|
61
|
+
case "plan-and-pr":
|
|
62
|
+
humanReviewStates.push(columnName);
|
|
63
|
+
break;
|
|
64
|
+
case "plan-only":
|
|
65
|
+
humanReviewStates.push(columnName);
|
|
66
|
+
break;
|
|
67
|
+
case "pr-only":
|
|
68
|
+
awaitingMergeStates.push(columnName);
|
|
69
|
+
break;
|
|
70
|
+
case "none":
|
|
71
|
+
// In "none" mode, review columns are treated as implementation
|
|
72
|
+
implementationStates.push(columnName);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case "done":
|
|
77
|
+
completedStates.push(columnName);
|
|
78
|
+
break;
|
|
79
|
+
case "ignored":
|
|
80
|
+
// Ignored columns don't map to any phase
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
planningStates,
|
|
86
|
+
humanReviewStates,
|
|
87
|
+
implementationStates,
|
|
88
|
+
awaitingMergeStates,
|
|
89
|
+
completedStates,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// ── 3.3: Mapping → WorkflowLifecycleConfig conversion ───────────────────────
|
|
93
|
+
export function toWorkflowLifecycleConfig(stateFieldName, roles, mode) {
|
|
94
|
+
const phases = buildPhaseMapping(roles, mode);
|
|
95
|
+
// Transition targets: where issues move when a phase completes
|
|
96
|
+
const planningCompleteState = resolveTransitionTarget(phases, "planning", mode);
|
|
97
|
+
const implementationCompleteState = resolveTransitionTarget(phases, "implementation", mode);
|
|
98
|
+
const mergeCompleteState = phases.completedStates[0] ?? phases.awaitingMergeStates[0] ?? "Done";
|
|
99
|
+
return {
|
|
100
|
+
stateFieldName,
|
|
101
|
+
planningStates: phases.planningStates,
|
|
102
|
+
humanReviewStates: phases.humanReviewStates,
|
|
103
|
+
implementationStates: phases.implementationStates,
|
|
104
|
+
awaitingMergeStates: phases.awaitingMergeStates,
|
|
105
|
+
completedStates: phases.completedStates,
|
|
106
|
+
planningCompleteState,
|
|
107
|
+
implementationCompleteState,
|
|
108
|
+
mergeCompleteState,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function resolveTransitionTarget(phases, fromPhase, mode) {
|
|
112
|
+
if (fromPhase === "planning") {
|
|
113
|
+
// After planning: go to human-review (if exists) or implementation
|
|
114
|
+
if ((mode === "plan-and-pr" || mode === "plan-only") &&
|
|
115
|
+
phases.humanReviewStates.length > 0) {
|
|
116
|
+
return phases.humanReviewStates[0];
|
|
117
|
+
}
|
|
118
|
+
return phases.implementationStates[0] ?? "In Progress";
|
|
119
|
+
}
|
|
120
|
+
// After implementation: go to awaiting-merge (if exists) or completed
|
|
121
|
+
if ((mode === "plan-and-pr" || mode === "pr-only") &&
|
|
122
|
+
phases.awaitingMergeStates.length > 0) {
|
|
123
|
+
return phases.awaitingMergeStates[0];
|
|
124
|
+
}
|
|
125
|
+
return phases.completedStates[0] ?? "Done";
|
|
126
|
+
}
|
|
127
|
+
export function validateMapping(roles) {
|
|
128
|
+
const errors = [];
|
|
129
|
+
const warnings = [];
|
|
130
|
+
const roleEntries = Object.entries(roles);
|
|
131
|
+
const triggerColumns = roleEntries.filter(([, r]) => r === "trigger");
|
|
132
|
+
const workingColumns = roleEntries.filter(([, r]) => r === "working");
|
|
133
|
+
const doneColumns = roleEntries.filter(([, r]) => r === "done");
|
|
134
|
+
const reviewColumns = roleEntries.filter(([, r]) => r === "human-review");
|
|
135
|
+
// Required roles
|
|
136
|
+
if (triggerColumns.length === 0) {
|
|
137
|
+
errors.push("Missing required role: 'trigger' — at least one column must trigger work.");
|
|
138
|
+
}
|
|
139
|
+
if (workingColumns.length === 0) {
|
|
140
|
+
errors.push("Missing required role: 'working' — at least one column must represent active work.");
|
|
141
|
+
}
|
|
142
|
+
if (doneColumns.length === 0) {
|
|
143
|
+
errors.push("Missing required role: 'done' — at least one column must represent completion.");
|
|
144
|
+
}
|
|
145
|
+
// Warnings for unusual setups
|
|
146
|
+
if (triggerColumns.length > 1) {
|
|
147
|
+
warnings.push(`Multiple trigger columns: ${triggerColumns.map(([n]) => n).join(", ")}. ` +
|
|
148
|
+
"All will be treated as planning states.");
|
|
149
|
+
}
|
|
150
|
+
if (doneColumns.length > 1) {
|
|
151
|
+
warnings.push(`Multiple done columns: ${doneColumns.map(([n]) => n).join(", ")}. ` +
|
|
152
|
+
"All will be treated as completed states.");
|
|
153
|
+
}
|
|
154
|
+
if (reviewColumns.length > 2) {
|
|
155
|
+
warnings.push(`${reviewColumns.length} review columns detected. ` +
|
|
156
|
+
"Consider simplifying to one or two review stages.");
|
|
157
|
+
}
|
|
158
|
+
return { valid: errors.length === 0, errors, warnings };
|
|
159
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type CliWorkspaceConfig } from "./config.js";
|
|
2
|
+
export declare function resolveRuntimeRoot(configDir: string): string;
|
|
3
|
+
export declare function resolveWorkspaceConfig(configDir: string, requestedWorkspaceId?: string): Promise<CliWorkspaceConfig | null>;
|
|
4
|
+
export declare function orchestratorWorkspaceConfigPath(runtimeRoot: string, workspaceId: string): string;
|
|
5
|
+
export declare function syncWorkspaceToRuntime(configDir: string, workspaceConfig: CliWorkspaceConfig): Promise<string>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { loadGlobalConfig, loadWorkspaceConfig, } from "./config.js";
|
|
4
|
+
export function resolveRuntimeRoot(configDir) {
|
|
5
|
+
return resolve(configDir);
|
|
6
|
+
}
|
|
7
|
+
export async function resolveWorkspaceConfig(configDir, requestedWorkspaceId) {
|
|
8
|
+
if (requestedWorkspaceId) {
|
|
9
|
+
return loadWorkspaceConfig(configDir, requestedWorkspaceId);
|
|
10
|
+
}
|
|
11
|
+
const global = await loadGlobalConfig(configDir);
|
|
12
|
+
if (!global?.activeWorkspace) {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
return loadWorkspaceConfig(configDir, global.activeWorkspace);
|
|
16
|
+
}
|
|
17
|
+
export function orchestratorWorkspaceConfigPath(runtimeRoot, workspaceId) {
|
|
18
|
+
return join(runtimeRoot, "orchestrator", "workspaces", workspaceId, "config.json");
|
|
19
|
+
}
|
|
20
|
+
export async function syncWorkspaceToRuntime(configDir, workspaceConfig) {
|
|
21
|
+
const runtimeRoot = resolveRuntimeRoot(configDir);
|
|
22
|
+
const configPath = orchestratorWorkspaceConfigPath(runtimeRoot, workspaceConfig.workspaceId);
|
|
23
|
+
await mkdir(dirname(configPath), { recursive: true });
|
|
24
|
+
await writeFile(configPath, JSON.stringify(workspaceConfig, null, 2) + "\n");
|
|
25
|
+
return runtimeRoot;
|
|
26
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gh-symphony/cli",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"author": "hojinzs",
|
|
6
|
+
"description": "Interactive CLI for GitHub Symphony orchestration",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"gh-symphony": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/hojinzs/github-symphony.git",
|
|
31
|
+
"directory": "packages/cli"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://github.com/hojinzs/github-symphony#readme",
|
|
34
|
+
"bugs": {
|
|
35
|
+
"url": "https://github.com/hojinzs/github-symphony/issues"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@clack/prompts": "^0.9.1",
|
|
39
|
+
"@gh-symphony/core": "0.0.1",
|
|
40
|
+
"@gh-symphony/orchestrator": "0.0.1",
|
|
41
|
+
"@gh-symphony/tracker-github": "0.0.1"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"build": "tsc -p tsconfig.json",
|
|
45
|
+
"lint": "eslint src --ext .ts",
|
|
46
|
+
"test": "vitest run --passWithNoTests",
|
|
47
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
48
|
+
}
|
|
49
|
+
}
|