jfl 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/doctor.d.ts +1 -0
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +30 -1
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/ide.d.ts +2 -1
- package/dist/commands/ide.d.ts.map +1 -1
- package/dist/commands/ide.js +60 -1
- package/dist/commands/ide.js.map +1 -1
- package/dist/commands/init-from-service.d.ts +15 -0
- package/dist/commands/init-from-service.d.ts.map +1 -0
- package/dist/commands/init-from-service.js +541 -0
- package/dist/commands/init-from-service.js.map +1 -0
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +32 -1
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/kanban.d.ts.map +1 -1
- package/dist/commands/kanban.js +13 -4
- package/dist/commands/kanban.js.map +1 -1
- package/dist/commands/linear.d.ts +41 -0
- package/dist/commands/linear.d.ts.map +1 -0
- package/dist/commands/linear.js +715 -0
- package/dist/commands/linear.js.map +1 -0
- package/dist/commands/peter.d.ts.map +1 -1
- package/dist/commands/peter.js +232 -25
- package/dist/commands/peter.js.map +1 -1
- package/dist/commands/services.d.ts.map +1 -1
- package/dist/commands/services.js +146 -0
- package/dist/commands/services.js.map +1 -1
- package/dist/commands/setup.d.ts.map +1 -1
- package/dist/commands/setup.js +173 -13
- package/dist/commands/setup.js.map +1 -1
- package/dist/commands/telemetry-monitor.d.ts +11 -0
- package/dist/commands/telemetry-monitor.d.ts.map +1 -0
- package/dist/commands/telemetry-monitor.js +224 -0
- package/dist/commands/telemetry-monitor.js.map +1 -0
- package/dist/commands/telemetry-test.d.ts +11 -0
- package/dist/commands/telemetry-test.d.ts.map +1 -0
- package/dist/commands/telemetry-test.js +67 -0
- package/dist/commands/telemetry-test.js.map +1 -0
- package/dist/commands/tenet-agents.d.ts +13 -0
- package/dist/commands/tenet-agents.d.ts.map +1 -0
- package/dist/commands/tenet-agents.js +191 -0
- package/dist/commands/tenet-agents.js.map +1 -0
- package/dist/commands/tenet-setup.d.ts +19 -0
- package/dist/commands/tenet-setup.d.ts.map +1 -0
- package/dist/commands/tenet-setup.js +131 -0
- package/dist/commands/tenet-setup.js.map +1 -0
- package/dist/commands/train.d.ts +18 -0
- package/dist/commands/train.d.ts.map +1 -1
- package/dist/commands/train.js +182 -0
- package/dist/commands/train.js.map +1 -1
- package/dist/commands/whoami.d.ts +2 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +24 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.js +159 -10
- package/dist/index.js.map +1 -1
- package/dist/lib/advanced-setup.d.ts +78 -0
- package/dist/lib/advanced-setup.d.ts.map +1 -0
- package/dist/lib/advanced-setup.js +433 -0
- package/dist/lib/advanced-setup.js.map +1 -0
- package/dist/lib/agent-config.d.ts +33 -0
- package/dist/lib/agent-config.d.ts.map +1 -1
- package/dist/lib/agent-config.js +26 -0
- package/dist/lib/agent-config.js.map +1 -1
- package/dist/lib/counterfactual-training-bridge.d.ts +114 -0
- package/dist/lib/counterfactual-training-bridge.d.ts.map +1 -0
- package/dist/lib/counterfactual-training-bridge.js +322 -0
- package/dist/lib/counterfactual-training-bridge.js.map +1 -0
- package/dist/lib/discovery-agent.d.ts +48 -0
- package/dist/lib/discovery-agent.d.ts.map +1 -0
- package/dist/lib/discovery-agent.js +111 -0
- package/dist/lib/discovery-agent.js.map +1 -0
- package/dist/lib/flow-engine.d.ts.map +1 -1
- package/dist/lib/flow-engine.js +46 -8
- package/dist/lib/flow-engine.js.map +1 -1
- package/dist/lib/gtm-generator.d.ts +29 -0
- package/dist/lib/gtm-generator.d.ts.map +1 -0
- package/dist/lib/gtm-generator.js +252 -0
- package/dist/lib/gtm-generator.js.map +1 -0
- package/dist/lib/hub-health.d.ts +40 -0
- package/dist/lib/hub-health.d.ts.map +1 -0
- package/dist/lib/hub-health.js +89 -0
- package/dist/lib/hub-health.js.map +1 -0
- package/dist/lib/invariant-monitor.d.ts +6 -2
- package/dist/lib/invariant-monitor.d.ts.map +1 -1
- package/dist/lib/invariant-monitor.js +89 -2
- package/dist/lib/invariant-monitor.js.map +1 -1
- package/dist/lib/journal-analyzer.d.ts +71 -0
- package/dist/lib/journal-analyzer.d.ts.map +1 -0
- package/dist/lib/journal-analyzer.js +306 -0
- package/dist/lib/journal-analyzer.js.map +1 -0
- package/dist/lib/linear-client.d.ts +73 -0
- package/dist/lib/linear-client.d.ts.map +1 -0
- package/dist/lib/linear-client.js +112 -0
- package/dist/lib/linear-client.js.map +1 -0
- package/dist/lib/linear-id-map.d.ts +20 -0
- package/dist/lib/linear-id-map.d.ts.map +1 -0
- package/dist/lib/linear-id-map.js +57 -0
- package/dist/lib/linear-id-map.js.map +1 -0
- package/dist/lib/linear-kanban.d.ts +66 -0
- package/dist/lib/linear-kanban.d.ts.map +1 -0
- package/dist/lib/linear-kanban.js +175 -0
- package/dist/lib/linear-kanban.js.map +1 -0
- package/dist/lib/onboarding.d.ts +40 -0
- package/dist/lib/onboarding.d.ts.map +1 -0
- package/dist/lib/onboarding.js +213 -0
- package/dist/lib/onboarding.js.map +1 -0
- package/dist/lib/physical-world-model.d.ts +50 -0
- package/dist/lib/physical-world-model.d.ts.map +1 -0
- package/dist/lib/physical-world-model.js +251 -0
- package/dist/lib/physical-world-model.js.map +1 -0
- package/dist/lib/planning-loop.d.ts +157 -0
- package/dist/lib/planning-loop.d.ts.map +1 -0
- package/dist/lib/planning-loop.js +537 -0
- package/dist/lib/planning-loop.js.map +1 -0
- package/dist/lib/policy-head.d.ts +13 -0
- package/dist/lib/policy-head.d.ts.map +1 -1
- package/dist/lib/policy-head.js +168 -2
- package/dist/lib/policy-head.js.map +1 -1
- package/dist/lib/resource-optimizer-middleware.d.ts +39 -0
- package/dist/lib/resource-optimizer-middleware.d.ts.map +1 -0
- package/dist/lib/resource-optimizer-middleware.js +222 -0
- package/dist/lib/resource-optimizer-middleware.js.map +1 -0
- package/dist/lib/resource-optimizer.d.ts +71 -0
- package/dist/lib/resource-optimizer.d.ts.map +1 -0
- package/dist/lib/resource-optimizer.js +228 -0
- package/dist/lib/resource-optimizer.js.map +1 -0
- package/dist/lib/rl-manager.d.ts +74 -0
- package/dist/lib/rl-manager.d.ts.map +1 -0
- package/dist/lib/rl-manager.js +244 -0
- package/dist/lib/rl-manager.js.map +1 -0
- package/dist/lib/service-analyzer.d.ts +76 -0
- package/dist/lib/service-analyzer.d.ts.map +1 -0
- package/dist/lib/service-analyzer.js +704 -0
- package/dist/lib/service-analyzer.js.map +1 -0
- package/dist/lib/service-gtm.js +2 -2
- package/dist/lib/service-gtm.js.map +1 -1
- package/dist/lib/service-questionnaire.d.ts +11 -0
- package/dist/lib/service-questionnaire.d.ts.map +1 -0
- package/dist/lib/service-questionnaire.js +89 -0
- package/dist/lib/service-questionnaire.js.map +1 -0
- package/dist/lib/setup/agent-generator.d.ts +2 -0
- package/dist/lib/setup/agent-generator.d.ts.map +1 -1
- package/dist/lib/setup/agent-generator.js +128 -4
- package/dist/lib/setup/agent-generator.js.map +1 -1
- package/dist/lib/setup/flow-generator.d.ts +10 -0
- package/dist/lib/setup/flow-generator.d.ts.map +1 -0
- package/dist/lib/setup/flow-generator.js +113 -0
- package/dist/lib/setup/flow-generator.js.map +1 -0
- package/dist/lib/setup/invariant-bridge.d.ts +91 -0
- package/dist/lib/setup/invariant-bridge.d.ts.map +1 -0
- package/dist/lib/setup/invariant-bridge.js +384 -0
- package/dist/lib/setup/invariant-bridge.js.map +1 -0
- package/dist/lib/setup/spec-generator.d.ts +41 -5
- package/dist/lib/setup/spec-generator.d.ts.map +1 -1
- package/dist/lib/setup/spec-generator.js +503 -29
- package/dist/lib/setup/spec-generator.js.map +1 -1
- package/dist/lib/stratus-client.js +1 -1
- package/dist/lib/stratus-client.js.map +1 -1
- package/dist/lib/surface-agent.d.ts +78 -0
- package/dist/lib/surface-agent.d.ts.map +1 -0
- package/dist/lib/surface-agent.js +105 -0
- package/dist/lib/surface-agent.js.map +1 -0
- package/dist/lib/surface-coordination-example.d.ts +30 -0
- package/dist/lib/surface-coordination-example.d.ts.map +1 -0
- package/dist/lib/surface-coordination-example.js +164 -0
- package/dist/lib/surface-coordination-example.js.map +1 -0
- package/dist/lib/telemetry/physical-world-collector.d.ts +15 -0
- package/dist/lib/telemetry/physical-world-collector.d.ts.map +1 -0
- package/dist/lib/telemetry/physical-world-collector.js +177 -0
- package/dist/lib/telemetry/physical-world-collector.js.map +1 -0
- package/dist/lib/telemetry/training-bridge.d.ts +51 -0
- package/dist/lib/telemetry/training-bridge.d.ts.map +1 -0
- package/dist/lib/telemetry/training-bridge.js +185 -0
- package/dist/lib/telemetry/training-bridge.js.map +1 -0
- package/dist/lib/telemetry.d.ts +2 -1
- package/dist/lib/telemetry.d.ts.map +1 -1
- package/dist/lib/telemetry.js +23 -2
- package/dist/lib/telemetry.js.map +1 -1
- package/dist/lib/tenet-board-agent.d.ts +52 -0
- package/dist/lib/tenet-board-agent.d.ts.map +1 -0
- package/dist/lib/tenet-board-agent.js +226 -0
- package/dist/lib/tenet-board-agent.js.map +1 -0
- package/dist/lib/tenet-ide-agent.d.ts +40 -0
- package/dist/lib/tenet-ide-agent.d.ts.map +1 -0
- package/dist/lib/tenet-ide-agent.js +199 -0
- package/dist/lib/tenet-ide-agent.js.map +1 -0
- package/dist/lib/workspace/data-pipeline.d.ts.map +1 -1
- package/dist/lib/workspace/data-pipeline.js +27 -5
- package/dist/lib/workspace/data-pipeline.js.map +1 -1
- package/dist/lib/workspace/sidebar-runner.d.ts +13 -0
- package/dist/lib/workspace/sidebar-runner.d.ts.map +1 -0
- package/dist/lib/workspace/sidebar-runner.js +419 -0
- package/dist/lib/workspace/sidebar-runner.js.map +1 -0
- package/dist/lib/workspace/surface-registry.d.ts.map +1 -1
- package/dist/lib/workspace/surface-registry.js +4 -1
- package/dist/lib/workspace/surface-registry.js.map +1 -1
- package/dist/lib/workspace/surfaces/agent-overview.d.ts +3 -3
- package/dist/lib/workspace/surfaces/agent-overview.d.ts.map +1 -1
- package/dist/lib/workspace/surfaces/agent-overview.js +3 -3
- package/dist/lib/workspace/surfaces/agent-overview.js.map +1 -1
- package/dist/lib/workspace/surfaces/index.d.ts +3 -0
- package/dist/lib/workspace/surfaces/index.d.ts.map +1 -1
- package/dist/lib/workspace/surfaces/index.js +3 -0
- package/dist/lib/workspace/surfaces/index.js.map +1 -1
- package/dist/lib/workspace/surfaces/kanban.d.ts +15 -0
- package/dist/lib/workspace/surfaces/kanban.d.ts.map +1 -0
- package/dist/lib/workspace/surfaces/kanban.js +43 -0
- package/dist/lib/workspace/surfaces/kanban.js.map +1 -0
- package/dist/lib/workspace/surfaces/physical-world.d.ts +15 -0
- package/dist/lib/workspace/surfaces/physical-world.d.ts.map +1 -0
- package/dist/lib/workspace/surfaces/physical-world.js +37 -0
- package/dist/lib/workspace/surfaces/physical-world.js.map +1 -0
- package/dist/lib/workspace/surfaces/sidebar.d.ts +22 -0
- package/dist/lib/workspace/surfaces/sidebar.d.ts.map +1 -0
- package/dist/lib/workspace/surfaces/sidebar.js +90 -0
- package/dist/lib/workspace/surfaces/sidebar.js.map +1 -0
- package/dist/types/flows.d.ts +2 -1
- package/dist/types/flows.d.ts.map +1 -1
- package/dist/types/physical-world-model.d.ts +65 -0
- package/dist/types/physical-world-model.d.ts.map +1 -0
- package/dist/types/physical-world-model.js +43 -0
- package/dist/types/physical-world-model.js.map +1 -0
- package/dist/types/telemetry.d.ts +37 -0
- package/dist/types/telemetry.d.ts.map +1 -1
- package/dist/types/world-model.d.ts.map +1 -1
- package/dist/types/world-model.js +14 -7
- package/dist/types/world-model.js.map +1 -1
- package/dist/utils/context-hub-port.d.ts.map +1 -1
- package/dist/utils/context-hub-port.js +6 -1
- package/dist/utils/context-hub-port.js.map +1 -1
- package/package.json +3 -2
- package/packages/pi/extensions/index.ts +34 -6
- package/packages/pi/extensions/onboarding-v1.ts +8 -8
- package/packages/pi/extensions/onboarding-v2.ts +5 -5
- package/scripts/telemetry-dashboard.sh +44 -0
- package/scripts/test-planning-loop-e2e.ts +181 -0
- package/scripts/test-server-inference.ts +49 -0
- package/scripts/test-state-sensitivity.ts +32 -0
- package/scripts/train/v2/benchmark.py +661 -0
- package/scripts/train/v2/generate_balanced.py +439 -0
- package/scripts/train/v2/generate_hard_negatives.py +219 -0
- package/scripts/train/v2/infer.py +149 -36
- package/scripts/train/v2/infer_server.py +224 -0
- package/scripts/train/v2/online_train.py +576 -0
- package/scripts/train/v2/precompute.py +24 -6
- package/template/CLAUDE.md +74 -132
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jfl linear — Linear project sync
|
|
3
|
+
*
|
|
4
|
+
* Bidirectional sync between Linear projects and GitHub Issues (jfl kanban).
|
|
5
|
+
* Subcommands: link, unlink, status, sync
|
|
6
|
+
*
|
|
7
|
+
* @purpose CLI commands for Linear ↔ GitHub kanban bidirectional sync
|
|
8
|
+
*/
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
import inquirer from "inquirer";
|
|
12
|
+
import { execSync } from "child_process";
|
|
13
|
+
import { existsSync, readFileSync, writeFileSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
import { getLinearClient, getLinearTeams, getLinearProjects, getTeamStates, getProjectIssues, createLinearIssue, updateLinearIssue, } from "../lib/linear-client.js";
|
|
16
|
+
import { readMap, writeMap, addMapping, } from "../lib/linear-id-map.js";
|
|
17
|
+
import { linearToTenet, kanbanToTenet, tenetToLinearCreate, tenetToGitHubBody, tenetToGitHubLabels, findLinearStateId, } from "../lib/linear-kanban.js";
|
|
18
|
+
import { GitHubKanban } from "../lib/kanban-github.js";
|
|
19
|
+
import { getProjectDataDir } from "../utils/jfl-config.js";
|
|
20
|
+
// ============================================================================
|
|
21
|
+
// Helpers
|
|
22
|
+
// ============================================================================
|
|
23
|
+
function gh(args, cwd) {
|
|
24
|
+
return execSync(`gh ${args}`, {
|
|
25
|
+
cwd,
|
|
26
|
+
encoding: "utf-8",
|
|
27
|
+
timeout: 15000,
|
|
28
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
29
|
+
}).trim();
|
|
30
|
+
}
|
|
31
|
+
function readProjectConfig(projectRoot) {
|
|
32
|
+
const dataDir = getProjectDataDir(projectRoot);
|
|
33
|
+
const configPath = join(projectRoot, dataDir, "config.json");
|
|
34
|
+
if (!existsSync(configPath))
|
|
35
|
+
return {};
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function writeProjectConfig(projectRoot, config) {
|
|
44
|
+
const dataDir = getProjectDataDir(projectRoot);
|
|
45
|
+
const configPath = join(projectRoot, dataDir, "config.json");
|
|
46
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
47
|
+
}
|
|
48
|
+
// Env vars override file config — enables CI/GitHub Actions usage without .jfl/config.json
|
|
49
|
+
function requireLinearConfig(projectRoot) {
|
|
50
|
+
const config = readProjectConfig(projectRoot);
|
|
51
|
+
const base = config.linear ?? {};
|
|
52
|
+
const merged = {
|
|
53
|
+
apiKey: process.env.LINEAR_API_KEY ?? base.apiKey ?? "",
|
|
54
|
+
teamId: process.env.LINEAR_TEAM_ID ?? base.teamId ?? "",
|
|
55
|
+
projectId: process.env.LINEAR_PROJECT_ID ?? base.projectId ?? "",
|
|
56
|
+
scope: process.env.LINEAR_SCOPE ?? base.scope,
|
|
57
|
+
kanbanRepo: base.kanbanRepo,
|
|
58
|
+
webhookUrl: base.webhookUrl,
|
|
59
|
+
syncEnabled: base.syncEnabled,
|
|
60
|
+
teamName: base.teamName,
|
|
61
|
+
projectName: base.projectName,
|
|
62
|
+
};
|
|
63
|
+
if (!merged.apiKey || !merged.teamId || !merged.projectId) {
|
|
64
|
+
console.error(chalk.red("No Linear config found. Run: jfl linear link (or set LINEAR_API_KEY, LINEAR_TEAM_ID, LINEAR_PROJECT_ID env vars)"));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
return merged;
|
|
68
|
+
}
|
|
69
|
+
function getGitHubRepo(projectRoot) {
|
|
70
|
+
try {
|
|
71
|
+
const remote = execSync("git remote get-url origin", {
|
|
72
|
+
cwd: projectRoot, encoding: "utf-8", timeout: 5000,
|
|
73
|
+
}).trim();
|
|
74
|
+
const match = remote.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
|
75
|
+
return match ? match[1] : "";
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return "";
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function getColumnLabel(status) {
|
|
82
|
+
const map = {
|
|
83
|
+
backlog: "jfl/backlog",
|
|
84
|
+
in_progress: "jfl/in-progress",
|
|
85
|
+
eval: "jfl/eval",
|
|
86
|
+
done: "jfl/done",
|
|
87
|
+
};
|
|
88
|
+
return map[status] ?? "jfl/backlog";
|
|
89
|
+
}
|
|
90
|
+
// ============================================================================
|
|
91
|
+
// jfl linear link
|
|
92
|
+
// ============================================================================
|
|
93
|
+
export async function linearLink(projectRoot) {
|
|
94
|
+
console.log(chalk.bold("\n Link Linear Project\n"));
|
|
95
|
+
const { apiKey } = await inquirer.prompt([
|
|
96
|
+
{
|
|
97
|
+
type: "password",
|
|
98
|
+
name: "apiKey",
|
|
99
|
+
message: "Linear API key (lin_api_...):",
|
|
100
|
+
validate: (v) => v.startsWith("lin_api_") ? true : "Must start with lin_api_",
|
|
101
|
+
},
|
|
102
|
+
]);
|
|
103
|
+
const spinner = ora("Fetching Linear teams...").start();
|
|
104
|
+
let client;
|
|
105
|
+
let teams;
|
|
106
|
+
try {
|
|
107
|
+
client = getLinearClient(apiKey);
|
|
108
|
+
teams = await getLinearTeams(client);
|
|
109
|
+
spinner.stop();
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
spinner.fail("Failed to connect to Linear: " + (err.message ?? err));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (teams.length === 0) {
|
|
116
|
+
console.error(chalk.red("No Linear teams found for this API key."));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const { teamId } = await inquirer.prompt([
|
|
120
|
+
{
|
|
121
|
+
type: "list",
|
|
122
|
+
name: "teamId",
|
|
123
|
+
message: "Select Linear team:",
|
|
124
|
+
choices: teams.map(t => ({ name: `${t.name} (${t.key})`, value: t.id })),
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
spinner.start("Fetching Linear projects...");
|
|
128
|
+
let projects;
|
|
129
|
+
try {
|
|
130
|
+
projects = await getLinearProjects(client, teamId);
|
|
131
|
+
spinner.stop();
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
spinner.fail("Failed to fetch projects: " + (err.message ?? err));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (projects.length === 0) {
|
|
138
|
+
console.error(chalk.red("No Linear projects found for this team."));
|
|
139
|
+
console.log(chalk.gray(" Create a project in Linear first, then run jfl linear link again."));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const { projectId } = await inquirer.prompt([
|
|
143
|
+
{
|
|
144
|
+
type: "list",
|
|
145
|
+
name: "projectId",
|
|
146
|
+
message: "Select Linear project:",
|
|
147
|
+
choices: projects.map(p => ({
|
|
148
|
+
name: p.description ? `${p.name} — ${p.description.slice(0, 50)}` : p.name,
|
|
149
|
+
value: p.id,
|
|
150
|
+
})),
|
|
151
|
+
},
|
|
152
|
+
]);
|
|
153
|
+
// ── Scope detection ────────────────────────────────────────────────────────
|
|
154
|
+
// Detect if we're in a service (has gtm_parent) or a GTM with registered services.
|
|
155
|
+
// Linear project → JFL scope is a natural 1:1 mapping.
|
|
156
|
+
const projectConfig = readProjectConfig(projectRoot);
|
|
157
|
+
const registeredServices = projectConfig.registered_services ?? [];
|
|
158
|
+
const gtmParent = projectConfig.gtm_parent;
|
|
159
|
+
// Determine kanban repo: if this is a service, the kanban lives in the parent GTM
|
|
160
|
+
let kanbanRoot = projectRoot;
|
|
161
|
+
let kanbanRepoHint = "";
|
|
162
|
+
if (gtmParent && existsSync(gtmParent)) {
|
|
163
|
+
kanbanRoot = gtmParent;
|
|
164
|
+
kanbanRepoHint = getGitHubRepo(gtmParent);
|
|
165
|
+
}
|
|
166
|
+
const detectedRepo = getGitHubRepo(kanbanRoot) || kanbanRepoHint;
|
|
167
|
+
// Scope detection — build list of available scopes
|
|
168
|
+
const availableScopes = [];
|
|
169
|
+
if (gtmParent) {
|
|
170
|
+
// Service context — scope is this service's name
|
|
171
|
+
const { basename } = await import("path");
|
|
172
|
+
const serviceName = projectConfig.name ?? basename(projectRoot);
|
|
173
|
+
availableScopes.push(serviceName);
|
|
174
|
+
}
|
|
175
|
+
else if (registeredServices.length > 0) {
|
|
176
|
+
for (const svc of registeredServices)
|
|
177
|
+
availableScopes.push(svc.name);
|
|
178
|
+
}
|
|
179
|
+
let scopes = [];
|
|
180
|
+
if (availableScopes.length > 0) {
|
|
181
|
+
const checkboxChoices = [
|
|
182
|
+
{ name: chalk.dim("— no scope (all issues, no service label) —"), value: "__none__" },
|
|
183
|
+
new inquirer.Separator(" "),
|
|
184
|
+
...availableScopes.map(s => ({ name: `scope:${s}`, value: s })),
|
|
185
|
+
];
|
|
186
|
+
const answer = await inquirer.prompt([
|
|
187
|
+
{
|
|
188
|
+
type: "checkbox",
|
|
189
|
+
name: "scopes",
|
|
190
|
+
message: "Which scopes should this Linear project sync to? (space to select, enter to confirm)",
|
|
191
|
+
choices: checkboxChoices,
|
|
192
|
+
validate: (v) => true,
|
|
193
|
+
},
|
|
194
|
+
]);
|
|
195
|
+
// Filter out the sentinel none value
|
|
196
|
+
scopes = answer.scopes.filter(s => s !== "__none__");
|
|
197
|
+
}
|
|
198
|
+
// Build webhook URL(s): one per scope (or one without scope if none selected)
|
|
199
|
+
const platformUrl = process.env.JFL_PLATFORM_URL ?? "https://jfl-platform.fly.dev";
|
|
200
|
+
const baseUrl = detectedRepo
|
|
201
|
+
? `${platformUrl}/api/webhooks/linear?repo=${detectedRepo}`
|
|
202
|
+
: `${platformUrl}/api/webhooks/linear?repo=owner/repo`;
|
|
203
|
+
const defaultWebhookUrl = scopes.length > 0
|
|
204
|
+
? scopes.map(s => `${baseUrl}&scope=${s}`).join("\n")
|
|
205
|
+
: baseUrl;
|
|
206
|
+
const { webhookUrl } = await inquirer.prompt([
|
|
207
|
+
{
|
|
208
|
+
type: "input",
|
|
209
|
+
name: "webhookUrl",
|
|
210
|
+
message: "Webhook URL for real-time sync (press enter to accept or skip):",
|
|
211
|
+
default: scopes.length === 1 ? defaultWebhookUrl : defaultWebhookUrl.split("\n")[0],
|
|
212
|
+
},
|
|
213
|
+
]);
|
|
214
|
+
// Save config
|
|
215
|
+
const config = readProjectConfig(projectRoot);
|
|
216
|
+
const team = teams.find(t => t.id === teamId);
|
|
217
|
+
const project = projects.find(p => p.id === projectId);
|
|
218
|
+
config.linear = {
|
|
219
|
+
apiKey,
|
|
220
|
+
teamId,
|
|
221
|
+
teamName: team.name,
|
|
222
|
+
projectId,
|
|
223
|
+
projectName: project.name,
|
|
224
|
+
scopes: scopes.length > 0 ? scopes : undefined,
|
|
225
|
+
scope: scopes.length === 1 ? scopes[0] : undefined, // legacy compat
|
|
226
|
+
kanbanRepo: detectedRepo || undefined,
|
|
227
|
+
webhookUrl: webhookUrl || undefined,
|
|
228
|
+
syncEnabled: true,
|
|
229
|
+
linkedAt: new Date().toISOString(),
|
|
230
|
+
};
|
|
231
|
+
writeProjectConfig(projectRoot, config);
|
|
232
|
+
// Initialize map
|
|
233
|
+
const existingMap = readMap(projectRoot);
|
|
234
|
+
writeMap(projectRoot, {
|
|
235
|
+
...existingMap,
|
|
236
|
+
projectId,
|
|
237
|
+
teamId,
|
|
238
|
+
mappings: existingMap.mappings ?? {},
|
|
239
|
+
});
|
|
240
|
+
console.log();
|
|
241
|
+
console.log(chalk.green(` ✓ Linked to Linear project: ${project.name} (team: ${team.name})`));
|
|
242
|
+
if (scopes.length > 0)
|
|
243
|
+
console.log(chalk.gray(` Scopes: ${scopes.map(s => `scope:${s}`).join(", ")}`));
|
|
244
|
+
if (detectedRepo)
|
|
245
|
+
console.log(chalk.gray(` Kanban repo: ${detectedRepo}`));
|
|
246
|
+
if (scopes.length > 1) {
|
|
247
|
+
console.log(chalk.yellow(" → Register one webhook URL per scope in Linear: Settings → API → Webhooks"));
|
|
248
|
+
for (const s of scopes)
|
|
249
|
+
console.log(chalk.gray(` ${baseUrl}&scope=${s}`));
|
|
250
|
+
}
|
|
251
|
+
else if (webhookUrl) {
|
|
252
|
+
console.log(chalk.gray(` Webhook URL: ${webhookUrl}`));
|
|
253
|
+
console.log(chalk.yellow(" → Register this URL in Linear: Settings → API → Webhooks"));
|
|
254
|
+
}
|
|
255
|
+
console.log();
|
|
256
|
+
console.log(chalk.gray(" Run 'jfl linear sync' to do an initial sync"));
|
|
257
|
+
console.log();
|
|
258
|
+
}
|
|
259
|
+
// ============================================================================
|
|
260
|
+
// jfl linear unlink
|
|
261
|
+
// ============================================================================
|
|
262
|
+
export async function linearUnlink(projectRoot) {
|
|
263
|
+
const config = readProjectConfig(projectRoot);
|
|
264
|
+
if (!config.linear) {
|
|
265
|
+
console.log(chalk.gray("No Linear project linked."));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const { confirm } = await inquirer.prompt([
|
|
269
|
+
{
|
|
270
|
+
type: "confirm",
|
|
271
|
+
name: "confirm",
|
|
272
|
+
message: `Unlink Linear project "${config.linear.projectName}"?`,
|
|
273
|
+
default: false,
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
if (!confirm)
|
|
277
|
+
return;
|
|
278
|
+
delete config.linear;
|
|
279
|
+
writeProjectConfig(projectRoot, config);
|
|
280
|
+
console.log(chalk.green(" ✓ Unlinked Linear project"));
|
|
281
|
+
}
|
|
282
|
+
// ============================================================================
|
|
283
|
+
// jfl linear status
|
|
284
|
+
// ============================================================================
|
|
285
|
+
export async function linearStatus(projectRoot) {
|
|
286
|
+
const config = readProjectConfig(projectRoot);
|
|
287
|
+
if (!config.linear) {
|
|
288
|
+
console.log(chalk.gray("\n No Linear project linked. Run: jfl linear link\n"));
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const { teamName, projectName, linkedAt, webhookUrl, syncEnabled } = config.linear;
|
|
292
|
+
const map = readMap(projectRoot);
|
|
293
|
+
const mappingCount = Object.keys(map.mappings).length;
|
|
294
|
+
console.log(chalk.bold("\n Linear Sync Status\n"));
|
|
295
|
+
console.log(` Project: ${chalk.cyan(projectName)} (team: ${teamName})`);
|
|
296
|
+
console.log(` Linked: ${chalk.gray(new Date(linkedAt).toLocaleString())}`);
|
|
297
|
+
console.log(` Sync: ${syncEnabled ? chalk.green("enabled") : chalk.yellow("disabled")}`);
|
|
298
|
+
console.log(` Webhook: ${webhookUrl ? chalk.gray(webhookUrl) : chalk.yellow("not configured")}`);
|
|
299
|
+
console.log(` Mapped: ${chalk.bold(String(mappingCount))} issues`);
|
|
300
|
+
console.log();
|
|
301
|
+
if (mappingCount > 0) {
|
|
302
|
+
const repo = getGitHubRepo(projectRoot);
|
|
303
|
+
console.log(chalk.gray(" Issue Mappings:"));
|
|
304
|
+
for (const [ghNum, linearId] of Object.entries(map.mappings).slice(0, 10)) {
|
|
305
|
+
const linearUrl = `https://linear.app/issue/${linearId}`;
|
|
306
|
+
console.log(chalk.gray(` GitHub #${ghNum} ↔ ${linearId}`));
|
|
307
|
+
}
|
|
308
|
+
if (mappingCount > 10) {
|
|
309
|
+
console.log(chalk.gray(` ... and ${mappingCount - 10} more`));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
console.log();
|
|
313
|
+
}
|
|
314
|
+
// ============================================================================
|
|
315
|
+
// jfl linear sync
|
|
316
|
+
// ============================================================================
|
|
317
|
+
export async function linearSync(projectRoot, options) {
|
|
318
|
+
const linearConfig = requireLinearConfig(projectRoot);
|
|
319
|
+
const jsonMode = options.json ?? false;
|
|
320
|
+
// Resolve kanban root: service → parent GTM, otherwise this repo
|
|
321
|
+
const projectConf = readProjectConfig(projectRoot);
|
|
322
|
+
const gtmParent = linearConfig.kanbanRepo
|
|
323
|
+
? undefined
|
|
324
|
+
: projectConf.gtm_parent;
|
|
325
|
+
const kanbanRoot = gtmParent && existsSync(gtmParent) ? gtmParent : projectRoot;
|
|
326
|
+
const repo = linearConfig.kanbanRepo ?? getGitHubRepo(kanbanRoot);
|
|
327
|
+
if (!repo) {
|
|
328
|
+
console.error(chalk.red("Cannot determine GitHub repo. Set kanbanRepo in .jfl/config.json linear section."));
|
|
329
|
+
process.exit(1);
|
|
330
|
+
}
|
|
331
|
+
const scope = linearConfig.scope;
|
|
332
|
+
const direction = options.direction ?? "both";
|
|
333
|
+
const dryRun = options.dryRun ?? false;
|
|
334
|
+
const result = { direction, dryRun, repo, scope, changes: [], skipped: 0, errors: [] };
|
|
335
|
+
const log = (msg) => { if (!jsonMode)
|
|
336
|
+
console.log(msg); };
|
|
337
|
+
const logSection = (msg) => { if (!jsonMode)
|
|
338
|
+
console.log(chalk.bold(msg)); };
|
|
339
|
+
if (dryRun)
|
|
340
|
+
log(chalk.yellow(" [dry run] No changes will be made\n"));
|
|
341
|
+
const client = getLinearClient(linearConfig.apiKey);
|
|
342
|
+
if (!jsonMode)
|
|
343
|
+
ora("Fetching data...").start().stop();
|
|
344
|
+
let linearIssues;
|
|
345
|
+
let states;
|
|
346
|
+
let kanban;
|
|
347
|
+
let githubCards;
|
|
348
|
+
try {
|
|
349
|
+
;
|
|
350
|
+
[linearIssues, states] = await Promise.all([
|
|
351
|
+
getProjectIssues(client, linearConfig.projectId),
|
|
352
|
+
getTeamStates(client, linearConfig.teamId),
|
|
353
|
+
]);
|
|
354
|
+
kanban = new GitHubKanban(kanbanRoot);
|
|
355
|
+
githubCards = await kanban.getCards(scope ? { scope } : undefined);
|
|
356
|
+
}
|
|
357
|
+
catch (err) {
|
|
358
|
+
const msg = err.message ?? String(err);
|
|
359
|
+
if (jsonMode) {
|
|
360
|
+
process.stdout.write(JSON.stringify({ ...result, errors: [{ title: "fetch", error: msg }] }) + "\n");
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
console.error(chalk.red("Failed to fetch: " + msg));
|
|
364
|
+
}
|
|
365
|
+
process.exit(1);
|
|
366
|
+
}
|
|
367
|
+
const map = readMap(projectRoot);
|
|
368
|
+
const linearToGhMap = {};
|
|
369
|
+
for (const [ghNum, lid] of Object.entries(map.mappings)) {
|
|
370
|
+
linearToGhMap[lid] = parseInt(ghNum);
|
|
371
|
+
}
|
|
372
|
+
const ghByNumber = {};
|
|
373
|
+
for (const card of githubCards)
|
|
374
|
+
ghByNumber[card.number] = card;
|
|
375
|
+
// ── GitHub → Linear ──────────────────────────────────────────────────────
|
|
376
|
+
if (direction === "github" || direction === "both") {
|
|
377
|
+
logSection("\n GitHub → Linear");
|
|
378
|
+
for (const card of githubCards) {
|
|
379
|
+
const existingLinearId = map.mappings[card.number];
|
|
380
|
+
if (existingLinearId) {
|
|
381
|
+
const linearIssue = linearIssues.find(i => i.id === existingLinearId);
|
|
382
|
+
if (linearIssue) {
|
|
383
|
+
const expectedColumn = linearToTenet(linearIssue).status;
|
|
384
|
+
if (expectedColumn !== card.column) {
|
|
385
|
+
const stateId = findLinearStateId(states, card.column);
|
|
386
|
+
if (stateId && !dryRun) {
|
|
387
|
+
try {
|
|
388
|
+
await updateLinearIssue(client, existingLinearId, { stateId });
|
|
389
|
+
result.changes.push({ side: "linear", action: "updated", githubNumber: card.number, linearId: existingLinearId, title: card.title, field: "status", from: expectedColumn, to: card.column });
|
|
390
|
+
log(chalk.gray(` ↑ Linear ${linearIssue.identifier} "${card.title}" → ${card.column}`));
|
|
391
|
+
}
|
|
392
|
+
catch (err) {
|
|
393
|
+
result.errors.push({ title: card.title, error: err.message });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else {
|
|
397
|
+
result.skipped++;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
result.skipped++;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// New → create in Linear
|
|
407
|
+
const tenet = kanbanToTenet(card);
|
|
408
|
+
const input = tenetToLinearCreate(tenet, linearConfig.teamId, linearConfig.projectId, states);
|
|
409
|
+
if (dryRun) {
|
|
410
|
+
result.changes.push({ side: "linear", action: "created", githubNumber: card.number, title: card.title });
|
|
411
|
+
log(chalk.gray(` [dry] Would create in Linear: "${card.title}"`));
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
try {
|
|
415
|
+
const created_issue = await createLinearIssue(client, input);
|
|
416
|
+
if (created_issue) {
|
|
417
|
+
addMapping(projectRoot, card.number, created_issue.id);
|
|
418
|
+
result.changes.push({ side: "linear", action: "created", githubNumber: card.number, linearIdentifier: created_issue.identifier, linearId: created_issue.id, title: card.title });
|
|
419
|
+
log(chalk.green(` ✓ Linear ${created_issue.identifier} ← GitHub #${card.number} "${card.title}"`));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
result.errors.push({ title: card.title, error: err.message });
|
|
424
|
+
log(chalk.red(` ✗ "${card.title}": ${err.message}`));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
// ── Linear → GitHub ──────────────────────────────────────────────────────
|
|
429
|
+
if (direction === "linear" || direction === "both") {
|
|
430
|
+
logSection("\n Linear → GitHub");
|
|
431
|
+
for (const issue of linearIssues) {
|
|
432
|
+
const existingGhNum = linearToGhMap[issue.id];
|
|
433
|
+
if (existingGhNum) {
|
|
434
|
+
const ghCard = ghByNumber[existingGhNum];
|
|
435
|
+
if (ghCard) {
|
|
436
|
+
const tenet = linearToTenet(issue);
|
|
437
|
+
if (tenet.status !== ghCard.column) {
|
|
438
|
+
const newLabel = getColumnLabel(tenet.status);
|
|
439
|
+
const allColLabels = ["jfl/backlog", "jfl/in-progress", "jfl/eval", "jfl/done"];
|
|
440
|
+
const removeArgs = allColLabels.filter(l => l !== newLabel).map(l => `--remove-label "${l}"`).join(" ");
|
|
441
|
+
if (!dryRun) {
|
|
442
|
+
try {
|
|
443
|
+
gh(`issue edit ${existingGhNum} --repo ${repo} ${removeArgs} --add-label "${newLabel}"`, kanbanRoot);
|
|
444
|
+
if (tenet.status === "done") {
|
|
445
|
+
try {
|
|
446
|
+
gh(`issue edit ${existingGhNum} --repo ${repo} --state closed`, kanbanRoot);
|
|
447
|
+
}
|
|
448
|
+
catch { }
|
|
449
|
+
}
|
|
450
|
+
result.changes.push({ side: "github", action: "updated", githubNumber: existingGhNum, linearIdentifier: issue.identifier, title: issue.title, field: "status", from: ghCard.column, to: tenet.status });
|
|
451
|
+
log(chalk.gray(` ↓ GitHub #${existingGhNum} "${issue.title}" → ${tenet.status}`));
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
result.errors.push({ title: issue.title, error: err.message });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
result.changes.push({ side: "github", action: "updated", githubNumber: existingGhNum, title: issue.title, field: "status", from: ghCard.column, to: tenet.status });
|
|
459
|
+
log(chalk.gray(` [dry] GitHub #${existingGhNum} → ${tenet.status}`));
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
result.skipped++;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
// New → create GitHub issue
|
|
469
|
+
const tenet = linearToTenet(issue);
|
|
470
|
+
const labels = tenetToGitHubLabels(tenet, scope);
|
|
471
|
+
const body = tenetToGitHubBody(tenet);
|
|
472
|
+
if (dryRun) {
|
|
473
|
+
result.changes.push({ side: "github", action: "created", linearIdentifier: issue.identifier, linearId: issue.id, title: issue.title });
|
|
474
|
+
log(chalk.gray(` [dry] Would create GitHub issue: "${issue.title}"`));
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const labelArgs = labels.map(l => `--label "${l}"`).join(" ");
|
|
479
|
+
const { join: pathJoin } = await import("path");
|
|
480
|
+
const { writeFileSync: wf, unlinkSync: ul } = await import("fs");
|
|
481
|
+
const tmpFile = pathJoin(kanbanRoot, ".jfl", `.linear-sync-${Date.now()}.tmp`);
|
|
482
|
+
wf(tmpFile, body);
|
|
483
|
+
let ghNum = 0;
|
|
484
|
+
try {
|
|
485
|
+
const ghResult = gh(`issue create --repo ${repo} --title "${issue.title.replace(/"/g, '\\"')}" --body-file "${tmpFile}" ${labelArgs}`, kanbanRoot);
|
|
486
|
+
const numMatch = ghResult.match(/\/issues\/(\d+)/);
|
|
487
|
+
ghNum = numMatch ? parseInt(numMatch[1]) : 0;
|
|
488
|
+
}
|
|
489
|
+
finally {
|
|
490
|
+
try {
|
|
491
|
+
ul(tmpFile);
|
|
492
|
+
}
|
|
493
|
+
catch { }
|
|
494
|
+
}
|
|
495
|
+
if (ghNum > 0) {
|
|
496
|
+
addMapping(projectRoot, ghNum, issue.id);
|
|
497
|
+
result.changes.push({ side: "github", action: "created", githubNumber: ghNum, linearIdentifier: issue.identifier, linearId: issue.id, title: issue.title });
|
|
498
|
+
log(chalk.green(` ✓ GitHub #${ghNum} ← Linear ${issue.identifier} "${issue.title}"`));
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
result.errors.push({ title: issue.title, error: err.message });
|
|
503
|
+
log(chalk.red(` ✗ "${issue.title}": ${err.message}`));
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Output
|
|
508
|
+
if (jsonMode) {
|
|
509
|
+
process.stdout.write(JSON.stringify(result) + "\n");
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
const created = result.changes.filter(c => c.action === "created").length;
|
|
513
|
+
const updated = result.changes.filter(c => c.action === "updated").length;
|
|
514
|
+
console.log();
|
|
515
|
+
console.log(chalk.bold(" Sync complete:"), chalk.green(`${created} created`), chalk.yellow(`${updated} updated`), chalk.gray(`${result.skipped} skipped`), result.errors.length > 0 ? chalk.red(`${result.errors.length} errors`) : "");
|
|
516
|
+
console.log();
|
|
517
|
+
}
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
// ============================================================================
|
|
521
|
+
// jfl linear bootstrap — deploy GitHub Actions workflow + set secrets
|
|
522
|
+
// ============================================================================
|
|
523
|
+
const WORKFLOW_TEMPLATE = `name: Linear Sync
|
|
524
|
+
|
|
525
|
+
on:
|
|
526
|
+
issues:
|
|
527
|
+
types: [opened, edited, closed, labeled, unlabeled]
|
|
528
|
+
schedule:
|
|
529
|
+
- cron: '*/30 * * * *'
|
|
530
|
+
workflow_dispatch:
|
|
531
|
+
inputs:
|
|
532
|
+
direction:
|
|
533
|
+
description: 'Sync direction: github, linear, or both'
|
|
534
|
+
default: 'both'
|
|
535
|
+
type: choice
|
|
536
|
+
options: [both, github, linear]
|
|
537
|
+
|
|
538
|
+
jobs:
|
|
539
|
+
sync:
|
|
540
|
+
name: Sync Linear ↔ GitHub Issues
|
|
541
|
+
runs-on: ubuntu-latest
|
|
542
|
+
permissions:
|
|
543
|
+
issues: write
|
|
544
|
+
contents: write
|
|
545
|
+
|
|
546
|
+
steps:
|
|
547
|
+
- uses: actions/checkout@v4
|
|
548
|
+
|
|
549
|
+
- uses: actions/setup-node@v4
|
|
550
|
+
with:
|
|
551
|
+
node-version: '20'
|
|
552
|
+
|
|
553
|
+
- name: Install jfl
|
|
554
|
+
run: npm install -g jfl@latest
|
|
555
|
+
|
|
556
|
+
- name: Bootstrap kanban labels
|
|
557
|
+
run: jfl kanban bootstrap --repo \${{ github.repository }}
|
|
558
|
+
env:
|
|
559
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
560
|
+
continue-on-error: true
|
|
561
|
+
|
|
562
|
+
- name: Sync
|
|
563
|
+
id: sync
|
|
564
|
+
run: |
|
|
565
|
+
# issues event → push that change to Linear immediately (github direction)
|
|
566
|
+
# schedule/dispatch → full bidirectional
|
|
567
|
+
if [ "\${{ github.event_name }}" = "issues" ]; then
|
|
568
|
+
DIRECTION="github"
|
|
569
|
+
else
|
|
570
|
+
DIRECTION="\${{ inputs.direction || 'both' }}"
|
|
571
|
+
fi
|
|
572
|
+
|
|
573
|
+
jfl linear sync --direction \$DIRECTION --json | tee /tmp/sync-result.json
|
|
574
|
+
echo "result=$(cat /tmp/sync-result.json)" >> \$GITHUB_OUTPUT
|
|
575
|
+
env:
|
|
576
|
+
LINEAR_API_KEY: \${{ secrets.LINEAR_API_KEY }}
|
|
577
|
+
LINEAR_TEAM_ID: \${{ vars.LINEAR_TEAM_ID }}
|
|
578
|
+
LINEAR_PROJECT_ID: \${{ vars.LINEAR_PROJECT_ID }}
|
|
579
|
+
LINEAR_SCOPE: \${{ vars.LINEAR_SCOPE }}
|
|
580
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
581
|
+
|
|
582
|
+
- name: Summary
|
|
583
|
+
if: always()
|
|
584
|
+
run: |
|
|
585
|
+
cat /tmp/sync-result.json 2>/dev/null | node -e "
|
|
586
|
+
const r = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));
|
|
587
|
+
const created = r.changes.filter(c=>c.action==='created').length;
|
|
588
|
+
const updated = r.changes.filter(c=>c.action==='updated').length;
|
|
589
|
+
console.log('## Linear Sync');
|
|
590
|
+
console.log(\`- **Direction:** \${r.direction}\`);
|
|
591
|
+
console.log(\`- **Created:** \${created}\`);
|
|
592
|
+
console.log(\`- **Updated:** \${updated}\`);
|
|
593
|
+
console.log(\`- **Skipped:** \${r.skipped}\`);
|
|
594
|
+
if(r.errors.length) console.log(\`- **Errors:** \${r.errors.length}\`);
|
|
595
|
+
if(r.changes.length) {
|
|
596
|
+
console.log('');
|
|
597
|
+
console.log('### Changes');
|
|
598
|
+
r.changes.forEach(c => {
|
|
599
|
+
const loc = c.githubNumber ? \`GitHub #\${c.githubNumber}\` : \`Linear \${c.linearIdentifier}\`;
|
|
600
|
+
const dest = c.side === 'github' ? 'GitHub' : 'Linear';
|
|
601
|
+
console.log(\`- [\${c.action}] \${dest}: \${c.title} (\${loc})\`);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
" >> \$GITHUB_STEP_SUMMARY || true
|
|
605
|
+
`;
|
|
606
|
+
export async function linearBootstrap(projectRoot) {
|
|
607
|
+
const linearConfig = requireLinearConfig(projectRoot);
|
|
608
|
+
const projectConf = readProjectConfig(projectRoot);
|
|
609
|
+
const gtmParent = linearConfig.kanbanRepo ? undefined : projectConf.gtm_parent;
|
|
610
|
+
const kanbanRoot = gtmParent && existsSync(gtmParent) ? gtmParent : projectRoot;
|
|
611
|
+
const repo = linearConfig.kanbanRepo ?? getGitHubRepo(kanbanRoot);
|
|
612
|
+
if (!repo) {
|
|
613
|
+
console.error(chalk.red("Cannot determine GitHub repo. Run from inside the project or set kanbanRepo."));
|
|
614
|
+
process.exit(1);
|
|
615
|
+
}
|
|
616
|
+
console.log(chalk.bold("\n Bootstrap Linear Sync — GitHub Actions\n"));
|
|
617
|
+
console.log(chalk.gray(` Repo: ${repo}`));
|
|
618
|
+
console.log(chalk.gray(` Project: ${linearConfig.projectName ?? linearConfig.projectId}`));
|
|
619
|
+
if (linearConfig.scope)
|
|
620
|
+
console.log(chalk.gray(` Scope: ${linearConfig.scope}`));
|
|
621
|
+
console.log();
|
|
622
|
+
// Write workflow file
|
|
623
|
+
const workflowDir = join(kanbanRoot, ".github", "workflows");
|
|
624
|
+
const workflowPath = join(workflowDir, "linear-sync.yml");
|
|
625
|
+
if (!existsSync(workflowDir)) {
|
|
626
|
+
const { mkdirSync } = await import("fs");
|
|
627
|
+
mkdirSync(workflowDir, { recursive: true });
|
|
628
|
+
}
|
|
629
|
+
if (existsSync(workflowPath)) {
|
|
630
|
+
const { overwrite } = await inquirer.prompt([{
|
|
631
|
+
type: "confirm", name: "overwrite",
|
|
632
|
+
message: "linear-sync.yml already exists — overwrite?",
|
|
633
|
+
default: false,
|
|
634
|
+
}]);
|
|
635
|
+
if (!overwrite) {
|
|
636
|
+
console.log(chalk.gray(" Skipped workflow file."));
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
|
|
640
|
+
console.log(chalk.green(` ✓ Written: .github/workflows/linear-sync.yml`));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
writeFileSync(workflowPath, WORKFLOW_TEMPLATE);
|
|
645
|
+
console.log(chalk.green(` ✓ Written: .github/workflows/linear-sync.yml`));
|
|
646
|
+
}
|
|
647
|
+
// Set GitHub secrets + variables
|
|
648
|
+
console.log();
|
|
649
|
+
console.log(chalk.gray(" Setting GitHub secrets and variables..."));
|
|
650
|
+
const spinner = ora("Setting LINEAR_API_KEY secret...").start();
|
|
651
|
+
try {
|
|
652
|
+
gh(`secret set LINEAR_API_KEY --body "${linearConfig.apiKey}" --repo ${repo}`, kanbanRoot);
|
|
653
|
+
spinner.succeed(chalk.green(" ✓ SECRET: LINEAR_API_KEY"));
|
|
654
|
+
}
|
|
655
|
+
catch (err) {
|
|
656
|
+
spinner.fail(chalk.yellow(` ✗ SECRET: LINEAR_API_KEY (${err.message}) — set manually: gh secret set LINEAR_API_KEY --repo ${repo}`));
|
|
657
|
+
}
|
|
658
|
+
// Variables (non-sensitive)
|
|
659
|
+
for (const [name, value] of [
|
|
660
|
+
["LINEAR_TEAM_ID", linearConfig.teamId],
|
|
661
|
+
["LINEAR_PROJECT_ID", linearConfig.projectId],
|
|
662
|
+
...(linearConfig.scope ? [["LINEAR_SCOPE", linearConfig.scope]] : []),
|
|
663
|
+
]) {
|
|
664
|
+
try {
|
|
665
|
+
gh(`variable set ${name} --body "${value}" --repo ${repo}`, kanbanRoot);
|
|
666
|
+
console.log(chalk.green(` ✓ VAR: ${name}=${value}`));
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
console.log(chalk.yellow(` ✗ VAR: ${name} — set manually: gh variable set ${name} --body "${value}" --repo ${repo}`));
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
// Commit the workflow file
|
|
673
|
+
console.log();
|
|
674
|
+
const { commit } = await inquirer.prompt([{
|
|
675
|
+
type: "confirm", name: "commit",
|
|
676
|
+
message: "Commit and push workflow file now?",
|
|
677
|
+
default: true,
|
|
678
|
+
}]);
|
|
679
|
+
if (commit) {
|
|
680
|
+
try {
|
|
681
|
+
const { execSync: exec } = await import("child_process");
|
|
682
|
+
exec(`git add .github/workflows/linear-sync.yml && git commit -m "ci: add Linear sync workflow" && git push`, {
|
|
683
|
+
cwd: kanbanRoot, encoding: "utf-8", stdio: "inherit",
|
|
684
|
+
});
|
|
685
|
+
console.log(chalk.green(" ✓ Pushed"));
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
console.log(chalk.yellow(" Could not push automatically — commit manually."));
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
// Trigger initial run
|
|
692
|
+
console.log();
|
|
693
|
+
const { trigger } = await inquirer.prompt([{
|
|
694
|
+
type: "confirm", name: "trigger",
|
|
695
|
+
message: "Trigger an initial sync run now?",
|
|
696
|
+
default: true,
|
|
697
|
+
}]);
|
|
698
|
+
if (trigger) {
|
|
699
|
+
try {
|
|
700
|
+
gh(`workflow run linear-sync.yml --repo ${repo} --field direction=both`, kanbanRoot);
|
|
701
|
+
console.log(chalk.green(` ✓ Triggered — watch at: https://github.com/${repo}/actions`));
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
console.log(chalk.gray(` Run manually: gh workflow run linear-sync.yml --repo ${repo}`));
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
console.log();
|
|
708
|
+
console.log(chalk.bold(" Done! How sync works:"));
|
|
709
|
+
console.log(chalk.gray(" • GitHub issue label changed → Linear updates within seconds (issues trigger)"));
|
|
710
|
+
console.log(chalk.gray(" • Linear issue created/moved → GitHub syncs every 30 minutes (schedule)"));
|
|
711
|
+
console.log(chalk.gray(" • Manual: jfl linear sync → runs immediately from your machine"));
|
|
712
|
+
console.log(chalk.gray(` • Actions: https://github.com/${repo}/actions`));
|
|
713
|
+
console.log();
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=linear.js.map
|