@bretwardjames/tw-bridge 0.1.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/cli.js ADDED
@@ -0,0 +1,592 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import fs2 from "fs";
5
+ import path3 from "path";
6
+ import os2 from "os";
7
+
8
+ // src/config.ts
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import os from "os";
12
+ var CONFIG_PATHS = [
13
+ path.join(os.homedir(), ".config", "tw-bridge", "config.json"),
14
+ path.join(os.homedir(), ".tw-bridge.json")
15
+ ];
16
+ function findConfigPath() {
17
+ for (const p of CONFIG_PATHS) {
18
+ if (fs.existsSync(p)) return p;
19
+ }
20
+ return null;
21
+ }
22
+ function loadConfig() {
23
+ const configPath = findConfigPath();
24
+ if (!configPath) {
25
+ return { backends: {} };
26
+ }
27
+ const raw = fs.readFileSync(configPath, "utf-8");
28
+ return JSON.parse(raw);
29
+ }
30
+ function saveConfig(config) {
31
+ const configPath = findConfigPath() ?? CONFIG_PATHS[0];
32
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
33
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
34
+ return configPath;
35
+ }
36
+
37
+ // src/adapters/ghp.ts
38
+ import { spawnSync } from "child_process";
39
+ import path2 from "path";
40
+ function statusToTag(status) {
41
+ if (!status) return null;
42
+ return status.toLowerCase().replace(/\s+/g, "_");
43
+ }
44
+ function mapPriority(fields) {
45
+ const priority = fields["Priority"]?.toLowerCase();
46
+ if (!priority) return void 0;
47
+ if (priority.startsWith("high") || priority === "urgent" || priority === "p0" || priority === "p1") return "H";
48
+ if (priority.startsWith("med") || priority === "p2") return "M";
49
+ if (priority.startsWith("low") || priority === "p3" || priority === "p4") return "L";
50
+ return void 0;
51
+ }
52
+ function extractErrorMessage(raw) {
53
+ const lines = raw.split("\n");
54
+ const meaningful = lines.find(
55
+ (l) => /^(fatal|error|Error):/i.test(l.trim())
56
+ );
57
+ if (meaningful) return meaningful.trim();
58
+ const first = lines.find(
59
+ (l) => l.trim() && !l.trim().startsWith("at ") && !l.includes("node:internal")
60
+ );
61
+ return first?.trim() ?? raw.split("\n")[0].trim();
62
+ }
63
+ var GhpAdapter = class {
64
+ name = "ghp";
65
+ config;
66
+ defaultConfig(cwd) {
67
+ return {
68
+ cwd,
69
+ project: path2.basename(cwd)
70
+ };
71
+ }
72
+ async init(config) {
73
+ this.config = config;
74
+ if (!this.config.cwd) {
75
+ throw new Error('ghp adapter requires "cwd" in config (path to git repo)');
76
+ }
77
+ }
78
+ async pull() {
79
+ const result = spawnSync("ghp", ["work", "--json"], {
80
+ cwd: this.config.cwd,
81
+ stdio: ["pipe", "pipe", "pipe"],
82
+ encoding: "utf-8"
83
+ });
84
+ if (result.status !== 0 || !result.stdout?.trim()) {
85
+ const raw = result.stderr?.trim() || result.stdout?.trim() || "unknown error";
86
+ console.error(` Warning: ${extractErrorMessage(raw)}`);
87
+ return [];
88
+ }
89
+ let items;
90
+ try {
91
+ items = JSON.parse(result.stdout);
92
+ } catch {
93
+ console.error(` Warning: ${extractErrorMessage(result.stdout.trim())}`);
94
+ return [];
95
+ }
96
+ const tasks = [];
97
+ for (const item of items) {
98
+ const statusTag = statusToTag(item.status);
99
+ const tags = [];
100
+ if (statusTag) tags.push(statusTag);
101
+ for (const label of item.labels) {
102
+ tags.push(label.name.toLowerCase().replace(/\s+/g, "_"));
103
+ }
104
+ const project = this.config.project ?? item.repository?.split("/")[1] ?? void 0;
105
+ const task = {
106
+ uuid: "",
107
+ description: item.title,
108
+ status: "pending",
109
+ entry: (/* @__PURE__ */ new Date()).toISOString(),
110
+ project,
111
+ tags,
112
+ priority: mapPriority(item.fields),
113
+ // backend and backend_id are set by the sync layer
114
+ backend: "",
115
+ backend_id: String(item.number),
116
+ annotations: item.url ? [{ entry: (/* @__PURE__ */ new Date()).toISOString(), description: item.url }] : void 0
117
+ };
118
+ tasks.push(task);
119
+ }
120
+ return tasks;
121
+ }
122
+ async onStart(task, ttyFd) {
123
+ const issueNumber = task.backend_id;
124
+ if (!issueNumber) {
125
+ process.stderr.write("tw-bridge [ghp]: task has no backend_id, skipping ghp start\n");
126
+ return;
127
+ }
128
+ process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}
129
+ `);
130
+ const result = spawnSync("ghp", ["start", issueNumber], {
131
+ cwd: this.config.cwd,
132
+ stdio: [ttyFd, ttyFd, ttyFd]
133
+ });
134
+ if (result.status !== 0) {
135
+ throw new Error(`ghp start exited with code ${result.status}`);
136
+ }
137
+ }
138
+ async onDone(task) {
139
+ const issueNumber = task.backend_id;
140
+ if (!issueNumber) return;
141
+ process.stderr.write(`tw-bridge [ghp]: completing issue #${issueNumber}
142
+ `);
143
+ const result = spawnSync("ghp", ["done", issueNumber], {
144
+ cwd: this.config.cwd,
145
+ stdio: "pipe"
146
+ });
147
+ if (result.status !== 0) {
148
+ throw new Error(`ghp done exited with code ${result.status}`);
149
+ }
150
+ }
151
+ };
152
+
153
+ // src/registry.ts
154
+ var BUILTIN_ADAPTERS = {
155
+ ghp: () => new GhpAdapter()
156
+ };
157
+ function matchBackend(task, config) {
158
+ if (task.backend && config.backends[task.backend]) {
159
+ return { name: task.backend, backend: config.backends[task.backend] };
160
+ }
161
+ for (const [name, backend] of Object.entries(config.backends)) {
162
+ const { match } = backend;
163
+ if (match.tags?.length && task.tags?.some((t) => match.tags.includes(t))) {
164
+ return { name, backend };
165
+ }
166
+ if (match.project && task.project === match.project) {
167
+ return { name, backend };
168
+ }
169
+ }
170
+ return null;
171
+ }
172
+ function createAdapter(adapterType) {
173
+ const factory = BUILTIN_ADAPTERS[adapterType];
174
+ if (!factory) return null;
175
+ return factory();
176
+ }
177
+ function listAdapterTypes() {
178
+ return Object.keys(BUILTIN_ADAPTERS);
179
+ }
180
+ async function resolveAdapter(task, config) {
181
+ const matched = matchBackend(task, config);
182
+ if (!matched) return null;
183
+ const factory = BUILTIN_ADAPTERS[matched.backend.adapter];
184
+ if (!factory) {
185
+ process.stderr.write(`tw-bridge: unknown adapter "${matched.backend.adapter}"
186
+ `);
187
+ return null;
188
+ }
189
+ const adapter = factory();
190
+ await adapter.init(matched.backend.config ?? {});
191
+ return adapter;
192
+ }
193
+
194
+ // src/taskwarrior.ts
195
+ import { spawnSync as spawnSync2 } from "child_process";
196
+ function getExistingTasks(backend) {
197
+ const result = spawnSync2(
198
+ "task",
199
+ ["backend:" + backend, "status:pending", "export"],
200
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
201
+ );
202
+ if (result.status !== 0) {
203
+ return /* @__PURE__ */ new Map();
204
+ }
205
+ const tasks = JSON.parse(result.stdout || "[]");
206
+ const map = /* @__PURE__ */ new Map();
207
+ for (const task of tasks) {
208
+ if (task.backend_id) {
209
+ map.set(task.backend_id, task);
210
+ }
211
+ }
212
+ return map;
213
+ }
214
+ function importTask(task) {
215
+ const { uuid: _, ...taskData } = task;
216
+ const result = spawnSync2("task", ["import"], {
217
+ input: JSON.stringify(taskData),
218
+ encoding: "utf-8",
219
+ stdio: ["pipe", "pipe", "pipe"]
220
+ });
221
+ if (result.status !== 0) {
222
+ throw new Error(`task import failed: ${result.stderr?.trim()}`);
223
+ }
224
+ const match = result.stdout?.match(/([0-9a-f-]{36})/);
225
+ return { uuid: match?.[1] ?? "unknown" };
226
+ }
227
+ function updateTaskTags(existing, newTags) {
228
+ const existingTags = new Set(existing.tags ?? []);
229
+ const targetTags = new Set(newTags);
230
+ if (existingTags.size === targetTags.size && [...existingTags].every((t) => targetTags.has(t))) {
231
+ return false;
232
+ }
233
+ const args = ["task", existing.uuid, "modify"];
234
+ for (const tag of existingTags) {
235
+ if (!targetTags.has(tag)) args.push(`-${tag}`);
236
+ }
237
+ for (const tag of targetTags) {
238
+ if (!existingTags.has(tag)) args.push(`+${tag}`);
239
+ }
240
+ const result = spawnSync2(args[0], args.slice(1), {
241
+ encoding: "utf-8",
242
+ stdio: ["pipe", "pipe", "pipe"]
243
+ });
244
+ return result.status === 0;
245
+ }
246
+ function ensureContext(name, tags) {
247
+ const check = spawnSync2("task", ["context", "show"], {
248
+ encoding: "utf-8",
249
+ stdio: ["pipe", "pipe", "pipe"]
250
+ });
251
+ const existing = check.stdout ?? "";
252
+ const filter = tags.map((t) => `+${t}`).join(" or ");
253
+ const result = spawnSync2(
254
+ "task",
255
+ ["rc.confirmation=off", "context", "define", name, filter],
256
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
257
+ );
258
+ return result.status === 0;
259
+ }
260
+ function completeTask(existing, keepTags) {
261
+ const keep = new Set(keepTags);
262
+ const removals = (existing.tags ?? []).filter((t) => !keep.has(t)).map((t) => `-${t}`);
263
+ const args = ["rc.confirmation=off", existing.uuid, "modify", ...removals];
264
+ if (removals.length > 0) {
265
+ spawnSync2("task", args, {
266
+ encoding: "utf-8",
267
+ stdio: ["pipe", "pipe", "pipe"]
268
+ });
269
+ }
270
+ const result = spawnSync2(
271
+ "task",
272
+ ["rc.confirmation=off", existing.uuid, "done"],
273
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
274
+ );
275
+ return result.status === 0;
276
+ }
277
+ function updateTaskDescription(existing, newDescription) {
278
+ if (existing.description === newDescription) return false;
279
+ const result = spawnSync2(
280
+ "task",
281
+ [existing.uuid, "modify", `description:${newDescription}`],
282
+ { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }
283
+ );
284
+ return result.status === 0;
285
+ }
286
+
287
+ // src/cli.ts
288
+ var HOOKS_DIR = path3.join(os2.homedir(), ".task", "hooks");
289
+ var commands = {
290
+ add: addBackend,
291
+ install,
292
+ sync,
293
+ which,
294
+ config: showConfig
295
+ };
296
+ async function main() {
297
+ const command = process.argv[2];
298
+ if (!command || command === "--help") {
299
+ console.log("Usage: tw-bridge <command>\n");
300
+ console.log("Commands:");
301
+ console.log(" add Add a new backend instance");
302
+ console.log(" install Install Taskwarrior hooks and shell integration");
303
+ console.log(" sync Pull tasks from all backends");
304
+ console.log(" which Print the context for the current directory");
305
+ console.log(" config Show current configuration");
306
+ return;
307
+ }
308
+ const handler = commands[command];
309
+ if (!handler) {
310
+ console.error(`Unknown command: ${command}`);
311
+ process.exit(1);
312
+ }
313
+ await handler();
314
+ }
315
+ function parseFlag(flag) {
316
+ const idx = process.argv.indexOf(flag);
317
+ return idx !== -1 ? process.argv[idx + 1] : void 0;
318
+ }
319
+ async function addBackend() {
320
+ const name = process.argv[3];
321
+ const adapterType = parseFlag("--adapter");
322
+ const tagOverride = parseFlag("--tag");
323
+ if (!name || name.startsWith("--")) {
324
+ console.error("Usage: tw-bridge add <name> --adapter <type> [--tag <context-tag>]");
325
+ console.error(`
326
+ Available adapters: ${listAdapterTypes().join(", ")}`);
327
+ process.exit(1);
328
+ }
329
+ if (!adapterType) {
330
+ console.error("Missing --adapter flag.");
331
+ console.error(`Available adapters: ${listAdapterTypes().join(", ")}`);
332
+ process.exit(1);
333
+ }
334
+ const adapter = createAdapter(adapterType);
335
+ if (!adapter) {
336
+ console.error(`Unknown adapter: ${adapterType}`);
337
+ console.error(`Available adapters: ${listAdapterTypes().join(", ")}`);
338
+ process.exit(1);
339
+ }
340
+ const config = loadConfig();
341
+ if (config.backends[name]) {
342
+ console.error(`Backend "${name}" already exists. Remove it first or choose a different name.`);
343
+ process.exit(1);
344
+ }
345
+ const matchTag = tagOverride ?? name;
346
+ const cwd = process.cwd();
347
+ const adapterConfig = adapter.defaultConfig(cwd);
348
+ config.backends[name] = {
349
+ adapter: adapterType,
350
+ match: { tags: [matchTag] },
351
+ config: adapterConfig
352
+ };
353
+ const configPath = saveConfig(config);
354
+ console.log(`Added backend "${name}" (adapter: ${adapterType})`);
355
+ console.log(`Config: ${configPath}`);
356
+ console.log(`Match tag: +${matchTag}`);
357
+ if (adapterConfig.cwd) {
358
+ console.log(`Working directory: ${adapterConfig.cwd}`);
359
+ }
360
+ if (adapterConfig.project) {
361
+ console.log(`Project: ${adapterConfig.project}`);
362
+ }
363
+ console.log("");
364
+ await syncBackend(name, config.backends[name], config);
365
+ console.log(`
366
+ Use 'task context ${matchTag}' to switch to this project.`);
367
+ }
368
+ async function install() {
369
+ fs2.mkdirSync(HOOKS_DIR, { recursive: true });
370
+ const hookSource = path3.resolve(
371
+ path3.dirname(new URL(import.meta.url).pathname),
372
+ "hooks",
373
+ "on-modify.js"
374
+ );
375
+ const hookTarget = path3.join(HOOKS_DIR, "on-modify.tw-bridge");
376
+ if (fs2.existsSync(hookTarget)) {
377
+ fs2.unlinkSync(hookTarget);
378
+ }
379
+ fs2.symlinkSync(hookSource, hookTarget);
380
+ fs2.chmodSync(hookSource, 493);
381
+ console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
382
+ console.log("\nAdd these to your .taskrc:\n");
383
+ console.log("# --- tw-bridge UDAs ---");
384
+ console.log("uda.backend.type=string");
385
+ console.log("uda.backend.label=Backend");
386
+ console.log("uda.backend_id.type=string");
387
+ console.log("uda.backend_id.label=Backend ID");
388
+ console.log("");
389
+ console.log("# --- Urgency coefficients (adjust to taste) ---");
390
+ console.log("urgency.user.tag.backlog.coefficient=0.0");
391
+ console.log("urgency.user.tag.todo.coefficient=1.0");
392
+ console.log("urgency.user.tag.in_progress.coefficient=4.0");
393
+ console.log("urgency.user.tag.in_review.coefficient=-2.0");
394
+ console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
395
+ console.log("urgency.user.tag.in_beta.coefficient=-6.0");
396
+ installShellFunction();
397
+ }
398
+ var SHELL_FUNCTION = `
399
+ # tw-bridge: auto-context task wrapper
400
+ task() {
401
+ local ctx
402
+ ctx=$(tw-bridge which 2>/dev/null)
403
+ if [ -n "$ctx" ]; then
404
+ command task "rc.context=$ctx" "$@"
405
+ else
406
+ command task "$@"
407
+ fi
408
+ }
409
+ `.trim();
410
+ var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
411
+ function installShellFunction() {
412
+ const shell = process.env.SHELL ?? "/bin/bash";
413
+ const home = os2.homedir();
414
+ let rcFile;
415
+ if (shell.endsWith("zsh")) {
416
+ rcFile = path3.join(home, ".zshrc");
417
+ } else {
418
+ rcFile = path3.join(home, ".bashrc");
419
+ }
420
+ const existing = fs2.existsSync(rcFile) ? fs2.readFileSync(rcFile, "utf-8") : "";
421
+ if (existing.includes(SHELL_MARKER)) {
422
+ console.log(`
423
+ Shell integration already installed in ${rcFile}`);
424
+ return;
425
+ }
426
+ fs2.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
427
+ console.log(`
428
+ Shell integration installed in ${rcFile}`);
429
+ console.log("Restart your shell or run: source " + rcFile);
430
+ }
431
+ var SEEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", ".seen-dirs");
432
+ function loadSeenDirs() {
433
+ try {
434
+ const raw = fs2.readFileSync(SEEN_FILE, "utf-8");
435
+ return new Set(raw.split("\n").filter(Boolean));
436
+ } catch {
437
+ return /* @__PURE__ */ new Set();
438
+ }
439
+ }
440
+ function markDirSeen(dir) {
441
+ const seen = loadSeenDirs();
442
+ if (seen.has(dir)) return;
443
+ seen.add(dir);
444
+ fs2.mkdirSync(path3.dirname(SEEN_FILE), { recursive: true });
445
+ fs2.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
446
+ }
447
+ function isGitRepo(dir) {
448
+ try {
449
+ let current = dir;
450
+ while (current !== path3.dirname(current)) {
451
+ if (fs2.existsSync(path3.join(current, ".git"))) return true;
452
+ current = path3.dirname(current);
453
+ }
454
+ return false;
455
+ } catch {
456
+ return false;
457
+ }
458
+ }
459
+ async function which() {
460
+ const cwd = process.cwd();
461
+ const config = loadConfig();
462
+ for (const [_name, backend] of Object.entries(config.backends)) {
463
+ const backendCwd = backend.config?.cwd;
464
+ if (!backendCwd) continue;
465
+ const resolved = path3.resolve(backendCwd);
466
+ if (cwd === resolved || cwd.startsWith(resolved + path3.sep)) {
467
+ const contextTag = backend.match.tags?.[0];
468
+ if (contextTag) {
469
+ process.stdout.write(contextTag);
470
+ return;
471
+ }
472
+ }
473
+ }
474
+ if (config.default_context) {
475
+ process.stdout.write(config.default_context);
476
+ return;
477
+ }
478
+ if (isGitRepo(cwd)) {
479
+ const seen = loadSeenDirs();
480
+ let gitRoot = cwd;
481
+ let current = cwd;
482
+ while (current !== path3.dirname(current)) {
483
+ if (fs2.existsSync(path3.join(current, ".git"))) {
484
+ gitRoot = current;
485
+ break;
486
+ }
487
+ current = path3.dirname(current);
488
+ }
489
+ if (!seen.has(gitRoot)) {
490
+ const dirName = path3.basename(gitRoot);
491
+ process.stderr.write(
492
+ `tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
493
+ `
494
+ );
495
+ markDirSeen(gitRoot);
496
+ }
497
+ }
498
+ }
499
+ async function sync() {
500
+ const config = loadConfig();
501
+ const backendNames = Object.keys(config.backends);
502
+ if (backendNames.length === 0) {
503
+ console.log("No backends configured. Use `tw-bridge add` to get started.");
504
+ return;
505
+ }
506
+ for (const name of backendNames) {
507
+ await syncBackend(name, config.backends[name], config);
508
+ }
509
+ }
510
+ async function syncBackend(name, backend, config) {
511
+ const adapter = await resolveAdapter(
512
+ { backend: name },
513
+ config
514
+ );
515
+ if (!adapter) {
516
+ console.error(`Skipping ${name}: adapter "${backend.adapter}" not found`);
517
+ return;
518
+ }
519
+ console.log(`Syncing ${name}...`);
520
+ const matchTags = backend.match.tags ?? [];
521
+ if (matchTags.length > 0) {
522
+ const contextName = matchTags[0];
523
+ if (ensureContext(contextName, matchTags)) {
524
+ console.log(` Context "${contextName}" configured (${matchTags.map((t) => "+" + t).join(" ")})`);
525
+ }
526
+ }
527
+ const remoteTasks = await adapter.pull();
528
+ const existing = getExistingTasks(name);
529
+ const doneStatuses = new Set(
530
+ (backend.done_statuses ?? ["done"]).map((s) => s.toLowerCase())
531
+ );
532
+ for (const task of remoteTasks) {
533
+ task.backend = name;
534
+ task.tags = [...matchTags, ...task.tags ?? []];
535
+ }
536
+ let created = 0;
537
+ let updated = 0;
538
+ let completed = 0;
539
+ let unchanged = 0;
540
+ for (const task of remoteTasks) {
541
+ const backendId = task.backend_id;
542
+ const existingTask = existing.get(backendId);
543
+ const statusTag = task.tags?.find((t) => !matchTags.includes(t) && doneStatuses.has(t));
544
+ const isDone = !!statusTag;
545
+ if (!existingTask) {
546
+ if (isDone) {
547
+ } else {
548
+ const { uuid } = importTask(task);
549
+ console.log(` + [#${backendId}] ${task.description} (${uuid.slice(0, 8)})`);
550
+ created++;
551
+ }
552
+ } else if (isDone) {
553
+ if (completeTask(existingTask, matchTags)) {
554
+ console.log(` \u2713 [#${backendId}] ${task.description}`);
555
+ completed++;
556
+ }
557
+ } else {
558
+ let changed = false;
559
+ if (updateTaskDescription(existingTask, task.description)) {
560
+ changed = true;
561
+ }
562
+ if (updateTaskTags(existingTask, task.tags ?? [])) {
563
+ changed = true;
564
+ }
565
+ if (changed) {
566
+ console.log(` ~ [#${backendId}] ${task.description}`);
567
+ updated++;
568
+ } else {
569
+ unchanged++;
570
+ }
571
+ }
572
+ existing.delete(backendId);
573
+ }
574
+ if (existing.size > 0) {
575
+ console.log(` ${existing.size} task(s) no longer in ${name} (not modified)`);
576
+ }
577
+ console.log(` Synced: ${created} created, ${updated} updated, ${completed} completed, ${unchanged} unchanged`);
578
+ }
579
+ async function showConfig() {
580
+ const configPath = findConfigPath();
581
+ if (!configPath) {
582
+ console.log("No config file found. Searched:");
583
+ console.log(" ~/.config/tw-bridge/config.json");
584
+ console.log(" ~/.tw-bridge.json");
585
+ return;
586
+ }
587
+ console.log(`Config: ${configPath}
588
+ `);
589
+ const config = loadConfig();
590
+ console.log(JSON.stringify(config, null, 2));
591
+ }
592
+ main();
@@ -0,0 +1,210 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/hooks/on-modify.ts
4
+ import fs2 from "fs";
5
+
6
+ // src/config.ts
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import os from "os";
10
+ var CONFIG_PATHS = [
11
+ path.join(os.homedir(), ".config", "tw-bridge", "config.json"),
12
+ path.join(os.homedir(), ".tw-bridge.json")
13
+ ];
14
+ function findConfigPath() {
15
+ for (const p of CONFIG_PATHS) {
16
+ if (fs.existsSync(p)) return p;
17
+ }
18
+ return null;
19
+ }
20
+ function loadConfig() {
21
+ const configPath = findConfigPath();
22
+ if (!configPath) {
23
+ return { backends: {} };
24
+ }
25
+ const raw = fs.readFileSync(configPath, "utf-8");
26
+ return JSON.parse(raw);
27
+ }
28
+
29
+ // src/adapters/ghp.ts
30
+ import { spawnSync } from "child_process";
31
+ import path2 from "path";
32
+ function statusToTag(status) {
33
+ if (!status) return null;
34
+ return status.toLowerCase().replace(/\s+/g, "_");
35
+ }
36
+ function mapPriority(fields) {
37
+ const priority = fields["Priority"]?.toLowerCase();
38
+ if (!priority) return void 0;
39
+ if (priority.startsWith("high") || priority === "urgent" || priority === "p0" || priority === "p1") return "H";
40
+ if (priority.startsWith("med") || priority === "p2") return "M";
41
+ if (priority.startsWith("low") || priority === "p3" || priority === "p4") return "L";
42
+ return void 0;
43
+ }
44
+ function extractErrorMessage(raw) {
45
+ const lines = raw.split("\n");
46
+ const meaningful = lines.find(
47
+ (l) => /^(fatal|error|Error):/i.test(l.trim())
48
+ );
49
+ if (meaningful) return meaningful.trim();
50
+ const first = lines.find(
51
+ (l) => l.trim() && !l.trim().startsWith("at ") && !l.includes("node:internal")
52
+ );
53
+ return first?.trim() ?? raw.split("\n")[0].trim();
54
+ }
55
+ var GhpAdapter = class {
56
+ name = "ghp";
57
+ config;
58
+ defaultConfig(cwd) {
59
+ return {
60
+ cwd,
61
+ project: path2.basename(cwd)
62
+ };
63
+ }
64
+ async init(config) {
65
+ this.config = config;
66
+ if (!this.config.cwd) {
67
+ throw new Error('ghp adapter requires "cwd" in config (path to git repo)');
68
+ }
69
+ }
70
+ async pull() {
71
+ const result = spawnSync("ghp", ["work", "--json"], {
72
+ cwd: this.config.cwd,
73
+ stdio: ["pipe", "pipe", "pipe"],
74
+ encoding: "utf-8"
75
+ });
76
+ if (result.status !== 0 || !result.stdout?.trim()) {
77
+ const raw = result.stderr?.trim() || result.stdout?.trim() || "unknown error";
78
+ console.error(` Warning: ${extractErrorMessage(raw)}`);
79
+ return [];
80
+ }
81
+ let items;
82
+ try {
83
+ items = JSON.parse(result.stdout);
84
+ } catch {
85
+ console.error(` Warning: ${extractErrorMessage(result.stdout.trim())}`);
86
+ return [];
87
+ }
88
+ const tasks = [];
89
+ for (const item of items) {
90
+ const statusTag = statusToTag(item.status);
91
+ const tags = [];
92
+ if (statusTag) tags.push(statusTag);
93
+ for (const label of item.labels) {
94
+ tags.push(label.name.toLowerCase().replace(/\s+/g, "_"));
95
+ }
96
+ const project = this.config.project ?? item.repository?.split("/")[1] ?? void 0;
97
+ const task = {
98
+ uuid: "",
99
+ description: item.title,
100
+ status: "pending",
101
+ entry: (/* @__PURE__ */ new Date()).toISOString(),
102
+ project,
103
+ tags,
104
+ priority: mapPriority(item.fields),
105
+ // backend and backend_id are set by the sync layer
106
+ backend: "",
107
+ backend_id: String(item.number),
108
+ annotations: item.url ? [{ entry: (/* @__PURE__ */ new Date()).toISOString(), description: item.url }] : void 0
109
+ };
110
+ tasks.push(task);
111
+ }
112
+ return tasks;
113
+ }
114
+ async onStart(task, ttyFd) {
115
+ const issueNumber = task.backend_id;
116
+ if (!issueNumber) {
117
+ process.stderr.write("tw-bridge [ghp]: task has no backend_id, skipping ghp start\n");
118
+ return;
119
+ }
120
+ process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}
121
+ `);
122
+ const result = spawnSync("ghp", ["start", issueNumber], {
123
+ cwd: this.config.cwd,
124
+ stdio: [ttyFd, ttyFd, ttyFd]
125
+ });
126
+ if (result.status !== 0) {
127
+ throw new Error(`ghp start exited with code ${result.status}`);
128
+ }
129
+ }
130
+ async onDone(task) {
131
+ const issueNumber = task.backend_id;
132
+ if (!issueNumber) return;
133
+ process.stderr.write(`tw-bridge [ghp]: completing issue #${issueNumber}
134
+ `);
135
+ const result = spawnSync("ghp", ["done", issueNumber], {
136
+ cwd: this.config.cwd,
137
+ stdio: "pipe"
138
+ });
139
+ if (result.status !== 0) {
140
+ throw new Error(`ghp done exited with code ${result.status}`);
141
+ }
142
+ }
143
+ };
144
+
145
+ // src/registry.ts
146
+ var BUILTIN_ADAPTERS = {
147
+ ghp: () => new GhpAdapter()
148
+ };
149
+ function matchBackend(task, config) {
150
+ if (task.backend && config.backends[task.backend]) {
151
+ return { name: task.backend, backend: config.backends[task.backend] };
152
+ }
153
+ for (const [name, backend] of Object.entries(config.backends)) {
154
+ const { match } = backend;
155
+ if (match.tags?.length && task.tags?.some((t) => match.tags.includes(t))) {
156
+ return { name, backend };
157
+ }
158
+ if (match.project && task.project === match.project) {
159
+ return { name, backend };
160
+ }
161
+ }
162
+ return null;
163
+ }
164
+ async function resolveAdapter(task, config) {
165
+ const matched = matchBackend(task, config);
166
+ if (!matched) return null;
167
+ const factory = BUILTIN_ADAPTERS[matched.backend.adapter];
168
+ if (!factory) {
169
+ process.stderr.write(`tw-bridge: unknown adapter "${matched.backend.adapter}"
170
+ `);
171
+ return null;
172
+ }
173
+ const adapter = factory();
174
+ await adapter.init(matched.backend.config ?? {});
175
+ return adapter;
176
+ }
177
+
178
+ // src/hooks/on-modify.ts
179
+ async function main() {
180
+ const input = fs2.readFileSync("/dev/stdin", "utf-8").trim().split("\n");
181
+ const oldTask = JSON.parse(input[0]);
182
+ const newTask = JSON.parse(input[1]);
183
+ process.stdout.write(JSON.stringify(newTask) + "\n");
184
+ const config = loadConfig();
185
+ const adapter = await resolveAdapter(newTask, config);
186
+ if (!adapter) return;
187
+ const wasStarted = !oldTask.start && !!newTask.start;
188
+ const wasStopped = !!oldTask.start && !newTask.start && newTask.status === "pending";
189
+ const wasCompleted = oldTask.status !== "completed" && newTask.status === "completed";
190
+ let ttyFd = null;
191
+ try {
192
+ if (wasStarted && adapter.onStart) {
193
+ ttyFd = fs2.openSync("/dev/tty", "r+");
194
+ await adapter.onStart(newTask, ttyFd);
195
+ } else if (wasStopped && adapter.onStop) {
196
+ await adapter.onStop(newTask);
197
+ } else if (wasCompleted && adapter.onDone) {
198
+ await adapter.onDone(newTask);
199
+ } else if (adapter.onModify) {
200
+ await adapter.onModify(oldTask, newTask);
201
+ }
202
+ } catch (err) {
203
+ const msg = err instanceof Error ? err.message : String(err);
204
+ process.stderr.write(`tw-bridge [${adapter.name}]: ${msg}
205
+ `);
206
+ } finally {
207
+ if (ttyFd !== null) fs2.closeSync(ttyFd);
208
+ }
209
+ }
210
+ main();
@@ -0,0 +1,14 @@
1
+ {
2
+ "backends": {
3
+ "ghp": {
4
+ "adapter": "ghp",
5
+ "match": {
6
+ "tags": ["ghp"]
7
+ },
8
+ "config": {
9
+ "cwd": "/home/bretwardjames/IdeaProjects/ghp",
10
+ "project": "ghp"
11
+ }
12
+ }
13
+ }
14
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@bretwardjames/tw-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Taskwarrior backend bridge — unified sync and hooks for multiple task management platforms",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/bretwardjames/tw-bridge.git"
10
+ },
11
+ "keywords": [
12
+ "taskwarrior",
13
+ "timewarrior",
14
+ "task-management",
15
+ "github-projects",
16
+ "sync"
17
+ ],
18
+ "bin": {
19
+ "tw-bridge": "./dist/cli.js"
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "examples"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsup",
27
+ "dev": "tsup --watch",
28
+ "prepublishOnly": "npm run build"
29
+ },
30
+ "devDependencies": {
31
+ "@types/node": "^22.0.0",
32
+ "tsup": "^8.0.0",
33
+ "typescript": "^5.7.0"
34
+ }
35
+ }