@bretwardjames/tw-bridge 0.2.0 → 0.4.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 +582 -50
- package/dist/extensions/bridge.js +219 -0
- package/dist/hooks/on-modify.js +565 -26
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import
|
|
4
|
+
import fs3 from "fs";
|
|
5
|
+
import path4 from "path";
|
|
6
|
+
import os3 from "os";
|
|
7
7
|
|
|
8
8
|
// src/config.ts
|
|
9
9
|
import fs from "fs";
|
|
@@ -30,7 +30,7 @@ function loadConfig() {
|
|
|
30
30
|
function saveConfig(config) {
|
|
31
31
|
const configPath = findConfigPath() ?? CONFIG_PATHS[0];
|
|
32
32
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
33
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
33
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { mode: 384 });
|
|
34
34
|
return configPath;
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -125,9 +125,11 @@ var GhpAdapter = class {
|
|
|
125
125
|
process.stderr.write("tw-bridge [ghp]: task has no backend_id, skipping ghp start\n");
|
|
126
126
|
return;
|
|
127
127
|
}
|
|
128
|
-
process.
|
|
128
|
+
const parallel = process.env.TW_BRIDGE_MODE === "parallel";
|
|
129
|
+
const args = parallel ? ["start", issueNumber, "--parallel"] : ["start", issueNumber];
|
|
130
|
+
process.stderr.write(`tw-bridge [ghp]: starting issue #${issueNumber}${parallel ? " (parallel)" : ""}
|
|
129
131
|
`);
|
|
130
|
-
const result = spawnSync("ghp",
|
|
132
|
+
const result = spawnSync("ghp", args, {
|
|
131
133
|
cwd: this.config.cwd,
|
|
132
134
|
stdio: [ttyFd, ttyFd, ttyFd]
|
|
133
135
|
});
|
|
@@ -150,9 +152,430 @@ var GhpAdapter = class {
|
|
|
150
152
|
}
|
|
151
153
|
};
|
|
152
154
|
|
|
155
|
+
// src/adapters/asana.ts
|
|
156
|
+
import fs2 from "fs";
|
|
157
|
+
import path3 from "path";
|
|
158
|
+
import os2 from "os";
|
|
159
|
+
import { execSync } from "child_process";
|
|
160
|
+
import { createInterface } from "readline/promises";
|
|
161
|
+
import { stdin, stdout } from "process";
|
|
162
|
+
var API_BASE = "https://app.asana.com/api/1.0";
|
|
163
|
+
var OAUTH_AUTHORIZE = "https://app.asana.com/-/oauth_authorize";
|
|
164
|
+
var OAUTH_TOKEN = "https://app.asana.com/-/oauth_token";
|
|
165
|
+
var OOB_REDIRECT = "urn:ietf:wg:oauth:2.0:oob";
|
|
166
|
+
var TOKEN_FILE = path3.join(os2.homedir(), ".config", "tw-bridge", "asana-tokens.json");
|
|
167
|
+
function loadTokens() {
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(fs2.readFileSync(TOKEN_FILE, "utf-8"));
|
|
170
|
+
} catch {
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function saveTokens(tokens) {
|
|
175
|
+
fs2.mkdirSync(path3.dirname(TOKEN_FILE), { recursive: true });
|
|
176
|
+
fs2.writeFileSync(TOKEN_FILE, JSON.stringify(tokens, null, 2), { mode: 384 });
|
|
177
|
+
}
|
|
178
|
+
async function refreshAccessToken(tokens) {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
if (tokens.access_token && tokens.expires_at > now + 3e5) {
|
|
181
|
+
return tokens.access_token;
|
|
182
|
+
}
|
|
183
|
+
const res = await fetch(OAUTH_TOKEN, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
186
|
+
body: new URLSearchParams({
|
|
187
|
+
grant_type: "refresh_token",
|
|
188
|
+
client_id: tokens.client_id,
|
|
189
|
+
client_secret: tokens.client_secret,
|
|
190
|
+
refresh_token: tokens.refresh_token
|
|
191
|
+
})
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
const body = await res.text();
|
|
195
|
+
throw new Error(`Token refresh failed (${res.status}): ${body}`);
|
|
196
|
+
}
|
|
197
|
+
const data = await res.json();
|
|
198
|
+
tokens.access_token = data.access_token;
|
|
199
|
+
tokens.expires_at = now + (data.expires_in ?? 3600) * 1e3;
|
|
200
|
+
saveTokens(tokens);
|
|
201
|
+
return tokens.access_token;
|
|
202
|
+
}
|
|
203
|
+
async function oauthLogin(clientId, clientSecret, rl) {
|
|
204
|
+
const authUrl = `${OAUTH_AUTHORIZE}?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(OOB_REDIRECT)}&response_type=code`;
|
|
205
|
+
console.log(`
|
|
206
|
+
Opening browser for authorization...`);
|
|
207
|
+
console.log(` If it doesn't open, visit:
|
|
208
|
+
${authUrl}
|
|
209
|
+
`);
|
|
210
|
+
try {
|
|
211
|
+
execSync(`xdg-open "${authUrl}" 2>/dev/null || open "${authUrl}" 2>/dev/null`, {
|
|
212
|
+
stdio: "ignore"
|
|
213
|
+
});
|
|
214
|
+
} catch {
|
|
215
|
+
}
|
|
216
|
+
const code = (await rl.question(" Paste the authorization code: ")).trim();
|
|
217
|
+
if (!code) throw new Error("No authorization code provided");
|
|
218
|
+
const tokenRes = await fetch(OAUTH_TOKEN, {
|
|
219
|
+
method: "POST",
|
|
220
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
221
|
+
body: new URLSearchParams({
|
|
222
|
+
grant_type: "authorization_code",
|
|
223
|
+
client_id: clientId,
|
|
224
|
+
client_secret: clientSecret,
|
|
225
|
+
redirect_uri: OOB_REDIRECT,
|
|
226
|
+
code
|
|
227
|
+
})
|
|
228
|
+
});
|
|
229
|
+
if (!tokenRes.ok) {
|
|
230
|
+
const body = await tokenRes.text();
|
|
231
|
+
throw new Error(`Token exchange failed (${tokenRes.status}): ${body}`);
|
|
232
|
+
}
|
|
233
|
+
const data = await tokenRes.json();
|
|
234
|
+
const tokens = {
|
|
235
|
+
access_token: data.access_token,
|
|
236
|
+
refresh_token: data.refresh_token,
|
|
237
|
+
expires_at: Date.now() + (data.expires_in ?? 3600) * 1e3,
|
|
238
|
+
client_id: clientId,
|
|
239
|
+
client_secret: clientSecret
|
|
240
|
+
};
|
|
241
|
+
saveTokens(tokens);
|
|
242
|
+
return tokens;
|
|
243
|
+
}
|
|
244
|
+
async function asanaFetch(path5, token, options = {}) {
|
|
245
|
+
const url = `${API_BASE}${path5}`;
|
|
246
|
+
const res = await fetch(url, {
|
|
247
|
+
...options,
|
|
248
|
+
headers: {
|
|
249
|
+
Authorization: `Bearer ${token}`,
|
|
250
|
+
"Content-Type": "application/json",
|
|
251
|
+
...options.headers
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
if (!res.ok) {
|
|
255
|
+
const body = await res.text();
|
|
256
|
+
throw new Error(`Asana API ${res.status}: ${body}`);
|
|
257
|
+
}
|
|
258
|
+
const json = await res.json();
|
|
259
|
+
return json.data;
|
|
260
|
+
}
|
|
261
|
+
async function asanaFetchAll(path5, token) {
|
|
262
|
+
const results = [];
|
|
263
|
+
let nextPage = path5;
|
|
264
|
+
while (nextPage) {
|
|
265
|
+
const url = `${API_BASE}${nextPage}`;
|
|
266
|
+
const res = await fetch(url, {
|
|
267
|
+
headers: { Authorization: `Bearer ${token}` }
|
|
268
|
+
});
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
const body = await res.text();
|
|
271
|
+
throw new Error(`Asana API ${res.status}: ${body}`);
|
|
272
|
+
}
|
|
273
|
+
const json = await res.json();
|
|
274
|
+
results.push(...json.data);
|
|
275
|
+
if (json.next_page?.path) {
|
|
276
|
+
nextPage = json.next_page.path;
|
|
277
|
+
} else {
|
|
278
|
+
nextPage = null;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
async function prompt(rl, question) {
|
|
284
|
+
return rl.question(question);
|
|
285
|
+
}
|
|
286
|
+
async function pickOne(rl, label, items) {
|
|
287
|
+
console.log(`
|
|
288
|
+
${label}:`);
|
|
289
|
+
for (let i = 0; i < items.length; i++) {
|
|
290
|
+
console.log(` ${i + 1}) ${items[i].name}`);
|
|
291
|
+
}
|
|
292
|
+
while (true) {
|
|
293
|
+
const answer = await prompt(rl, `Choose (1-${items.length}): `);
|
|
294
|
+
const idx = parseInt(answer, 10) - 1;
|
|
295
|
+
if (idx >= 0 && idx < items.length) return items[idx];
|
|
296
|
+
console.log(" Invalid choice, try again.");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
var AsanaAdapter = class {
|
|
300
|
+
name = "asana";
|
|
301
|
+
config;
|
|
302
|
+
accessToken = null;
|
|
303
|
+
/**
|
|
304
|
+
* Resolve the access token based on auth config.
|
|
305
|
+
* - "oauth": load from token file, refresh if needed
|
|
306
|
+
* - "env:VAR": read from environment
|
|
307
|
+
* - anything else: treat as raw PAT
|
|
308
|
+
*/
|
|
309
|
+
async resolveToken() {
|
|
310
|
+
const auth = this.config.auth;
|
|
311
|
+
if (auth === "oauth") {
|
|
312
|
+
const tokens = loadTokens();
|
|
313
|
+
if (!tokens) throw new Error("No OAuth tokens found. Run `tw-bridge add` to authenticate.");
|
|
314
|
+
this.accessToken = await refreshAccessToken(tokens);
|
|
315
|
+
return this.accessToken;
|
|
316
|
+
}
|
|
317
|
+
if (this.accessToken) return this.accessToken;
|
|
318
|
+
if (auth.startsWith("env:")) {
|
|
319
|
+
const envVar = auth.slice(4);
|
|
320
|
+
const val = process.env[envVar];
|
|
321
|
+
if (!val) throw new Error(`Environment variable ${envVar} not set`);
|
|
322
|
+
this.accessToken = val;
|
|
323
|
+
return val;
|
|
324
|
+
}
|
|
325
|
+
this.accessToken = auth;
|
|
326
|
+
return auth;
|
|
327
|
+
}
|
|
328
|
+
defaultConfig(_cwd) {
|
|
329
|
+
return {
|
|
330
|
+
auth: "env:ASANA_TOKEN",
|
|
331
|
+
workspace_gid: "",
|
|
332
|
+
project_gid: "",
|
|
333
|
+
project_name: "",
|
|
334
|
+
sections: [],
|
|
335
|
+
done_sections: []
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
async setup(_cwd) {
|
|
339
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
340
|
+
try {
|
|
341
|
+
console.log("\n\u2500\u2500 Asana Adapter Setup \u2500\u2500\n");
|
|
342
|
+
console.log("Authentication:");
|
|
343
|
+
console.log(" 1) OAuth (browser login)");
|
|
344
|
+
console.log(" 2) Personal Access Token");
|
|
345
|
+
console.log(" 3) Environment variable");
|
|
346
|
+
const authChoice = await prompt(rl, "\nChoose (1-3): ");
|
|
347
|
+
let authConfig;
|
|
348
|
+
let token;
|
|
349
|
+
if (authChoice.trim() === "1") {
|
|
350
|
+
const clientId = (await prompt(rl, "\nAsana App Client ID: ")).trim();
|
|
351
|
+
const clientSecret = (await prompt(rl, "Asana App Client Secret: ")).trim();
|
|
352
|
+
if (!clientId || !clientSecret) {
|
|
353
|
+
console.error("\n Client ID and Secret are required.");
|
|
354
|
+
console.error(" Create an app at: https://app.asana.com/0/my-apps");
|
|
355
|
+
console.error(' Check "This is a native or command-line app"');
|
|
356
|
+
rl.close();
|
|
357
|
+
process.exit(1);
|
|
358
|
+
}
|
|
359
|
+
const tokens = await oauthLogin(clientId, clientSecret, rl);
|
|
360
|
+
token = tokens.access_token;
|
|
361
|
+
authConfig = "oauth";
|
|
362
|
+
} else if (authChoice.trim() === "3") {
|
|
363
|
+
const envVar = await prompt(rl, "Environment variable name [ASANA_TOKEN]: ");
|
|
364
|
+
const varName = envVar.trim() || "ASANA_TOKEN";
|
|
365
|
+
authConfig = `env:${varName}`;
|
|
366
|
+
token = process.env[varName] ?? "";
|
|
367
|
+
if (!token) {
|
|
368
|
+
console.log(`
|
|
369
|
+
Warning: ${varName} is not currently set.`);
|
|
370
|
+
const tempToken = await prompt(rl, " Paste a temporary token for setup (or Enter to skip): ");
|
|
371
|
+
token = tempToken.trim();
|
|
372
|
+
if (!token) {
|
|
373
|
+
console.error(`
|
|
374
|
+
Cannot complete setup without a valid token.`);
|
|
375
|
+
console.error(` Set ${varName} in your environment and try again.`);
|
|
376
|
+
rl.close();
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
token = (await prompt(rl, "Paste your Personal Access Token: ")).trim();
|
|
382
|
+
authConfig = token;
|
|
383
|
+
}
|
|
384
|
+
console.log("\nVerifying...");
|
|
385
|
+
let me;
|
|
386
|
+
try {
|
|
387
|
+
me = await asanaFetch("/users/me", token);
|
|
388
|
+
} catch (err) {
|
|
389
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
390
|
+
console.error(` Authentication failed: ${msg}`);
|
|
391
|
+
rl.close();
|
|
392
|
+
process.exit(1);
|
|
393
|
+
}
|
|
394
|
+
console.log(` Authenticated as: ${me.name} (${me.email})`);
|
|
395
|
+
const workspaces = await asanaFetch("/workspaces", token);
|
|
396
|
+
let workspace;
|
|
397
|
+
if (workspaces.length === 1) {
|
|
398
|
+
workspace = workspaces[0];
|
|
399
|
+
console.log(`
|
|
400
|
+
Workspace: ${workspace.name}`);
|
|
401
|
+
} else {
|
|
402
|
+
workspace = await pickOne(rl, "Select workspace", workspaces);
|
|
403
|
+
}
|
|
404
|
+
const projects = await asanaFetchAll(
|
|
405
|
+
`/workspaces/${workspace.gid}/projects?limit=100&archived=false`,
|
|
406
|
+
token
|
|
407
|
+
);
|
|
408
|
+
if (projects.length === 0) {
|
|
409
|
+
console.error("\n No projects found in this workspace.");
|
|
410
|
+
rl.close();
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
const project = await pickOne(rl, "Select project", projects);
|
|
414
|
+
const sections = await asanaFetch(
|
|
415
|
+
`/projects/${project.gid}/sections`,
|
|
416
|
+
token
|
|
417
|
+
);
|
|
418
|
+
console.log(`
|
|
419
|
+
Sections in "${project.name}":`);
|
|
420
|
+
console.log("Map each section to a Taskwarrior tag (or press Enter for default).\n");
|
|
421
|
+
const sectionMappings = [];
|
|
422
|
+
const doneSections = [];
|
|
423
|
+
for (const section of sections) {
|
|
424
|
+
const defaultTag = section.name.toLowerCase().replace(/\s+/g, "_");
|
|
425
|
+
const tag = await prompt(rl, ` "${section.name}" \u2192 tag [${defaultTag}]: `);
|
|
426
|
+
const finalTag = tag.trim() || defaultTag;
|
|
427
|
+
sectionMappings.push({
|
|
428
|
+
gid: section.gid,
|
|
429
|
+
name: section.name,
|
|
430
|
+
tag: finalTag
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
console.log("\nWhich sections represent completed tasks?");
|
|
434
|
+
for (let i = 0; i < sectionMappings.length; i++) {
|
|
435
|
+
console.log(` ${i + 1}) ${sectionMappings[i].name} (${sectionMappings[i].tag})`);
|
|
436
|
+
}
|
|
437
|
+
const doneAnswer = await prompt(rl, "Enter numbers separated by commas (e.g., 4,5): ");
|
|
438
|
+
for (const num of doneAnswer.split(",")) {
|
|
439
|
+
const idx = parseInt(num.trim(), 10) - 1;
|
|
440
|
+
if (idx >= 0 && idx < sectionMappings.length) {
|
|
441
|
+
doneSections.push(sectionMappings[idx].gid);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
console.log("\nWhich section should tasks move to on `task start`?");
|
|
445
|
+
for (let i = 0; i < sectionMappings.length; i++) {
|
|
446
|
+
console.log(` ${i + 1}) ${sectionMappings[i].name}`);
|
|
447
|
+
}
|
|
448
|
+
console.log(` 0) Don't move (no action on start)`);
|
|
449
|
+
const startAnswer = await prompt(rl, "Choose: ");
|
|
450
|
+
const startIdx = parseInt(startAnswer.trim(), 10) - 1;
|
|
451
|
+
const startSectionGid = startIdx >= 0 && startIdx < sectionMappings.length ? sectionMappings[startIdx].gid : void 0;
|
|
452
|
+
rl.close();
|
|
453
|
+
console.log(`
|
|
454
|
+
Project: ${project.name}`);
|
|
455
|
+
console.log(` Sections: ${sectionMappings.map((s) => s.tag).join(", ")}`);
|
|
456
|
+
console.log(` Done: ${doneSections.length} section(s)`);
|
|
457
|
+
if (startSectionGid) {
|
|
458
|
+
const startSection = sectionMappings.find((s) => s.gid === startSectionGid);
|
|
459
|
+
console.log(` On start: move to "${startSection?.name}"`);
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
auth: authConfig,
|
|
463
|
+
workspace_gid: workspace.gid,
|
|
464
|
+
project_gid: project.gid,
|
|
465
|
+
project_name: project.name,
|
|
466
|
+
sections: sectionMappings,
|
|
467
|
+
done_sections: doneSections,
|
|
468
|
+
start_section_gid: startSectionGid
|
|
469
|
+
};
|
|
470
|
+
} catch (err) {
|
|
471
|
+
rl.close();
|
|
472
|
+
throw err;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async init(config) {
|
|
476
|
+
this.config = config;
|
|
477
|
+
if (!this.config.project_gid) {
|
|
478
|
+
throw new Error('asana adapter requires "project_gid" in config');
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async pull() {
|
|
482
|
+
const token = await this.resolveToken();
|
|
483
|
+
const fields = [
|
|
484
|
+
"gid",
|
|
485
|
+
"name",
|
|
486
|
+
"completed",
|
|
487
|
+
"completed_at",
|
|
488
|
+
"assignee",
|
|
489
|
+
"assignee.name",
|
|
490
|
+
"memberships.project",
|
|
491
|
+
"memberships.project.name",
|
|
492
|
+
"memberships.section",
|
|
493
|
+
"memberships.section.name",
|
|
494
|
+
"permalink_url",
|
|
495
|
+
"custom_fields",
|
|
496
|
+
"custom_fields.name",
|
|
497
|
+
"custom_fields.display_value"
|
|
498
|
+
].join(",");
|
|
499
|
+
const asanaTasks = await asanaFetchAll(
|
|
500
|
+
`/projects/${this.config.project_gid}/tasks?opt_fields=${fields}&limit=100`,
|
|
501
|
+
token
|
|
502
|
+
);
|
|
503
|
+
const tasks = [];
|
|
504
|
+
const sectionMap = new Map(
|
|
505
|
+
this.config.sections.map((s) => [s.gid, s])
|
|
506
|
+
);
|
|
507
|
+
for (const at of asanaTasks) {
|
|
508
|
+
if (at.completed) continue;
|
|
509
|
+
const membership = at.memberships?.find(
|
|
510
|
+
(m) => m.project.gid === this.config.project_gid
|
|
511
|
+
);
|
|
512
|
+
const sectionGid = membership?.section?.gid;
|
|
513
|
+
const sectionMapping = sectionGid ? sectionMap.get(sectionGid) : null;
|
|
514
|
+
const tags = [];
|
|
515
|
+
if (sectionMapping) tags.push(sectionMapping.tag);
|
|
516
|
+
const priority = mapAsanaPriority(at.custom_fields);
|
|
517
|
+
const task = {
|
|
518
|
+
uuid: "",
|
|
519
|
+
description: at.name,
|
|
520
|
+
status: "pending",
|
|
521
|
+
entry: (/* @__PURE__ */ new Date()).toISOString(),
|
|
522
|
+
project: this.config.project_name,
|
|
523
|
+
tags,
|
|
524
|
+
priority,
|
|
525
|
+
backend: "",
|
|
526
|
+
backend_id: at.gid,
|
|
527
|
+
annotations: at.permalink_url ? [{ entry: (/* @__PURE__ */ new Date()).toISOString(), description: at.permalink_url }] : void 0
|
|
528
|
+
};
|
|
529
|
+
tasks.push(task);
|
|
530
|
+
}
|
|
531
|
+
return tasks;
|
|
532
|
+
}
|
|
533
|
+
async onStart(task, _ttyFd) {
|
|
534
|
+
if (!this.config.start_section_gid) return;
|
|
535
|
+
const taskGid = task.backend_id;
|
|
536
|
+
if (!taskGid) return;
|
|
537
|
+
const token = await this.resolveToken();
|
|
538
|
+
const startSection = this.config.sections.find(
|
|
539
|
+
(s) => s.gid === this.config.start_section_gid
|
|
540
|
+
);
|
|
541
|
+
process.stderr.write(
|
|
542
|
+
`tw-bridge [asana]: moving task to "${startSection?.name ?? "In Progress"}"
|
|
543
|
+
`
|
|
544
|
+
);
|
|
545
|
+
await asanaFetch(`/sections/${this.config.start_section_gid}/addTask`, token, {
|
|
546
|
+
method: "POST",
|
|
547
|
+
body: JSON.stringify({ data: { task: taskGid } })
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
async onDone(task) {
|
|
551
|
+
const taskGid = task.backend_id;
|
|
552
|
+
if (!taskGid) return;
|
|
553
|
+
const token = await this.resolveToken();
|
|
554
|
+
process.stderr.write(`tw-bridge [asana]: marking task complete
|
|
555
|
+
`);
|
|
556
|
+
await asanaFetch(`/tasks/${taskGid}`, token, {
|
|
557
|
+
method: "PUT",
|
|
558
|
+
body: JSON.stringify({ data: { completed: true } })
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
};
|
|
562
|
+
function mapAsanaPriority(fields) {
|
|
563
|
+
if (!fields) return void 0;
|
|
564
|
+
const pField = fields.find(
|
|
565
|
+
(f) => f.name.toLowerCase() === "priority" && f.display_value
|
|
566
|
+
);
|
|
567
|
+
if (!pField?.display_value) return void 0;
|
|
568
|
+
const val = pField.display_value.toLowerCase();
|
|
569
|
+
if (val.startsWith("high") || val === "urgent" || val === "p0" || val === "p1") return "H";
|
|
570
|
+
if (val.startsWith("med") || val === "p2") return "M";
|
|
571
|
+
if (val.startsWith("low") || val === "p3" || val === "p4") return "L";
|
|
572
|
+
return void 0;
|
|
573
|
+
}
|
|
574
|
+
|
|
153
575
|
// src/registry.ts
|
|
154
576
|
var BUILTIN_ADAPTERS = {
|
|
155
|
-
ghp: () => new GhpAdapter()
|
|
577
|
+
ghp: () => new GhpAdapter(),
|
|
578
|
+
asana: () => new AsanaAdapter()
|
|
156
579
|
};
|
|
157
580
|
function matchBackend(task, config) {
|
|
158
581
|
if (task.backend && config.backends[task.backend]) {
|
|
@@ -285,11 +708,12 @@ function updateTaskDescription(existing, newDescription) {
|
|
|
285
708
|
}
|
|
286
709
|
|
|
287
710
|
// src/cli.ts
|
|
288
|
-
var HOOKS_DIR =
|
|
711
|
+
var HOOKS_DIR = path4.join(os3.homedir(), ".task", "hooks");
|
|
289
712
|
var commands = {
|
|
290
713
|
add: addBackend,
|
|
291
714
|
install,
|
|
292
715
|
sync,
|
|
716
|
+
timewarrior: timewarriorCmd,
|
|
293
717
|
which,
|
|
294
718
|
config: showConfig
|
|
295
719
|
};
|
|
@@ -300,9 +724,10 @@ async function main() {
|
|
|
300
724
|
console.log("Commands:");
|
|
301
725
|
console.log(" add Add a new backend instance");
|
|
302
726
|
console.log(" install Install Taskwarrior hooks and shell integration");
|
|
303
|
-
console.log(" sync
|
|
304
|
-
console.log("
|
|
305
|
-
console.log("
|
|
727
|
+
console.log(" sync Pull tasks from all backends");
|
|
728
|
+
console.log(" timewarrior Manage Timewarrior integration");
|
|
729
|
+
console.log(" which Print the context for the current directory");
|
|
730
|
+
console.log(" config Show current configuration");
|
|
306
731
|
return;
|
|
307
732
|
}
|
|
308
733
|
const handler = commands[command];
|
|
@@ -344,14 +769,23 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
|
|
|
344
769
|
}
|
|
345
770
|
const matchTag = tagOverride ?? name;
|
|
346
771
|
const cwd = process.cwd();
|
|
347
|
-
const adapterConfig = adapter.defaultConfig(cwd);
|
|
772
|
+
const adapterConfig = adapter.setup ? await adapter.setup(cwd) : adapter.defaultConfig(cwd);
|
|
773
|
+
let doneStatuses;
|
|
774
|
+
const sections = adapterConfig.sections;
|
|
775
|
+
const doneSectionGids = adapterConfig.done_sections;
|
|
776
|
+
if (sections?.length && doneSectionGids?.length) {
|
|
777
|
+
const gidSet = new Set(doneSectionGids);
|
|
778
|
+
doneStatuses = sections.filter((s) => gidSet.has(s.gid)).map((s) => s.tag);
|
|
779
|
+
}
|
|
348
780
|
config.backends[name] = {
|
|
349
781
|
adapter: adapterType,
|
|
350
782
|
match: { tags: [matchTag] },
|
|
783
|
+
...doneStatuses?.length && { done_statuses: doneStatuses },
|
|
351
784
|
config: adapterConfig
|
|
352
785
|
};
|
|
353
786
|
const configPath = saveConfig(config);
|
|
354
|
-
console.log(`
|
|
787
|
+
console.log(`
|
|
788
|
+
Added backend "${name}" (adapter: ${adapterType})`);
|
|
355
789
|
console.log(`Config: ${configPath}`);
|
|
356
790
|
console.log(`Match tag: +${matchTag}`);
|
|
357
791
|
if (adapterConfig.cwd) {
|
|
@@ -366,18 +800,18 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
|
|
|
366
800
|
Use 'task context ${matchTag}' to switch to this project.`);
|
|
367
801
|
}
|
|
368
802
|
async function install() {
|
|
369
|
-
|
|
370
|
-
const hookSource =
|
|
371
|
-
|
|
803
|
+
fs3.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
804
|
+
const hookSource = path4.resolve(
|
|
805
|
+
path4.dirname(new URL(import.meta.url).pathname),
|
|
372
806
|
"hooks",
|
|
373
807
|
"on-modify.js"
|
|
374
808
|
);
|
|
375
|
-
const hookTarget =
|
|
376
|
-
if (
|
|
377
|
-
|
|
809
|
+
const hookTarget = path4.join(HOOKS_DIR, "on-modify.tw-bridge");
|
|
810
|
+
if (fs3.existsSync(hookTarget)) {
|
|
811
|
+
fs3.unlinkSync(hookTarget);
|
|
378
812
|
}
|
|
379
|
-
|
|
380
|
-
|
|
813
|
+
fs3.symlinkSync(hookSource, hookTarget);
|
|
814
|
+
fs3.chmodSync(hookSource, 493);
|
|
381
815
|
console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
|
|
382
816
|
console.log("\nAdd these to your .taskrc:\n");
|
|
383
817
|
console.log("# --- tw-bridge UDAs ---");
|
|
@@ -393,51 +827,141 @@ async function install() {
|
|
|
393
827
|
console.log("urgency.user.tag.in_review.coefficient=-2.0");
|
|
394
828
|
console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
|
|
395
829
|
console.log("urgency.user.tag.in_beta.coefficient=-6.0");
|
|
396
|
-
|
|
397
|
-
if (
|
|
398
|
-
console.log("\
|
|
399
|
-
console.log("
|
|
400
|
-
console.log(` mv ${timewHook} ${timewHook}.disabled`);
|
|
830
|
+
installTimewExtension();
|
|
831
|
+
if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
832
|
+
console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
|
|
833
|
+
console.log(" tw-bridge timewarrior enable");
|
|
401
834
|
}
|
|
402
835
|
installShellFunction();
|
|
403
836
|
}
|
|
837
|
+
var TIMEW_EXT_DIR = path4.join(os3.homedir(), ".timewarrior", "extensions");
|
|
838
|
+
function installTimewExtension() {
|
|
839
|
+
fs3.mkdirSync(TIMEW_EXT_DIR, { recursive: true });
|
|
840
|
+
const extSource = path4.resolve(
|
|
841
|
+
path4.dirname(new URL(import.meta.url).pathname),
|
|
842
|
+
"extensions",
|
|
843
|
+
"bridge.js"
|
|
844
|
+
);
|
|
845
|
+
const extTarget = path4.join(TIMEW_EXT_DIR, "bridge");
|
|
846
|
+
if (fs3.existsSync(extTarget)) {
|
|
847
|
+
fs3.unlinkSync(extTarget);
|
|
848
|
+
}
|
|
849
|
+
fs3.symlinkSync(extSource, extTarget);
|
|
850
|
+
fs3.chmodSync(extSource, 493);
|
|
851
|
+
console.log(`
|
|
852
|
+
Installed Timewarrior extension: ${extTarget} -> ${extSource}`);
|
|
853
|
+
console.log(" Usage: timew bridge [task-time|wall-time] [project-filter]");
|
|
854
|
+
}
|
|
855
|
+
var STANDARD_TIMEW_HOOK = path4.join(HOOKS_DIR, "on-modify.timewarrior");
|
|
856
|
+
async function timewarriorCmd() {
|
|
857
|
+
const sub = process.argv[3];
|
|
858
|
+
if (!sub || sub === "--help") {
|
|
859
|
+
console.log("Usage: tw-bridge timewarrior <subcommand>\n");
|
|
860
|
+
console.log("Subcommands:");
|
|
861
|
+
console.log(" enable Enable Timewarrior tracking");
|
|
862
|
+
console.log(" disable Disable Timewarrior tracking");
|
|
863
|
+
console.log(" status Show current Timewarrior configuration");
|
|
864
|
+
console.log("\nParallel time tracking is controlled per-invocation:");
|
|
865
|
+
console.log(" task start <id> --parallel Track in parallel with current task");
|
|
866
|
+
console.log(" task start <id> --switch Stop current task, start new one");
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
const config = loadConfig();
|
|
870
|
+
if (sub === "status") {
|
|
871
|
+
const tw = config.timewarrior;
|
|
872
|
+
if (!tw?.enabled) {
|
|
873
|
+
console.log("Timewarrior: disabled");
|
|
874
|
+
} else {
|
|
875
|
+
console.log("Timewarrior: enabled");
|
|
876
|
+
console.log(" Parallel tracking: use `task start <id> --parallel`");
|
|
877
|
+
}
|
|
878
|
+
const hookExists = fs3.existsSync(STANDARD_TIMEW_HOOK);
|
|
879
|
+
const hookDisabled = fs3.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
|
|
880
|
+
if (hookExists) {
|
|
881
|
+
console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
|
|
882
|
+
if (tw?.enabled) {
|
|
883
|
+
console.log(" Warning: may cause double-tracking. Run `tw-bridge timewarrior enable` to fix.");
|
|
884
|
+
}
|
|
885
|
+
} else if (hookDisabled) {
|
|
886
|
+
console.log("Standard hook: disabled");
|
|
887
|
+
} else {
|
|
888
|
+
console.log("Standard hook: not found");
|
|
889
|
+
}
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (sub === "enable") {
|
|
893
|
+
config.timewarrior = { enabled: true };
|
|
894
|
+
const configPath = saveConfig(config);
|
|
895
|
+
console.log("Timewarrior tracking enabled");
|
|
896
|
+
console.log(`Config: ${configPath}`);
|
|
897
|
+
if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
898
|
+
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
899
|
+
fs3.renameSync(STANDARD_TIMEW_HOOK, disabled);
|
|
900
|
+
console.log(`
|
|
901
|
+
Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
902
|
+
console.log("tw-bridge will handle Timewarrior tracking directly.");
|
|
903
|
+
}
|
|
904
|
+
return;
|
|
905
|
+
}
|
|
906
|
+
if (sub === "disable") {
|
|
907
|
+
config.timewarrior = { enabled: false };
|
|
908
|
+
const configPath = saveConfig(config);
|
|
909
|
+
console.log("Timewarrior tracking disabled");
|
|
910
|
+
console.log(`Config: ${configPath}`);
|
|
911
|
+
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
912
|
+
if (fs3.existsSync(disabled)) {
|
|
913
|
+
fs3.renameSync(disabled, STANDARD_TIMEW_HOOK);
|
|
914
|
+
console.log(`
|
|
915
|
+
Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
|
|
916
|
+
}
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
404
922
|
var SHELL_FUNCTION = `
|
|
405
923
|
# tw-bridge: auto-context task wrapper
|
|
406
924
|
task() {
|
|
407
|
-
local ctx
|
|
925
|
+
local ctx mode=""
|
|
926
|
+
local args=()
|
|
927
|
+
|
|
928
|
+
for arg in "$@"; do
|
|
929
|
+
case "$arg" in
|
|
930
|
+
--parallel|-p) mode="parallel" ;;
|
|
931
|
+
--switch|-s) mode="switch" ;;
|
|
932
|
+
*) args+=("$arg") ;;
|
|
933
|
+
esac
|
|
934
|
+
done
|
|
935
|
+
|
|
408
936
|
ctx=$(tw-bridge which 2>/dev/null)
|
|
409
|
-
|
|
410
|
-
command task "rc.context=$ctx" "$@"
|
|
411
|
-
else
|
|
412
|
-
command task "$@"
|
|
413
|
-
fi
|
|
937
|
+
TW_BRIDGE_MODE="$mode" command task \${ctx:+"rc.context=$ctx"} "\${args[@]}"
|
|
414
938
|
}
|
|
415
939
|
`.trim();
|
|
416
940
|
var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
|
|
417
941
|
function installShellFunction() {
|
|
418
942
|
const shell = process.env.SHELL ?? "/bin/bash";
|
|
419
|
-
const home =
|
|
943
|
+
const home = os3.homedir();
|
|
420
944
|
let rcFile;
|
|
421
945
|
if (shell.endsWith("zsh")) {
|
|
422
|
-
rcFile =
|
|
946
|
+
rcFile = path4.join(home, ".zshrc");
|
|
423
947
|
} else {
|
|
424
|
-
rcFile =
|
|
948
|
+
rcFile = path4.join(home, ".bashrc");
|
|
425
949
|
}
|
|
426
|
-
const existing =
|
|
950
|
+
const existing = fs3.existsSync(rcFile) ? fs3.readFileSync(rcFile, "utf-8") : "";
|
|
427
951
|
if (existing.includes(SHELL_MARKER)) {
|
|
428
952
|
console.log(`
|
|
429
953
|
Shell integration already installed in ${rcFile}`);
|
|
430
954
|
return;
|
|
431
955
|
}
|
|
432
|
-
|
|
956
|
+
fs3.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
|
|
433
957
|
console.log(`
|
|
434
958
|
Shell integration installed in ${rcFile}`);
|
|
435
959
|
console.log("Restart your shell or run: source " + rcFile);
|
|
436
960
|
}
|
|
437
|
-
var SEEN_FILE =
|
|
961
|
+
var SEEN_FILE = path4.join(os3.homedir(), ".config", "tw-bridge", ".seen-dirs");
|
|
438
962
|
function loadSeenDirs() {
|
|
439
963
|
try {
|
|
440
|
-
const raw =
|
|
964
|
+
const raw = fs3.readFileSync(SEEN_FILE, "utf-8");
|
|
441
965
|
return new Set(raw.split("\n").filter(Boolean));
|
|
442
966
|
} catch {
|
|
443
967
|
return /* @__PURE__ */ new Set();
|
|
@@ -447,15 +971,15 @@ function markDirSeen(dir) {
|
|
|
447
971
|
const seen = loadSeenDirs();
|
|
448
972
|
if (seen.has(dir)) return;
|
|
449
973
|
seen.add(dir);
|
|
450
|
-
|
|
451
|
-
|
|
974
|
+
fs3.mkdirSync(path4.dirname(SEEN_FILE), { recursive: true });
|
|
975
|
+
fs3.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
|
|
452
976
|
}
|
|
453
977
|
function isGitRepo(dir) {
|
|
454
978
|
try {
|
|
455
979
|
let current = dir;
|
|
456
|
-
while (current !==
|
|
457
|
-
if (
|
|
458
|
-
current =
|
|
980
|
+
while (current !== path4.dirname(current)) {
|
|
981
|
+
if (fs3.existsSync(path4.join(current, ".git"))) return true;
|
|
982
|
+
current = path4.dirname(current);
|
|
459
983
|
}
|
|
460
984
|
return false;
|
|
461
985
|
} catch {
|
|
@@ -468,8 +992,8 @@ async function which() {
|
|
|
468
992
|
for (const [_name, backend] of Object.entries(config.backends)) {
|
|
469
993
|
const backendCwd = backend.config?.cwd;
|
|
470
994
|
if (!backendCwd) continue;
|
|
471
|
-
const resolved =
|
|
472
|
-
if (cwd === resolved || cwd.startsWith(resolved +
|
|
995
|
+
const resolved = path4.resolve(backendCwd);
|
|
996
|
+
if (cwd === resolved || cwd.startsWith(resolved + path4.sep)) {
|
|
473
997
|
const contextTag = backend.match.tags?.[0];
|
|
474
998
|
if (contextTag) {
|
|
475
999
|
process.stdout.write(contextTag);
|
|
@@ -485,15 +1009,15 @@ async function which() {
|
|
|
485
1009
|
const seen = loadSeenDirs();
|
|
486
1010
|
let gitRoot = cwd;
|
|
487
1011
|
let current = cwd;
|
|
488
|
-
while (current !==
|
|
489
|
-
if (
|
|
1012
|
+
while (current !== path4.dirname(current)) {
|
|
1013
|
+
if (fs3.existsSync(path4.join(current, ".git"))) {
|
|
490
1014
|
gitRoot = current;
|
|
491
1015
|
break;
|
|
492
1016
|
}
|
|
493
|
-
current =
|
|
1017
|
+
current = path4.dirname(current);
|
|
494
1018
|
}
|
|
495
1019
|
if (!seen.has(gitRoot)) {
|
|
496
|
-
const dirName =
|
|
1020
|
+
const dirName = path4.basename(gitRoot);
|
|
497
1021
|
process.stderr.write(
|
|
498
1022
|
`tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
|
|
499
1023
|
`
|
|
@@ -557,6 +1081,14 @@ async function syncBackend(name, backend, config) {
|
|
|
557
1081
|
}
|
|
558
1082
|
} else if (isDone) {
|
|
559
1083
|
if (completeTask(existingTask, matchTags)) {
|
|
1084
|
+
if (adapter.onDone) {
|
|
1085
|
+
try {
|
|
1086
|
+
await adapter.onDone(task);
|
|
1087
|
+
} catch (err) {
|
|
1088
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1089
|
+
console.error(` \u26A0 [#${backendId}] failed to push completion: ${msg}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
560
1092
|
console.log(` \u2713 [#${backendId}] ${task.description}`);
|
|
561
1093
|
completed++;
|
|
562
1094
|
}
|