@bretwardjames/tw-bridge 0.3.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 +528 -67
- 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,7 +708,7 @@ 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,
|
|
@@ -346,14 +769,23 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
|
|
|
346
769
|
}
|
|
347
770
|
const matchTag = tagOverride ?? name;
|
|
348
771
|
const cwd = process.cwd();
|
|
349
|
-
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
|
+
}
|
|
350
780
|
config.backends[name] = {
|
|
351
781
|
adapter: adapterType,
|
|
352
782
|
match: { tags: [matchTag] },
|
|
783
|
+
...doneStatuses?.length && { done_statuses: doneStatuses },
|
|
353
784
|
config: adapterConfig
|
|
354
785
|
};
|
|
355
786
|
const configPath = saveConfig(config);
|
|
356
|
-
console.log(`
|
|
787
|
+
console.log(`
|
|
788
|
+
Added backend "${name}" (adapter: ${adapterType})`);
|
|
357
789
|
console.log(`Config: ${configPath}`);
|
|
358
790
|
console.log(`Match tag: +${matchTag}`);
|
|
359
791
|
if (adapterConfig.cwd) {
|
|
@@ -368,18 +800,18 @@ Available adapters: ${listAdapterTypes().join(", ")}`);
|
|
|
368
800
|
Use 'task context ${matchTag}' to switch to this project.`);
|
|
369
801
|
}
|
|
370
802
|
async function install() {
|
|
371
|
-
|
|
372
|
-
const hookSource =
|
|
373
|
-
|
|
803
|
+
fs3.mkdirSync(HOOKS_DIR, { recursive: true });
|
|
804
|
+
const hookSource = path4.resolve(
|
|
805
|
+
path4.dirname(new URL(import.meta.url).pathname),
|
|
374
806
|
"hooks",
|
|
375
807
|
"on-modify.js"
|
|
376
808
|
);
|
|
377
|
-
const hookTarget =
|
|
378
|
-
if (
|
|
379
|
-
|
|
809
|
+
const hookTarget = path4.join(HOOKS_DIR, "on-modify.tw-bridge");
|
|
810
|
+
if (fs3.existsSync(hookTarget)) {
|
|
811
|
+
fs3.unlinkSync(hookTarget);
|
|
380
812
|
}
|
|
381
|
-
|
|
382
|
-
|
|
813
|
+
fs3.symlinkSync(hookSource, hookTarget);
|
|
814
|
+
fs3.chmodSync(hookSource, 493);
|
|
383
815
|
console.log(`Installed hook: ${hookTarget} -> ${hookSource}`);
|
|
384
816
|
console.log("\nAdd these to your .taskrc:\n");
|
|
385
817
|
console.log("# --- tw-bridge UDAs ---");
|
|
@@ -395,22 +827,43 @@ async function install() {
|
|
|
395
827
|
console.log("urgency.user.tag.in_review.coefficient=-2.0");
|
|
396
828
|
console.log("urgency.user.tag.ready_for_beta.coefficient=-4.0");
|
|
397
829
|
console.log("urgency.user.tag.in_beta.coefficient=-6.0");
|
|
398
|
-
|
|
830
|
+
installTimewExtension();
|
|
831
|
+
if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
399
832
|
console.log("\nTimewarrior hook detected. To enable tw-bridge time tracking:");
|
|
400
|
-
console.log(" tw-bridge timewarrior enable
|
|
833
|
+
console.log(" tw-bridge timewarrior enable");
|
|
401
834
|
}
|
|
402
835
|
installShellFunction();
|
|
403
836
|
}
|
|
404
|
-
var
|
|
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");
|
|
405
856
|
async function timewarriorCmd() {
|
|
406
857
|
const sub = process.argv[3];
|
|
407
858
|
if (!sub || sub === "--help") {
|
|
408
859
|
console.log("Usage: tw-bridge timewarrior <subcommand>\n");
|
|
409
860
|
console.log("Subcommands:");
|
|
410
|
-
console.log(" enable
|
|
411
|
-
console.log("
|
|
412
|
-
console.log("
|
|
413
|
-
console.log("
|
|
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");
|
|
414
867
|
return;
|
|
415
868
|
}
|
|
416
869
|
const config = loadConfig();
|
|
@@ -419,10 +872,11 @@ async function timewarriorCmd() {
|
|
|
419
872
|
if (!tw?.enabled) {
|
|
420
873
|
console.log("Timewarrior: disabled");
|
|
421
874
|
} else {
|
|
422
|
-
console.log(
|
|
875
|
+
console.log("Timewarrior: enabled");
|
|
876
|
+
console.log(" Parallel tracking: use `task start <id> --parallel`");
|
|
423
877
|
}
|
|
424
|
-
const hookExists =
|
|
425
|
-
const hookDisabled =
|
|
878
|
+
const hookExists = fs3.existsSync(STANDARD_TIMEW_HOOK);
|
|
879
|
+
const hookDisabled = fs3.existsSync(STANDARD_TIMEW_HOOK + ".disabled");
|
|
426
880
|
if (hookExists) {
|
|
427
881
|
console.log(`Standard hook: active (${STANDARD_TIMEW_HOOK})`);
|
|
428
882
|
if (tw?.enabled) {
|
|
@@ -435,18 +889,14 @@ async function timewarriorCmd() {
|
|
|
435
889
|
}
|
|
436
890
|
return;
|
|
437
891
|
}
|
|
438
|
-
if (sub === "enable"
|
|
439
|
-
|
|
440
|
-
config.timewarrior = {
|
|
441
|
-
enabled: true,
|
|
442
|
-
allow_overlaps: allowOverlaps
|
|
443
|
-
};
|
|
892
|
+
if (sub === "enable") {
|
|
893
|
+
config.timewarrior = { enabled: true };
|
|
444
894
|
const configPath = saveConfig(config);
|
|
445
|
-
console.log(
|
|
895
|
+
console.log("Timewarrior tracking enabled");
|
|
446
896
|
console.log(`Config: ${configPath}`);
|
|
447
|
-
if (
|
|
897
|
+
if (fs3.existsSync(STANDARD_TIMEW_HOOK)) {
|
|
448
898
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
449
|
-
|
|
899
|
+
fs3.renameSync(STANDARD_TIMEW_HOOK, disabled);
|
|
450
900
|
console.log(`
|
|
451
901
|
Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
452
902
|
console.log("tw-bridge will handle Timewarrior tracking directly.");
|
|
@@ -454,16 +904,13 @@ Disabled standard hook: ${STANDARD_TIMEW_HOOK} -> .disabled`);
|
|
|
454
904
|
return;
|
|
455
905
|
}
|
|
456
906
|
if (sub === "disable") {
|
|
457
|
-
config.timewarrior = {
|
|
458
|
-
enabled: false,
|
|
459
|
-
allow_overlaps: false
|
|
460
|
-
};
|
|
907
|
+
config.timewarrior = { enabled: false };
|
|
461
908
|
const configPath = saveConfig(config);
|
|
462
909
|
console.log("Timewarrior tracking disabled");
|
|
463
910
|
console.log(`Config: ${configPath}`);
|
|
464
911
|
const disabled = STANDARD_TIMEW_HOOK + ".disabled";
|
|
465
|
-
if (
|
|
466
|
-
|
|
912
|
+
if (fs3.existsSync(disabled)) {
|
|
913
|
+
fs3.renameSync(disabled, STANDARD_TIMEW_HOOK);
|
|
467
914
|
console.log(`
|
|
468
915
|
Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
|
|
469
916
|
}
|
|
@@ -475,40 +922,46 @@ Restored standard hook: ${STANDARD_TIMEW_HOOK}`);
|
|
|
475
922
|
var SHELL_FUNCTION = `
|
|
476
923
|
# tw-bridge: auto-context task wrapper
|
|
477
924
|
task() {
|
|
478
|
-
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
|
+
|
|
479
936
|
ctx=$(tw-bridge which 2>/dev/null)
|
|
480
|
-
|
|
481
|
-
command task "rc.context=$ctx" "$@"
|
|
482
|
-
else
|
|
483
|
-
command task "$@"
|
|
484
|
-
fi
|
|
937
|
+
TW_BRIDGE_MODE="$mode" command task \${ctx:+"rc.context=$ctx"} "\${args[@]}"
|
|
485
938
|
}
|
|
486
939
|
`.trim();
|
|
487
940
|
var SHELL_MARKER = "# tw-bridge: auto-context task wrapper";
|
|
488
941
|
function installShellFunction() {
|
|
489
942
|
const shell = process.env.SHELL ?? "/bin/bash";
|
|
490
|
-
const home =
|
|
943
|
+
const home = os3.homedir();
|
|
491
944
|
let rcFile;
|
|
492
945
|
if (shell.endsWith("zsh")) {
|
|
493
|
-
rcFile =
|
|
946
|
+
rcFile = path4.join(home, ".zshrc");
|
|
494
947
|
} else {
|
|
495
|
-
rcFile =
|
|
948
|
+
rcFile = path4.join(home, ".bashrc");
|
|
496
949
|
}
|
|
497
|
-
const existing =
|
|
950
|
+
const existing = fs3.existsSync(rcFile) ? fs3.readFileSync(rcFile, "utf-8") : "";
|
|
498
951
|
if (existing.includes(SHELL_MARKER)) {
|
|
499
952
|
console.log(`
|
|
500
953
|
Shell integration already installed in ${rcFile}`);
|
|
501
954
|
return;
|
|
502
955
|
}
|
|
503
|
-
|
|
956
|
+
fs3.appendFileSync(rcFile, "\n" + SHELL_FUNCTION + "\n");
|
|
504
957
|
console.log(`
|
|
505
958
|
Shell integration installed in ${rcFile}`);
|
|
506
959
|
console.log("Restart your shell or run: source " + rcFile);
|
|
507
960
|
}
|
|
508
|
-
var SEEN_FILE =
|
|
961
|
+
var SEEN_FILE = path4.join(os3.homedir(), ".config", "tw-bridge", ".seen-dirs");
|
|
509
962
|
function loadSeenDirs() {
|
|
510
963
|
try {
|
|
511
|
-
const raw =
|
|
964
|
+
const raw = fs3.readFileSync(SEEN_FILE, "utf-8");
|
|
512
965
|
return new Set(raw.split("\n").filter(Boolean));
|
|
513
966
|
} catch {
|
|
514
967
|
return /* @__PURE__ */ new Set();
|
|
@@ -518,15 +971,15 @@ function markDirSeen(dir) {
|
|
|
518
971
|
const seen = loadSeenDirs();
|
|
519
972
|
if (seen.has(dir)) return;
|
|
520
973
|
seen.add(dir);
|
|
521
|
-
|
|
522
|
-
|
|
974
|
+
fs3.mkdirSync(path4.dirname(SEEN_FILE), { recursive: true });
|
|
975
|
+
fs3.writeFileSync(SEEN_FILE, [...seen].join("\n") + "\n");
|
|
523
976
|
}
|
|
524
977
|
function isGitRepo(dir) {
|
|
525
978
|
try {
|
|
526
979
|
let current = dir;
|
|
527
|
-
while (current !==
|
|
528
|
-
if (
|
|
529
|
-
current =
|
|
980
|
+
while (current !== path4.dirname(current)) {
|
|
981
|
+
if (fs3.existsSync(path4.join(current, ".git"))) return true;
|
|
982
|
+
current = path4.dirname(current);
|
|
530
983
|
}
|
|
531
984
|
return false;
|
|
532
985
|
} catch {
|
|
@@ -539,8 +992,8 @@ async function which() {
|
|
|
539
992
|
for (const [_name, backend] of Object.entries(config.backends)) {
|
|
540
993
|
const backendCwd = backend.config?.cwd;
|
|
541
994
|
if (!backendCwd) continue;
|
|
542
|
-
const resolved =
|
|
543
|
-
if (cwd === resolved || cwd.startsWith(resolved +
|
|
995
|
+
const resolved = path4.resolve(backendCwd);
|
|
996
|
+
if (cwd === resolved || cwd.startsWith(resolved + path4.sep)) {
|
|
544
997
|
const contextTag = backend.match.tags?.[0];
|
|
545
998
|
if (contextTag) {
|
|
546
999
|
process.stdout.write(contextTag);
|
|
@@ -556,15 +1009,15 @@ async function which() {
|
|
|
556
1009
|
const seen = loadSeenDirs();
|
|
557
1010
|
let gitRoot = cwd;
|
|
558
1011
|
let current = cwd;
|
|
559
|
-
while (current !==
|
|
560
|
-
if (
|
|
1012
|
+
while (current !== path4.dirname(current)) {
|
|
1013
|
+
if (fs3.existsSync(path4.join(current, ".git"))) {
|
|
561
1014
|
gitRoot = current;
|
|
562
1015
|
break;
|
|
563
1016
|
}
|
|
564
|
-
current =
|
|
1017
|
+
current = path4.dirname(current);
|
|
565
1018
|
}
|
|
566
1019
|
if (!seen.has(gitRoot)) {
|
|
567
|
-
const dirName =
|
|
1020
|
+
const dirName = path4.basename(gitRoot);
|
|
568
1021
|
process.stderr.write(
|
|
569
1022
|
`tw-bridge: unconfigured project "${dirName}". Run: tw-bridge add ${dirName} --adapter ghp
|
|
570
1023
|
`
|
|
@@ -628,6 +1081,14 @@ async function syncBackend(name, backend, config) {
|
|
|
628
1081
|
}
|
|
629
1082
|
} else if (isDone) {
|
|
630
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
|
+
}
|
|
631
1092
|
console.log(` \u2713 [#${backendId}] ${task.description}`);
|
|
632
1093
|
completed++;
|
|
633
1094
|
}
|