@ondrej-svec/hog 1.7.2 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -20,7 +20,7 @@ function migrateConfig(raw) {
20
20
  raw = {
21
21
  ...raw,
22
22
  version: 2,
23
- repos: LEGACY_REPOS,
23
+ repos: [],
24
24
  board: {
25
25
  refreshInterval: 60,
26
26
  backlogLimit: 20,
@@ -135,7 +135,9 @@ function saveConfig(data) {
135
135
  ensureDir();
136
136
  const existing = getConfig();
137
137
  writeFileSync(CONFIG_FILE, `${JSON.stringify({ ...existing, ...data }, null, 2)}
138
- `);
138
+ `, {
139
+ mode: 384
140
+ });
139
141
  }
140
142
  function requireAuth() {
141
143
  const auth = getAuth();
@@ -145,7 +147,7 @@ function requireAuth() {
145
147
  }
146
148
  return auth;
147
149
  }
148
- var CONFIG_DIR, AUTH_FILE, CONFIG_FILE, COMPLETION_ACTION_SCHEMA, REPO_NAME_PATTERN, REPO_CONFIG_SCHEMA, BOARD_CONFIG_SCHEMA, TICKTICK_CONFIG_SCHEMA, PROFILE_SCHEMA, HOG_CONFIG_SCHEMA, LEGACY_REPOS;
150
+ var CONFIG_DIR, AUTH_FILE, CONFIG_FILE, COMPLETION_ACTION_SCHEMA, REPO_NAME_PATTERN, REPO_CONFIG_SCHEMA, BOARD_CONFIG_SCHEMA, TICKTICK_CONFIG_SCHEMA, PROFILE_SCHEMA, HOG_CONFIG_SCHEMA;
149
151
  var init_config = __esm({
150
152
  "src/config.ts"() {
151
153
  "use strict";
@@ -191,7 +193,6 @@ var init_config = __esm({
191
193
  profiles: z.record(z.string(), PROFILE_SCHEMA).default({}),
192
194
  defaultProfile: z.string().optional()
193
195
  });
194
- LEGACY_REPOS = [];
195
196
  }
196
197
  });
197
198
 
@@ -335,10 +336,11 @@ async function extractIssueFields(input2, options = {}) {
335
336
  options.onLlmFallback?.("AI parsing unavailable, used keyword matching");
336
337
  return heuristic;
337
338
  }
339
+ const safeLabels = options.validLabels && options.validLabels.length > 0 ? llmResult.labels.filter((l) => (options.validLabels ?? []).includes(l)) : llmResult.labels;
338
340
  const merged = {
339
341
  ...llmResult,
340
342
  // Heuristic explicit tokens always win
341
- labels: heuristic.labels.length > 0 ? heuristic.labels : llmResult.labels,
343
+ labels: heuristic.labels.length > 0 ? heuristic.labels : safeLabels,
342
344
  assignee: heuristic.assignee ?? llmResult.assignee,
343
345
  dueDate: heuristic.dueDate ?? llmResult.due_date,
344
346
  // LLM title is used only if heuristic left explicit tokens
@@ -385,30 +387,41 @@ var init_api = __esm({
385
387
  throw new Error(`TickTick API error ${res.status}: ${text2}`);
386
388
  }
387
389
  const text = await res.text();
388
- if (!text) return void 0;
390
+ if (!text) return null;
389
391
  return JSON.parse(text);
390
392
  }
391
393
  async listProjects() {
392
- return this.request("GET", "/project");
394
+ return await this.request("GET", "/project") ?? [];
393
395
  }
394
396
  async getProject(projectId) {
395
- return this.request("GET", `/project/${projectId}`);
397
+ const result = await this.request("GET", `/project/${projectId}`);
398
+ if (!result) throw new Error(`TickTick API returned empty response for project ${projectId}`);
399
+ return result;
396
400
  }
397
401
  async getProjectData(projectId) {
398
- return this.request("GET", `/project/${projectId}/data`);
402
+ const result = await this.request("GET", `/project/${projectId}/data`);
403
+ if (!result)
404
+ throw new Error(`TickTick API returned empty response for project data ${projectId}`);
405
+ return result;
399
406
  }
400
407
  async listTasks(projectId) {
401
408
  const data = await this.getProjectData(projectId);
402
409
  return data.tasks ?? [];
403
410
  }
404
411
  async getTask(projectId, taskId) {
405
- return this.request("GET", `/project/${projectId}/task/${taskId}`);
412
+ const result = await this.request("GET", `/project/${projectId}/task/${taskId}`);
413
+ if (!result) throw new Error(`TickTick API returned empty response for task ${taskId}`);
414
+ return result;
406
415
  }
407
416
  async createTask(input2) {
408
- return this.request("POST", "/task", input2);
417
+ const result = await this.request("POST", "/task", input2);
418
+ if (!result) throw new Error("TickTick API returned empty response for createTask");
419
+ return result;
409
420
  }
410
421
  async updateTask(input2) {
411
- return this.request("POST", `/task/${input2.id}`, input2);
422
+ const result = await this.request("POST", `/task/${input2.id}`, input2);
423
+ if (!result) throw new Error(`TickTick API returned empty response for updateTask ${input2.id}`);
424
+ return result;
412
425
  }
413
426
  async completeTask(projectId, taskId) {
414
427
  await this.request("POST", `/project/${projectId}/task/${taskId}/complete`);
@@ -480,6 +493,31 @@ var init_types = __esm({
480
493
  }
481
494
  });
482
495
 
496
+ // src/board/constants.ts
497
+ function isTerminalStatus(status) {
498
+ return TERMINAL_STATUS_RE.test(status);
499
+ }
500
+ function isHeaderId(id) {
501
+ return id != null && (id.startsWith("header:") || id.startsWith("sub:"));
502
+ }
503
+ function timeAgo(date) {
504
+ const seconds = Math.floor((Date.now() - date.getTime()) / 1e3);
505
+ if (seconds < 10) return "just now";
506
+ if (seconds < 60) return `${seconds}s ago`;
507
+ const minutes = Math.floor(seconds / 60);
508
+ return `${minutes}m ago`;
509
+ }
510
+ function formatError(err) {
511
+ return err instanceof Error ? err.message : String(err);
512
+ }
513
+ var TERMINAL_STATUS_RE;
514
+ var init_constants = __esm({
515
+ "src/board/constants.ts"() {
516
+ "use strict";
517
+ TERMINAL_STATUS_RE = /^(done|shipped|won't|wont|closed|complete|completed)$/i;
518
+ }
519
+ });
520
+
483
521
  // src/github.ts
484
522
  var github_exports = {};
485
523
  __export(github_exports, {
@@ -489,6 +527,11 @@ __export(github_exports, {
489
527
  assignIssue: () => assignIssue,
490
528
  assignIssueAsync: () => assignIssueAsync,
491
529
  assignIssueToAsync: () => assignIssueToAsync,
530
+ clearProjectNodeIdCache: () => clearProjectNodeIdCache,
531
+ closeIssueAsync: () => closeIssueAsync,
532
+ createIssueAsync: () => createIssueAsync,
533
+ editIssueBodyAsync: () => editIssueBodyAsync,
534
+ editIssueTitleAsync: () => editIssueTitleAsync,
492
535
  fetchAssignedIssues: () => fetchAssignedIssues,
493
536
  fetchIssueAsync: () => fetchIssueAsync,
494
537
  fetchIssueCommentsAsync: () => fetchIssueCommentsAsync,
@@ -500,6 +543,7 @@ __export(github_exports, {
500
543
  fetchRepoLabelsAsync: () => fetchRepoLabelsAsync,
501
544
  removeLabelAsync: () => removeLabelAsync,
502
545
  unassignIssueAsync: () => unassignIssueAsync,
546
+ updateLabelsAsync: () => updateLabelsAsync,
503
547
  updateProjectItemDateAsync: () => updateProjectItemDateAsync,
504
548
  updateProjectItemStatus: () => updateProjectItemStatus,
505
549
  updateProjectItemStatusAsync: () => updateProjectItemStatusAsync
@@ -587,6 +631,24 @@ async function fetchIssueAsync(repo, issueNumber) {
587
631
  "number,title,url,state,updatedAt,labels,assignees,body,projectStatus"
588
632
  ]);
589
633
  }
634
+ async function closeIssueAsync(repo, issueNumber) {
635
+ await runGhAsync(["issue", "close", String(issueNumber), "--repo", repo]);
636
+ }
637
+ async function createIssueAsync(repo, title, body, labels) {
638
+ const args = ["issue", "create", "--repo", repo, "--title", title, "--body", body];
639
+ if (labels && labels.length > 0) {
640
+ for (const label of labels) {
641
+ args.push("--label", label);
642
+ }
643
+ }
644
+ return runGhAsync(args);
645
+ }
646
+ async function editIssueTitleAsync(repo, issueNumber, title) {
647
+ await runGhAsync(["issue", "edit", String(issueNumber), "--repo", repo, "--title", title]);
648
+ }
649
+ async function editIssueBodyAsync(repo, issueNumber, body) {
650
+ await runGhAsync(["issue", "edit", String(issueNumber), "--repo", repo, "--body", body]);
651
+ }
590
652
  async function addCommentAsync(repo, issueNumber, body) {
591
653
  await runGhAsync(["issue", "comment", String(issueNumber), "--repo", repo, "--body", body]);
592
654
  }
@@ -596,6 +658,12 @@ async function addLabelAsync(repo, issueNumber, label) {
596
658
  async function removeLabelAsync(repo, issueNumber, label) {
597
659
  await runGhAsync(["issue", "edit", String(issueNumber), "--repo", repo, "--remove-label", label]);
598
660
  }
661
+ async function updateLabelsAsync(repo, issueNumber, addLabels, removeLabels) {
662
+ const args = ["issue", "edit", String(issueNumber), "--repo", repo];
663
+ for (const label of addLabels) args.push("--add-label", label);
664
+ for (const label of removeLabels) args.push("--remove-label", label);
665
+ await runGhAsync(args);
666
+ }
599
667
  async function fetchIssueCommentsAsync(repo, issueNumber) {
600
668
  const result = await runGhJsonAsync([
601
669
  "issue",
@@ -635,6 +703,7 @@ function fetchProjectFields(repo, issueNumber, projectNumber) {
635
703
  }
636
704
  `;
637
705
  const [owner, repoName] = repo.split("/");
706
+ if (!(owner && repoName)) return {};
638
707
  try {
639
708
  const result = runGhJson([
640
709
  "api",
@@ -669,6 +738,7 @@ function fetchProjectFields(repo, issueNumber, projectNumber) {
669
738
  }
670
739
  function fetchProjectEnrichment(repo, projectNumber) {
671
740
  const [owner] = repo.split("/");
741
+ if (!owner) return /* @__PURE__ */ new Map();
672
742
  const query = `
673
743
  query($owner: String!, $projectNumber: Int!) {
674
744
  organization(login: $owner) {
@@ -741,6 +811,7 @@ function fetchProjectTargetDates(repo, projectNumber) {
741
811
  }
742
812
  function fetchProjectStatusOptions(repo, projectNumber, _statusFieldId) {
743
813
  const [owner] = repo.split("/");
814
+ if (!owner) return [];
744
815
  const query = `
745
816
  query($owner: String!, $projectNumber: Int!) {
746
817
  organization(login: $owner) {
@@ -791,8 +862,38 @@ async function fetchRepoLabelsAsync(repo) {
791
862
  return [];
792
863
  }
793
864
  }
865
+ function clearProjectNodeIdCache() {
866
+ projectNodeIdCache.clear();
867
+ }
868
+ async function getProjectNodeId(owner, projectNumber) {
869
+ const key = `${owner}/${String(projectNumber)}`;
870
+ const cached = projectNodeIdCache.get(key);
871
+ if (cached !== void 0) return cached;
872
+ const projectQuery = `
873
+ query($owner: String!) {
874
+ organization(login: $owner) {
875
+ projectV2(number: ${projectNumber}) {
876
+ id
877
+ }
878
+ }
879
+ }
880
+ `;
881
+ const projectResult = await runGhJsonAsync([
882
+ "api",
883
+ "graphql",
884
+ "-f",
885
+ `query=${projectQuery}`,
886
+ "-F",
887
+ `owner=${owner}`
888
+ ]);
889
+ const projectId = projectResult?.data?.organization?.projectV2?.id;
890
+ if (!projectId) return null;
891
+ projectNodeIdCache.set(key, projectId);
892
+ return projectId;
893
+ }
794
894
  function updateProjectItemStatus(repo, issueNumber, projectConfig) {
795
895
  const [owner, repoName] = repo.split("/");
896
+ if (!(owner && repoName)) return;
796
897
  const findItemQuery = `
797
898
  query($owner: String!, $repo: String!, $issueNumber: Int!) {
798
899
  repository(owner: $owner, name: $repo) {
@@ -875,6 +976,7 @@ function updateProjectItemStatus(repo, issueNumber, projectConfig) {
875
976
  }
876
977
  async function updateProjectItemStatusAsync(repo, issueNumber, projectConfig) {
877
978
  const [owner, repoName] = repo.split("/");
979
+ if (!(owner && repoName)) return;
878
980
  const findItemQuery = `
879
981
  query($owner: String!, $repo: String!, $issueNumber: Int!) {
880
982
  repository(owner: $owner, name: $repo) {
@@ -905,24 +1007,7 @@ async function updateProjectItemStatusAsync(repo, issueNumber, projectConfig) {
905
1007
  const projectNumber = projectConfig.projectNumber;
906
1008
  const projectItem = items.find((item) => item?.project?.number === projectNumber);
907
1009
  if (!projectItem?.id) return;
908
- const projectQuery = `
909
- query($owner: String!) {
910
- organization(login: $owner) {
911
- projectV2(number: ${projectNumber}) {
912
- id
913
- }
914
- }
915
- }
916
- `;
917
- const projectResult = await runGhJsonAsync([
918
- "api",
919
- "graphql",
920
- "-f",
921
- `query=${projectQuery}`,
922
- "-F",
923
- `owner=${owner}`
924
- ]);
925
- const projectId = projectResult?.data?.organization?.projectV2?.id;
1010
+ const projectId = await getProjectNodeId(owner, projectNumber);
926
1011
  if (!projectId) return;
927
1012
  const statusFieldId = projectConfig.statusFieldId;
928
1013
  const optionId = projectConfig.optionId;
@@ -957,6 +1042,7 @@ async function updateProjectItemStatusAsync(repo, issueNumber, projectConfig) {
957
1042
  }
958
1043
  async function updateProjectItemDateAsync(repo, issueNumber, projectConfig, dueDate) {
959
1044
  const [owner, repoName] = repo.split("/");
1045
+ if (!(owner && repoName)) return;
960
1046
  const findItemQuery = `
961
1047
  query($owner: String!, $repo: String!, $issueNumber: Int!) {
962
1048
  repository(owner: $owner, name: $repo) {
@@ -986,24 +1072,7 @@ async function updateProjectItemDateAsync(repo, issueNumber, projectConfig, dueD
986
1072
  const items = findResult?.data?.repository?.issue?.projectItems?.nodes ?? [];
987
1073
  const projectItem = items.find((item) => item?.project?.number === projectConfig.projectNumber);
988
1074
  if (!projectItem?.id) return;
989
- const projectQuery = `
990
- query($owner: String!) {
991
- organization(login: $owner) {
992
- projectV2(number: ${projectConfig.projectNumber}) {
993
- id
994
- }
995
- }
996
- }
997
- `;
998
- const projectResult = await runGhJsonAsync([
999
- "api",
1000
- "graphql",
1001
- "-f",
1002
- `query=${projectQuery}`,
1003
- "-F",
1004
- `owner=${owner}`
1005
- ]);
1006
- const projectId = projectResult?.data?.organization?.projectV2?.id;
1075
+ const projectId = await getProjectNodeId(owner, projectConfig.projectNumber);
1007
1076
  if (!projectId) return;
1008
1077
  const mutation = `
1009
1078
  mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
@@ -1034,12 +1103,13 @@ async function updateProjectItemDateAsync(repo, issueNumber, projectConfig, dueD
1034
1103
  `date=${dueDate}`
1035
1104
  ]);
1036
1105
  }
1037
- var execFileAsync, DATE_FIELD_NAME_RE2;
1106
+ var execFileAsync, DATE_FIELD_NAME_RE2, projectNodeIdCache;
1038
1107
  var init_github = __esm({
1039
1108
  "src/github.ts"() {
1040
1109
  "use strict";
1041
1110
  execFileAsync = promisify(execFile);
1042
1111
  DATE_FIELD_NAME_RE2 = /^(target\s*date|due\s*date|due|deadline)$/i;
1112
+ projectNodeIdCache = /* @__PURE__ */ new Map();
1043
1113
  }
1044
1114
  });
1045
1115
 
@@ -1057,7 +1127,7 @@ function loadSyncState() {
1057
1127
  }
1058
1128
  function saveSyncState(state) {
1059
1129
  writeFileSync3(STATE_FILE, `${JSON.stringify(state, null, 2)}
1060
- `);
1130
+ `, { mode: 384 });
1061
1131
  }
1062
1132
  function findMapping(state, githubRepo, issueNumber) {
1063
1133
  return state.mappings.find(
@@ -1281,8 +1351,6 @@ var init_use_action_log = __esm({
1281
1351
  });
1282
1352
 
1283
1353
  // src/board/hooks/use-actions.ts
1284
- import { execFile as execFile2 } from "child_process";
1285
- import { promisify as promisify2 } from "util";
1286
1354
  import { useCallback as useCallback2, useRef as useRef2 } from "react";
1287
1355
  function findIssueContext(repos, selectedId, config2) {
1288
1356
  if (!selectedId?.startsWith("gh:")) {
@@ -1301,17 +1369,10 @@ function findIssueContext(repos, selectedId, config2) {
1301
1369
  async function triggerCompletionActionAsync(action, repoName, issueNumber) {
1302
1370
  switch (action.type) {
1303
1371
  case "closeIssue":
1304
- await execFileAsync2("gh", ["issue", "close", String(issueNumber), "--repo", repoName], {
1305
- encoding: "utf-8",
1306
- timeout: 3e4
1307
- });
1372
+ await closeIssueAsync(repoName, issueNumber);
1308
1373
  break;
1309
1374
  case "addLabel":
1310
- await execFileAsync2(
1311
- "gh",
1312
- ["issue", "edit", String(issueNumber), "--repo", repoName, "--add-label", action.label],
1313
- { encoding: "utf-8", timeout: 3e4 }
1314
- );
1375
+ await addLabelAsync(repoName, issueNumber, action.label);
1315
1376
  break;
1316
1377
  case "updateProjectStatus":
1317
1378
  break;
@@ -1419,11 +1480,7 @@ function useActions({
1419
1480
  }
1420
1481
  const { issue, repoName } = ctx;
1421
1482
  const t = toast.loading("Commenting...");
1422
- execFileAsync2(
1423
- "gh",
1424
- ["issue", "comment", String(issue.number), "--repo", repoName, "--body", body],
1425
- { encoding: "utf-8", timeout: 3e4 }
1426
- ).then(() => {
1483
+ addCommentAsync(repoName, issue.number, body).then(() => {
1427
1484
  t.resolve(`Comment posted on #${issue.number}`);
1428
1485
  pushEntryRef.current?.({
1429
1486
  id: nextEntryId(),
@@ -1432,6 +1489,7 @@ function useActions({
1432
1489
  ago: Date.now()
1433
1490
  });
1434
1491
  refresh();
1492
+ onOverlayDone();
1435
1493
  }).catch((err) => {
1436
1494
  t.reject(`Comment failed: ${err instanceof Error ? err.message : String(err)}`);
1437
1495
  pushEntryRef.current?.({
@@ -1440,8 +1498,6 @@ function useActions({
1440
1498
  status: "error",
1441
1499
  ago: Date.now()
1442
1500
  });
1443
- }).finally(() => {
1444
- onOverlayDone();
1445
1501
  });
1446
1502
  },
1447
1503
  [toast, refresh, onOverlayDone]
@@ -1545,19 +1601,7 @@ function useActions({
1545
1601
  status: "success",
1546
1602
  ago: Date.now(),
1547
1603
  undo: async () => {
1548
- await execFileAsync2(
1549
- "gh",
1550
- [
1551
- "issue",
1552
- "edit",
1553
- String(issue.number),
1554
- "--repo",
1555
- repoName,
1556
- "--remove-assignee",
1557
- "@me"
1558
- ],
1559
- { encoding: "utf-8", timeout: 3e4 }
1560
- );
1604
+ await unassignIssueAsync(repoName, issue.number, "@me");
1561
1605
  }
1562
1606
  });
1563
1607
  refresh();
@@ -1571,33 +1615,6 @@ function useActions({
1571
1615
  });
1572
1616
  });
1573
1617
  }, [toast, refresh]);
1574
- const handleUnassign = useCallback2(() => {
1575
- const ctx = findIssueContext(reposRef.current, selectedIdRef.current, configRef.current);
1576
- if (!(ctx.issue && ctx.repoName)) return;
1577
- const { issue, repoName } = ctx;
1578
- const assignees = issue.assignees ?? [];
1579
- const selfAssigned = assignees.some((a) => a.login === configRef.current.board.assignee);
1580
- if (!selfAssigned) {
1581
- const firstAssignee = assignees[0];
1582
- if (firstAssignee) {
1583
- toast.info(`Assigned to @${firstAssignee.login} \u2014 can only unassign self`);
1584
- } else {
1585
- toast.info("Not assigned");
1586
- }
1587
- return;
1588
- }
1589
- const t = toast.loading("Unassigning...");
1590
- execFileAsync2(
1591
- "gh",
1592
- ["issue", "edit", String(issue.number), "--repo", repoName, "--remove-assignee", "@me"],
1593
- { encoding: "utf-8", timeout: 3e4 }
1594
- ).then(() => {
1595
- t.resolve(`Unassigned #${issue.number} from @${configRef.current.board.assignee}`);
1596
- refresh();
1597
- }).catch((err) => {
1598
- t.reject(`Unassign failed: ${err instanceof Error ? err.message : String(err)}`);
1599
- });
1600
- }, [toast, refresh]);
1601
1618
  const handleCreateIssue = useCallback2(
1602
1619
  async (repo, title, body, dueDate, labels) => {
1603
1620
  const repoConfig = configRef.current.repos.find((r) => r.name === repo);
@@ -1608,16 +1625,9 @@ function useActions({
1608
1625
 
1609
1626
  ${dueLine}` : dueLine;
1610
1627
  }
1611
- const args = ["issue", "create", "--repo", repo, "--title", title, "--body", effectiveBody];
1612
- if (labels && labels.length > 0) {
1613
- for (const label of labels) {
1614
- args.push("--label", label);
1615
- }
1616
- }
1617
1628
  const t = toast.loading("Creating...");
1618
1629
  try {
1619
- const { stdout } = await execFileAsync2("gh", args, { encoding: "utf-8", timeout: 3e4 });
1620
- const output = stdout.trim();
1630
+ const output = await createIssueAsync(repo, title, effectiveBody, labels);
1621
1631
  const match = output.match(/\/(\d+)$/);
1622
1632
  const issueNumber = match?.[1] ? parseInt(match[1], 10) : 0;
1623
1633
  const shortName = repoConfig?.shortName ?? repo;
@@ -1646,11 +1656,8 @@ ${dueLine}` : dueLine;
1646
1656
  const ctx = findIssueContext(reposRef.current, selectedIdRef.current, configRef.current);
1647
1657
  if (!(ctx.issue && ctx.repoName)) return;
1648
1658
  const { issue, repoName } = ctx;
1649
- const args = ["issue", "edit", String(issue.number), "--repo", repoName];
1650
- for (const label of addLabels) args.push("--add-label", label);
1651
- for (const label of removeLabels) args.push("--remove-label", label);
1652
1659
  const t = toast.loading("Updating labels...");
1653
- execFileAsync2("gh", args, { encoding: "utf-8", timeout: 3e4 }).then(() => {
1660
+ updateLabelsAsync(repoName, issue.number, addLabels, removeLabels).then(() => {
1654
1661
  t.resolve(`Labels updated on #${issue.number}`);
1655
1662
  refresh();
1656
1663
  onOverlayDone();
@@ -1706,19 +1713,7 @@ ${dueLine}` : dueLine;
1706
1713
  const assignees = ctx.issue.assignees ?? [];
1707
1714
  if (!assignees.some((a) => a.login === configRef.current.board.assignee)) continue;
1708
1715
  try {
1709
- await execFileAsync2(
1710
- "gh",
1711
- [
1712
- "issue",
1713
- "edit",
1714
- String(ctx.issue.number),
1715
- "--repo",
1716
- ctx.repoName,
1717
- "--remove-assignee",
1718
- "@me"
1719
- ],
1720
- { encoding: "utf-8", timeout: 3e4 }
1721
- );
1716
+ await unassignIssueAsync(ctx.repoName, ctx.issue.number, "@me");
1722
1717
  } catch {
1723
1718
  failed.push(id);
1724
1719
  }
@@ -1783,7 +1778,6 @@ ${dueLine}` : dueLine;
1783
1778
  handleComment,
1784
1779
  handleStatusChange,
1785
1780
  handleAssign,
1786
- handleUnassign,
1787
1781
  handleLabelChange,
1788
1782
  handleCreateIssue,
1789
1783
  handleBulkAssign,
@@ -1791,15 +1785,13 @@ ${dueLine}` : dueLine;
1791
1785
  handleBulkStatusChange
1792
1786
  };
1793
1787
  }
1794
- var execFileAsync2, TERMINAL_STATUS_RE;
1795
1788
  var init_use_actions = __esm({
1796
1789
  "src/board/hooks/use-actions.ts"() {
1797
1790
  "use strict";
1798
1791
  init_github();
1799
1792
  init_pick();
1793
+ init_constants();
1800
1794
  init_use_action_log();
1801
- execFileAsync2 = promisify2(execFile2);
1802
- TERMINAL_STATUS_RE = /^(done|shipped|won't|wont|closed|complete|completed)$/i;
1803
1795
  }
1804
1796
  });
1805
1797
 
@@ -1860,7 +1852,8 @@ function useData(config2, options, refreshIntervalMs) {
1860
1852
  { workerData: { config: configRef.current, options: optionsRef.current } }
1861
1853
  );
1862
1854
  workerRef.current = worker;
1863
- worker.on("message", (msg) => {
1855
+ worker.on("message", (rawMsg) => {
1856
+ const msg = rawMsg;
1864
1857
  if (token.canceled) {
1865
1858
  worker.terminate();
1866
1859
  return;
@@ -1881,13 +1874,13 @@ function useData(config2, options, refreshIntervalMs) {
1881
1874
  consecutiveFailures: 0,
1882
1875
  autoRefreshPaused: false
1883
1876
  });
1884
- } else {
1877
+ } else if (msg.type === "error") {
1885
1878
  setState((prev) => {
1886
1879
  const failures = prev.consecutiveFailures + 1;
1887
1880
  return {
1888
1881
  ...prev,
1889
1882
  status: prev.data ? "success" : "error",
1890
- error: msg.error ?? "Unknown error",
1883
+ error: msg.error,
1891
1884
  isRefreshing: false,
1892
1885
  consecutiveFailures: failures,
1893
1886
  autoRefreshPaused: failures >= MAX_REFRESH_FAILURES
@@ -1998,9 +1991,6 @@ var init_use_data = __esm({
1998
1991
  // src/board/hooks/use-keyboard.ts
1999
1992
  import { useInput } from "ink";
2000
1993
  import { useCallback as useCallback4 } from "react";
2001
- function isHeaderId(id) {
2002
- return id != null && (id.startsWith("header:") || id.startsWith("sub:"));
2003
- }
2004
1994
  function useKeyboard({
2005
1995
  ui,
2006
1996
  nav,
@@ -2238,6 +2228,7 @@ function useKeyboard({
2238
2228
  var init_use_keyboard = __esm({
2239
2229
  "src/board/hooks/use-keyboard.ts"() {
2240
2230
  "use strict";
2231
+ init_constants();
2241
2232
  }
2242
2233
  });
2243
2234
 
@@ -2346,7 +2337,16 @@ function navReducer(state, action) {
2346
2337
  case "SET_ITEMS": {
2347
2338
  const sections = [...new Set(action.items.map((i) => i.section))];
2348
2339
  const isFirstLoad = state.sections.length === 0;
2349
- const collapsedSections = isFirstLoad ? new Set(sections.filter((s) => s === "activity")) : state.collapsedSections;
2340
+ let collapsedSections;
2341
+ if (isFirstLoad) {
2342
+ collapsedSections = new Set(sections.filter((s) => s === "activity"));
2343
+ } else {
2344
+ const validIds = /* @__PURE__ */ new Set([
2345
+ ...sections,
2346
+ ...action.items.filter((i) => i.type === "subHeader").map((i) => i.id)
2347
+ ]);
2348
+ collapsedSections = new Set([...state.collapsedSections].filter((id) => validIds.has(id)));
2349
+ }
2350
2350
  const selectionValid = state.selectedId != null && action.items.some((i) => i.id === state.selectedId);
2351
2351
  if (!isFirstLoad && selectionValid && arraysEqual(sections, state.sections)) {
2352
2352
  return state.allItems === action.items ? state : { ...state, allItems: action.items };
@@ -2579,19 +2579,6 @@ function useToast() {
2579
2579
  [addToast, removeToast]
2580
2580
  )
2581
2581
  };
2582
- const dismiss = useCallback7(
2583
- (id) => {
2584
- removeToast(id);
2585
- },
2586
- [removeToast]
2587
- );
2588
- const dismissAll = useCallback7(() => {
2589
- for (const timer of timersRef.current.values()) {
2590
- clearTimeout(timer);
2591
- }
2592
- timersRef.current.clear();
2593
- setToasts([]);
2594
- }, []);
2595
2582
  const handleErrorAction = useCallback7(
2596
2583
  (action) => {
2597
2584
  const errorToast = toasts.find((t) => t.type === "error");
@@ -2609,7 +2596,7 @@ function useToast() {
2609
2596
  },
2610
2597
  [toasts, removeToast]
2611
2598
  );
2612
- return { toasts, toast, dismiss, dismissAll, handleErrorAction };
2599
+ return { toasts, toast, handleErrorAction };
2613
2600
  }
2614
2601
  var MAX_VISIBLE, AUTO_DISMISS_MS, nextId;
2615
2602
  var init_use_toast = __esm({
@@ -3002,7 +2989,14 @@ var init_detail_panel = __esm({
3002
2989
  // src/board/components/hint-bar.tsx
3003
2990
  import { Box as Box3, Text as Text3 } from "ink";
3004
2991
  import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
3005
- function HintBar({ uiMode, multiSelectCount, searchQuery, mineOnly, hasUndoable }) {
2992
+ function HintBar({
2993
+ uiMode,
2994
+ multiSelectCount,
2995
+ searchQuery,
2996
+ mineOnly,
2997
+ hasUndoable,
2998
+ onHeader
2999
+ }) {
3006
3000
  if (uiMode === "multiSelect") {
3007
3001
  return /* @__PURE__ */ jsxs3(Box3, { children: [
3008
3002
  /* @__PURE__ */ jsxs3(Text3, { color: "cyan", bold: true, children: [
@@ -3033,6 +3027,17 @@ function HintBar({ uiMode, multiSelectCount, searchQuery, mineOnly, hasUndoable
3033
3027
  if (uiMode.startsWith("overlay:")) {
3034
3028
  return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "j/k:nav Enter:select Esc:cancel" }) });
3035
3029
  }
3030
+ if (onHeader) {
3031
+ return /* @__PURE__ */ jsxs3(Box3, { children: [
3032
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "j/k:nav Enter/Space:expand-collapse Tab:next-section C:collapse-all ?:more q:quit" }),
3033
+ mineOnly ? /* @__PURE__ */ jsx3(Text3, { color: "cyan", children: " filter:@me" }) : null,
3034
+ searchQuery ? /* @__PURE__ */ jsxs3(Text3, { color: "yellow", children: [
3035
+ ' filter:"',
3036
+ searchQuery,
3037
+ '"'
3038
+ ] }) : null
3039
+ ] });
3040
+ }
3036
3041
  return /* @__PURE__ */ jsxs3(Box3, { children: [
3037
3042
  /* @__PURE__ */ jsxs3(Text3, { color: "gray", children: [
3038
3043
  "j/k:nav Enter:open m:status c:comment F:find t:@me e:edit L:log",
@@ -3495,11 +3500,10 @@ var init_create_issue_form = __esm({
3495
3500
  });
3496
3501
 
3497
3502
  // src/board/components/edit-issue-overlay.tsx
3498
- import { execFile as execFile3, spawnSync as spawnSync2 } from "child_process";
3503
+ import { spawnSync as spawnSync2 } from "child_process";
3499
3504
  import { mkdtempSync as mkdtempSync2, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
3500
3505
  import { tmpdir as tmpdir2 } from "os";
3501
3506
  import { join as join5 } from "path";
3502
- import { promisify as promisify3 } from "util";
3503
3507
  import { Box as Box9, Text as Text9, useStdin as useStdin2 } from "ink";
3504
3508
  import { useEffect as useEffect6, useRef as useRef9, useState as useState10 } from "react";
3505
3509
  import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
@@ -3651,11 +3655,7 @@ function EditIssueOverlay({
3651
3655
  const changedFields = [];
3652
3656
  if (parsed.title !== issue.title) {
3653
3657
  try {
3654
- await execFileAsync3(
3655
- "gh",
3656
- ["issue", "edit", String(issue.number), "--repo", repoName, "--title", parsed.title],
3657
- { encoding: "utf-8", timeout: 3e4 }
3658
- );
3658
+ await editIssueTitleAsync(repoName, issue.number, parsed.title);
3659
3659
  changedFields.push("title");
3660
3660
  } catch {
3661
3661
  onToastError(`Failed to update title on #${issue.number}`);
@@ -3663,11 +3663,7 @@ function EditIssueOverlay({
3663
3663
  }
3664
3664
  if (parsed.body !== (issue.body ?? "").trim()) {
3665
3665
  try {
3666
- await execFileAsync3(
3667
- "gh",
3668
- ["issue", "edit", String(issue.number), "--repo", repoName, "--body", parsed.body],
3669
- { encoding: "utf-8", timeout: 3e4 }
3670
- );
3666
+ await editIssueBodyAsync(repoName, issue.number, parsed.body);
3671
3667
  changedFields.push("body");
3672
3668
  } catch {
3673
3669
  onToastError(`Failed to update body on #${issue.number}`);
@@ -3691,11 +3687,8 @@ function EditIssueOverlay({
3691
3687
  const addLabels = parsed.labels.filter((l) => !origLabels.includes(l));
3692
3688
  const removeLabels = origLabels.filter((l) => !parsed.labels.includes(l));
3693
3689
  if (addLabels.length > 0 || removeLabels.length > 0) {
3694
- const args = ["issue", "edit", String(issue.number), "--repo", repoName];
3695
- for (const l of addLabels) args.push("--add-label", l);
3696
- for (const l of removeLabels) args.push("--remove-label", l);
3697
3690
  try {
3698
- await execFileAsync3("gh", args, { encoding: "utf-8", timeout: 3e4 });
3691
+ await updateLabelsAsync(repoName, issue.number, addLabels, removeLabels);
3699
3692
  changedFields.push("labels");
3700
3693
  } catch {
3701
3694
  onToastError(`Failed to update labels on #${issue.number}`);
@@ -3704,34 +3697,10 @@ function EditIssueOverlay({
3704
3697
  if (parsed.assignee !== origAssignee) {
3705
3698
  try {
3706
3699
  if (parsed.assignee) {
3707
- await execFileAsync3(
3708
- "gh",
3709
- [
3710
- "issue",
3711
- "edit",
3712
- String(issue.number),
3713
- "--repo",
3714
- repoName,
3715
- "--add-assignee",
3716
- parsed.assignee
3717
- ],
3718
- { encoding: "utf-8", timeout: 3e4 }
3719
- );
3700
+ await assignIssueToAsync(repoName, issue.number, parsed.assignee);
3720
3701
  }
3721
3702
  if (origAssignee) {
3722
- await execFileAsync3(
3723
- "gh",
3724
- [
3725
- "issue",
3726
- "edit",
3727
- String(issue.number),
3728
- "--repo",
3729
- repoName,
3730
- "--remove-assignee",
3731
- origAssignee
3732
- ],
3733
- { encoding: "utf-8", timeout: 3e4 }
3734
- );
3703
+ await unassignIssueAsync(repoName, issue.number, origAssignee);
3735
3704
  }
3736
3705
  changedFields.push("assignee");
3737
3706
  } catch {
@@ -3785,14 +3754,12 @@ function EditIssueOverlay({
3785
3754
  "\u2026"
3786
3755
  ] }) });
3787
3756
  }
3788
- var execFileAsync3;
3789
3757
  var init_edit_issue_overlay = __esm({
3790
3758
  "src/board/components/edit-issue-overlay.tsx"() {
3791
3759
  "use strict";
3792
3760
  init_github();
3793
3761
  init_use_action_log();
3794
3762
  init_ink_instance();
3795
- execFileAsync3 = promisify3(execFile3);
3796
3763
  }
3797
3764
  });
3798
3765
 
@@ -4439,7 +4406,7 @@ import { Box as Box15, Text as Text15, useInput as useInput11 } from "ink";
4439
4406
  import { useRef as useRef12, useState as useState14 } from "react";
4440
4407
  import { jsx as jsx15, jsxs as jsxs15 } from "react/jsx-runtime";
4441
4408
  function isTerminal(name) {
4442
- return TERMINAL_STATUS_RE2.test(name);
4409
+ return TERMINAL_STATUS_RE.test(name);
4443
4410
  }
4444
4411
  function handlePickerInput(input2, key, state) {
4445
4412
  if (key.escape) {
@@ -4548,11 +4515,10 @@ function StatusPicker({
4548
4515
  /* @__PURE__ */ jsx15(Text15, { dimColor: true, children: "j/k:navigate Enter:select Esc:cancel" })
4549
4516
  ] });
4550
4517
  }
4551
- var TERMINAL_STATUS_RE2;
4552
4518
  var init_status_picker = __esm({
4553
4519
  "src/board/components/status-picker.tsx"() {
4554
4520
  "use strict";
4555
- TERMINAL_STATUS_RE2 = /^(done|shipped|won't|wont|closed|complete|completed)$/i;
4521
+ init_constants();
4556
4522
  }
4557
4523
  });
4558
4524
 
@@ -4737,7 +4703,7 @@ function formatTargetDate(dateStr) {
4737
4703
  color: "gray"
4738
4704
  };
4739
4705
  }
4740
- function timeAgo(dateStr) {
4706
+ function timeAgo2(dateStr) {
4741
4707
  const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1e3);
4742
4708
  if (seconds < 60) return "now";
4743
4709
  const minutes = Math.floor(seconds / 60);
@@ -4781,7 +4747,7 @@ function IssueRow({ issue, selfLogin, isSelected }) {
4781
4747
  /* @__PURE__ */ jsx17(Text16, { children: " " }),
4782
4748
  /* @__PURE__ */ jsx17(Text16, { color: assigneeColor, children: assigneeText.padEnd(14) }),
4783
4749
  /* @__PURE__ */ jsx17(Text16, { children: " " }),
4784
- /* @__PURE__ */ jsx17(Text16, { color: "gray", children: timeAgo(issue.updatedAt).padStart(4) }),
4750
+ /* @__PURE__ */ jsx17(Text16, { color: "gray", children: timeAgo2(issue.updatedAt).padStart(4) }),
4785
4751
  target.text ? /* @__PURE__ */ jsxs17(Fragment3, { children: [
4786
4752
  /* @__PURE__ */ jsx17(Text16, { children: " " }),
4787
4753
  /* @__PURE__ */ jsx17(Text16, { color: target.color, children: target.text })
@@ -4916,7 +4882,7 @@ function RowRenderer({ row, selectedId, selfLogin, isMultiSelected }) {
4916
4882
  ] });
4917
4883
  }
4918
4884
  case "activity": {
4919
- const ago = timeAgo2(row.event.timestamp);
4885
+ const ago = timeAgo(row.event.timestamp);
4920
4886
  return /* @__PURE__ */ jsxs19(Text18, { dimColor: true, children: [
4921
4887
  " ",
4922
4888
  ago,
@@ -4944,16 +4910,10 @@ function RowRenderer({ row, selectedId, selfLogin, isMultiSelected }) {
4944
4910
  return /* @__PURE__ */ jsx19(Text18, { children: "" });
4945
4911
  }
4946
4912
  }
4947
- function timeAgo2(date) {
4948
- const seconds = Math.floor((Date.now() - date.getTime()) / 1e3);
4949
- if (seconds < 10) return "just now";
4950
- if (seconds < 60) return `${seconds}s ago`;
4951
- const minutes = Math.floor(seconds / 60);
4952
- return `${minutes}m ago`;
4953
- }
4954
4913
  var init_row_renderer = __esm({
4955
4914
  "src/board/components/row-renderer.tsx"() {
4956
4915
  "use strict";
4916
+ init_constants();
4957
4917
  init_issue_row();
4958
4918
  init_task_row();
4959
4919
  }
@@ -5002,9 +4962,6 @@ import { Spinner as Spinner4 } from "@inkjs/ui";
5002
4962
  import { Box as Box20, Text as Text20, useApp, useStdout } from "ink";
5003
4963
  import { useCallback as useCallback11, useEffect as useEffect9, useMemo as useMemo3, useRef as useRef13, useState as useState15 } from "react";
5004
4964
  import { Fragment as Fragment5, jsx as jsx21, jsxs as jsxs21 } from "react/jsx-runtime";
5005
- function isTerminalStatus(status) {
5006
- return TERMINAL_STATUS_RE3.test(status);
5007
- }
5008
4965
  function resolveStatusGroups(statusOptions, configuredGroups) {
5009
4966
  if (configuredGroups && configuredGroups.length > 0) {
5010
4967
  return configuredGroups.map((entry) => {
@@ -5042,128 +4999,121 @@ function groupByStatus(issues) {
5042
4999
  }
5043
5000
  return groups;
5044
5001
  }
5045
- function collectGroupIssues(statusGroup, byStatus) {
5046
- const issues = [];
5047
- for (const status of statusGroup.statuses) {
5048
- const list = byStatus.get(status);
5049
- if (list) issues.push(...list);
5050
- }
5051
- issues.sort((a, b) => issuePriorityRank(a) - issuePriorityRank(b));
5052
- return issues;
5053
- }
5054
- function buildNavItems(repos, tasks, activityCount) {
5055
- const items = [];
5056
- if (activityCount > 0) {
5057
- items.push({ id: "header:activity", section: "activity", type: "header" });
5058
- }
5059
- for (const rd of repos) {
5060
- items.push({ id: `header:${rd.repo.shortName}`, section: rd.repo.shortName, type: "header" });
5002
+ function buildBoardTree(repos, tasks, activity) {
5003
+ const sections = repos.map((rd) => {
5004
+ const sectionId = rd.repo.name;
5005
+ if (rd.error) {
5006
+ return { repo: rd.repo, sectionId, groups: [], error: rd.error };
5007
+ }
5061
5008
  const statusGroupDefs = resolveStatusGroups(rd.statusOptions, rd.repo.statusGroups);
5062
5009
  const byStatus = groupByStatus(rd.issues);
5063
- const coveredStatuses = /* @__PURE__ */ new Set();
5010
+ const coveredKeys = /* @__PURE__ */ new Set();
5011
+ const groups = [];
5064
5012
  for (const sg of statusGroupDefs) {
5065
- const groupIssues = collectGroupIssues(sg, byStatus);
5066
- if (groupIssues.length === 0) continue;
5067
- const subId = `sub:${rd.repo.shortName}:${sg.label}`;
5068
- items.push({ id: subId, section: rd.repo.shortName, type: "subHeader" });
5069
- for (const issue of groupIssues) {
5013
+ const issues = [];
5014
+ for (const [status, statusIssues] of byStatus) {
5015
+ if (sg.statuses.some((s) => s.toLowerCase().trim() === status.toLowerCase().trim())) {
5016
+ issues.push(...statusIssues);
5017
+ }
5018
+ }
5019
+ if (issues.length === 0) continue;
5020
+ issues.sort((a, b) => issuePriorityRank(a) - issuePriorityRank(b));
5021
+ groups.push({ label: sg.label, subId: `sub:${sectionId}:${sg.label}`, issues });
5022
+ for (const s of sg.statuses) coveredKeys.add(s.toLowerCase().trim());
5023
+ }
5024
+ for (const [status, statusIssues] of byStatus) {
5025
+ if (!(coveredKeys.has(status.toLowerCase().trim()) || isTerminalStatus(status))) {
5026
+ groups.push({ label: status, subId: `sub:${sectionId}:${status}`, issues: statusIssues });
5027
+ }
5028
+ }
5029
+ return { repo: rd.repo, sectionId, groups, error: null };
5030
+ });
5031
+ return { activity, sections, tasks };
5032
+ }
5033
+ function buildNavItems(tree) {
5034
+ const items = [];
5035
+ if (tree.activity.length > 0)
5036
+ items.push({ id: "header:activity", section: "activity", type: "header" });
5037
+ for (const { repo, sectionId, groups } of tree.sections) {
5038
+ items.push({ id: `header:${sectionId}`, section: sectionId, type: "header" });
5039
+ for (const group of groups) {
5040
+ items.push({ id: group.subId, section: sectionId, type: "subHeader" });
5041
+ for (const issue of group.issues) {
5070
5042
  items.push({
5071
- id: `gh:${rd.repo.name}:${issue.number}`,
5072
- section: rd.repo.shortName,
5043
+ id: `gh:${repo.name}:${issue.number}`,
5044
+ section: sectionId,
5073
5045
  type: "item",
5074
- subSection: subId
5046
+ subSection: group.subId
5075
5047
  });
5076
5048
  }
5077
- for (const s of sg.statuses) coveredStatuses.add(s);
5078
- }
5079
- for (const [status, issues] of byStatus) {
5080
- if (!(coveredStatuses.has(status) || isTerminalStatus(status)) && issues.length > 0) {
5081
- const subId = `sub:${rd.repo.shortName}:${status}`;
5082
- items.push({ id: subId, section: rd.repo.shortName, type: "subHeader" });
5083
- for (const issue of issues) {
5084
- items.push({
5085
- id: `gh:${rd.repo.name}:${issue.number}`,
5086
- section: rd.repo.shortName,
5087
- type: "item",
5088
- subSection: subId
5089
- });
5090
- }
5091
- }
5092
5049
  }
5093
5050
  }
5094
- if (tasks.length > 0) {
5051
+ if (tree.tasks.length > 0) {
5095
5052
  items.push({ id: "header:ticktick", section: "ticktick", type: "header" });
5096
- for (const task2 of tasks) {
5053
+ for (const task2 of tree.tasks)
5097
5054
  items.push({ id: `tt:${task2.id}`, section: "ticktick", type: "item" });
5098
- }
5099
5055
  }
5100
5056
  return items;
5101
5057
  }
5102
- function buildFlatRows(repos, tasks, activity, isCollapsed) {
5058
+ function buildFlatRows(tree, isCollapsed) {
5103
5059
  const rows = [];
5104
- if (activity.length > 0) {
5060
+ if (tree.activity.length > 0) {
5105
5061
  const collapsed = isCollapsed("activity");
5106
5062
  rows.push({
5107
5063
  type: "sectionHeader",
5108
5064
  key: "header:activity",
5109
5065
  navId: "header:activity",
5110
5066
  label: "Recent Activity (24h)",
5111
- count: activity.length,
5067
+ count: tree.activity.length,
5112
5068
  countLabel: "events",
5113
5069
  isCollapsed: collapsed
5114
5070
  });
5115
5071
  if (!collapsed) {
5116
- for (const [i, event] of activity.entries()) {
5072
+ for (const [i, event] of tree.activity.entries()) {
5117
5073
  rows.push({ type: "activity", key: `act:${i}`, navId: null, event });
5118
5074
  }
5119
5075
  }
5120
5076
  }
5121
- for (const rd of repos) {
5122
- const { repo, issues, error: repoError } = rd;
5123
- const collapsed = isCollapsed(repo.shortName);
5077
+ for (const { repo, sectionId, groups, error } of tree.sections) {
5078
+ const collapsed = isCollapsed(sectionId);
5079
+ const totalIssues = groups.reduce((s, g) => s + g.issues.length, 0);
5124
5080
  rows.push({
5125
5081
  type: "sectionHeader",
5126
- key: `header:${repo.shortName}`,
5127
- navId: `header:${repo.shortName}`,
5082
+ key: `header:${sectionId}`,
5083
+ navId: `header:${sectionId}`,
5128
5084
  label: repo.shortName,
5129
- count: issues.length,
5085
+ // display label still shows shortName
5086
+ count: totalIssues,
5130
5087
  countLabel: "issues",
5131
5088
  isCollapsed: collapsed
5132
5089
  });
5133
5090
  if (!collapsed) {
5134
- if (repoError) {
5135
- rows.push({ type: "error", key: `error:${repo.shortName}`, navId: null, text: repoError });
5136
- } else if (issues.length === 0) {
5091
+ if (error) {
5092
+ rows.push({ type: "error", key: `error:${sectionId}`, navId: null, text: error });
5093
+ } else if (groups.length === 0) {
5137
5094
  rows.push({
5138
5095
  type: "subHeader",
5139
- key: `empty:${repo.shortName}`,
5096
+ key: `empty:${sectionId}`,
5140
5097
  navId: null,
5141
5098
  text: "No open issues"
5142
5099
  });
5143
5100
  } else {
5144
- const statusGroupDefs = resolveStatusGroups(rd.statusOptions, rd.repo.statusGroups);
5145
- const byStatus = groupByStatus(issues);
5146
- const coveredStatuses = /* @__PURE__ */ new Set();
5147
5101
  let isFirstGroup = true;
5148
- for (const sg of statusGroupDefs) {
5149
- const groupIssues = collectGroupIssues(sg, byStatus);
5150
- if (groupIssues.length === 0) continue;
5151
- if (!isFirstGroup) {
5152
- rows.push({ type: "gap", key: `gap:${repo.shortName}:${sg.label}`, navId: null });
5153
- }
5102
+ for (const group of groups) {
5103
+ if (!isFirstGroup)
5104
+ rows.push({ type: "gap", key: `gap:${sectionId}:${group.label}`, navId: null });
5154
5105
  isFirstGroup = false;
5155
- const subId = `sub:${repo.shortName}:${sg.label}`;
5156
- const subCollapsed = isCollapsed(subId);
5106
+ const subCollapsed = isCollapsed(group.subId);
5157
5107
  rows.push({
5158
5108
  type: "subHeader",
5159
- key: subId,
5160
- navId: subId,
5161
- text: sg.label,
5162
- count: groupIssues.length,
5109
+ key: group.subId,
5110
+ navId: group.subId,
5111
+ text: group.label,
5112
+ count: group.issues.length,
5163
5113
  isCollapsed: subCollapsed
5164
5114
  });
5165
5115
  if (!subCollapsed) {
5166
- for (const issue of groupIssues) {
5116
+ for (const issue of group.issues) {
5167
5117
  rows.push({
5168
5118
  type: "issue",
5169
5119
  key: `gh:${repo.name}:${issue.number}`,
@@ -5173,81 +5123,35 @@ function buildFlatRows(repos, tasks, activity, isCollapsed) {
5173
5123
  });
5174
5124
  }
5175
5125
  }
5176
- for (const s of sg.statuses) coveredStatuses.add(s);
5177
- }
5178
- for (const [status, groupIssues] of byStatus) {
5179
- if (!(coveredStatuses.has(status) || isTerminalStatus(status)) && groupIssues.length > 0) {
5180
- if (!isFirstGroup) {
5181
- rows.push({ type: "gap", key: `gap:${repo.shortName}:${status}`, navId: null });
5182
- }
5183
- isFirstGroup = false;
5184
- const subId = `sub:${repo.shortName}:${status}`;
5185
- const subCollapsed = isCollapsed(subId);
5186
- rows.push({
5187
- type: "subHeader",
5188
- key: subId,
5189
- navId: subId,
5190
- text: status,
5191
- count: groupIssues.length,
5192
- isCollapsed: subCollapsed
5193
- });
5194
- if (!subCollapsed) {
5195
- for (const issue of groupIssues) {
5196
- rows.push({
5197
- type: "issue",
5198
- key: `gh:${repo.name}:${issue.number}`,
5199
- navId: `gh:${repo.name}:${issue.number}`,
5200
- issue,
5201
- repoName: repo.name
5202
- });
5203
- }
5204
- }
5205
- }
5206
5126
  }
5207
5127
  }
5208
5128
  }
5209
5129
  }
5210
- if (tasks.length > 0) {
5130
+ if (tree.tasks.length > 0) {
5211
5131
  const collapsed = isCollapsed("ticktick");
5212
5132
  rows.push({
5213
5133
  type: "sectionHeader",
5214
5134
  key: "header:ticktick",
5215
5135
  navId: "header:ticktick",
5216
5136
  label: "Personal (TickTick)",
5217
- count: tasks.length,
5137
+ count: tree.tasks.length,
5218
5138
  countLabel: "tasks",
5219
5139
  isCollapsed: collapsed
5220
5140
  });
5221
5141
  if (!collapsed) {
5222
- for (const task2 of tasks) {
5142
+ for (const task2 of tree.tasks)
5223
5143
  rows.push({ type: "task", key: `tt:${task2.id}`, navId: `tt:${task2.id}`, task: task2 });
5224
- }
5225
5144
  }
5226
5145
  }
5227
5146
  return rows;
5228
5147
  }
5229
- function timeAgo3(date) {
5230
- const seconds = Math.floor((Date.now() - date.getTime()) / 1e3);
5231
- if (seconds < 10) return "just now";
5232
- if (seconds < 60) return `${seconds}s ago`;
5233
- const minutes = Math.floor(seconds / 60);
5234
- return `${minutes}m ago`;
5235
- }
5236
5148
  function openInBrowser(url) {
5149
+ if (!(url.startsWith("https://") || url.startsWith("http://"))) return;
5237
5150
  try {
5238
5151
  execFileSync3("open", [url], { stdio: "ignore" });
5239
5152
  } catch {
5240
5153
  }
5241
5154
  }
5242
- function findSelectedUrl(repos, selectedId) {
5243
- if (!selectedId?.startsWith("gh:")) return null;
5244
- for (const rd of repos) {
5245
- for (const issue of rd.issues) {
5246
- if (`gh:${rd.repo.name}:${issue.number}` === selectedId) return issue.url;
5247
- }
5248
- }
5249
- return null;
5250
- }
5251
5155
  function findSelectedIssueWithRepo(repos, selectedId) {
5252
5156
  if (!selectedId?.startsWith("gh:")) return null;
5253
5157
  for (const rd of repos) {
@@ -5258,8 +5162,17 @@ function findSelectedIssueWithRepo(repos, selectedId) {
5258
5162
  }
5259
5163
  return null;
5260
5164
  }
5261
- function isHeaderId2(id) {
5262
- return id != null && (id.startsWith("header:") || id.startsWith("sub:"));
5165
+ function RefreshAge({ lastRefresh }) {
5166
+ const [, setTick] = useState15(0);
5167
+ useEffect9(() => {
5168
+ const id = setInterval(() => setTick((t) => t + 1), 1e4);
5169
+ return () => clearInterval(id);
5170
+ }, []);
5171
+ if (!lastRefresh) return null;
5172
+ return /* @__PURE__ */ jsxs21(Text20, { color: refreshAgeColor(lastRefresh), children: [
5173
+ "Updated ",
5174
+ timeAgo(lastRefresh)
5175
+ ] });
5263
5176
  }
5264
5177
  function Dashboard({ config: config2, options, activeProfile }) {
5265
5178
  const { exit } = useApp();
@@ -5298,11 +5211,11 @@ function Dashboard({ config: config2, options, activeProfile }) {
5298
5211
  const last = logEntries[logEntries.length - 1];
5299
5212
  if (last?.status === "error") setLogVisible(true);
5300
5213
  }, [logEntries]);
5301
- const [, setTick] = useState15(0);
5302
5214
  useEffect9(() => {
5303
- const id = setInterval(() => setTick((t) => t + 1), 1e4);
5304
- return () => clearInterval(id);
5305
- }, []);
5215
+ if (data?.ticktickError) {
5216
+ toast.error(`TickTick sync failed: ${data.ticktickError}`);
5217
+ }
5218
+ }, [data?.ticktickError, toast.error]);
5306
5219
  const repos = useMemo3(() => {
5307
5220
  let filtered = allRepos;
5308
5221
  if (mineOnly) {
@@ -5321,10 +5234,11 @@ function Dashboard({ config: config2, options, activeProfile }) {
5321
5234
  const q = searchQuery.toLowerCase();
5322
5235
  return allTasks.filter((t) => t.title.toLowerCase().includes(q));
5323
5236
  }, [allTasks, searchQuery]);
5324
- const navItems = useMemo3(
5325
- () => buildNavItems(repos, tasks, allActivity.length),
5326
- [repos, tasks, allActivity.length]
5237
+ const boardTree = useMemo3(
5238
+ () => buildBoardTree(repos, tasks, allActivity),
5239
+ [repos, tasks, allActivity]
5327
5240
  );
5241
+ const navItems = useMemo3(() => buildNavItems(boardTree), [boardTree]);
5328
5242
  const nav = useNavigation(navItems);
5329
5243
  const getRepoForId = useCallback11((id) => {
5330
5244
  if (id.startsWith("gh:")) {
@@ -5405,7 +5319,7 @@ function Dashboard({ config: config2, options, activeProfile }) {
5405
5319
  const [focusLabel, setFocusLabel] = useState15(null);
5406
5320
  const handleEnterFocus = useCallback11(() => {
5407
5321
  const id = nav.selectedId;
5408
- if (!id || isHeaderId2(id)) return;
5322
+ if (!id || isHeaderId(id)) return;
5409
5323
  let label = "";
5410
5324
  if (id.startsWith("gh:")) {
5411
5325
  const found = findSelectedIssueWithRepo(repos, id);
@@ -5476,11 +5390,14 @@ function Dashboard({ config: config2, options, activeProfile }) {
5476
5390
  termSize.rows - CHROME_ROWS - overlayBarRows - toastRows - logPaneRows
5477
5391
  );
5478
5392
  const flatRows = useMemo3(
5479
- () => buildFlatRows(repos, tasks, allActivity, nav.isCollapsed),
5480
- [repos, tasks, allActivity, nav.isCollapsed]
5393
+ () => buildFlatRows(boardTree, nav.isCollapsed),
5394
+ [boardTree, nav.isCollapsed]
5481
5395
  );
5482
5396
  const scrollRef = useRef13(0);
5483
- const selectedRowIdx = flatRows.findIndex((r) => r.navId === nav.selectedId);
5397
+ const selectedRowIdx = useMemo3(
5398
+ () => flatRows.findIndex((r) => r.navId === nav.selectedId),
5399
+ [flatRows, nav.selectedId]
5400
+ );
5484
5401
  if (selectedRowIdx >= 0) {
5485
5402
  if (selectedRowIdx < scrollRef.current) {
5486
5403
  scrollRef.current = selectedRowIdx;
@@ -5497,7 +5414,7 @@ function Dashboard({ config: config2, options, activeProfile }) {
5497
5414
  const belowCount = flatRows.length - scrollRef.current - viewportHeight;
5498
5415
  const selectedItem = useMemo3(() => {
5499
5416
  const id = nav.selectedId;
5500
- if (!id || isHeaderId2(id)) return { issue: null, task: null, repoName: null };
5417
+ if (!id || isHeaderId(id)) return { issue: null, task: null, repoName: null };
5501
5418
  if (id.startsWith("gh:")) {
5502
5419
  for (const rd of repos) {
5503
5420
  for (const issue of rd.issues) {
@@ -5528,8 +5445,8 @@ function Dashboard({ config: config2, options, activeProfile }) {
5528
5445
  return rd?.statusOptions ?? [];
5529
5446
  }, [selectedItem.repoName, repos, multiSelect.count, multiSelect.constrainedRepo]);
5530
5447
  const handleOpen = useCallback11(() => {
5531
- const url = findSelectedUrl(repos, nav.selectedId);
5532
- if (url) openInBrowser(url);
5448
+ const found = findSelectedIssueWithRepo(repos, nav.selectedId);
5449
+ if (found) openInBrowser(found.issue.url);
5533
5450
  }, [repos, nav.selectedId]);
5534
5451
  const handleSlack = useCallback11(() => {
5535
5452
  const found = findSelectedIssueWithRepo(repos, nav.selectedId);
@@ -5697,13 +5614,10 @@ function Dashboard({ config: config2, options, activeProfile }) {
5697
5614
  isRefreshing ? /* @__PURE__ */ jsxs21(Fragment5, { children: [
5698
5615
  /* @__PURE__ */ jsx21(Spinner4, { label: "" }),
5699
5616
  /* @__PURE__ */ jsx21(Text20, { color: "cyan", children: " Refreshing..." })
5700
- ] }) : lastRefresh ? /* @__PURE__ */ jsxs21(Fragment5, { children: [
5701
- /* @__PURE__ */ jsxs21(Text20, { color: refreshAgeColor(lastRefresh), children: [
5702
- "Updated ",
5703
- timeAgo3(lastRefresh)
5704
- ] }),
5617
+ ] }) : /* @__PURE__ */ jsxs21(Fragment5, { children: [
5618
+ /* @__PURE__ */ jsx21(RefreshAge, { lastRefresh }),
5705
5619
  consecutiveFailures > 0 ? /* @__PURE__ */ jsx21(Text20, { color: "red", children: " (!)" }) : null
5706
- ] }) : null,
5620
+ ] }),
5707
5621
  autoRefreshPaused ? /* @__PURE__ */ jsx21(Text20, { color: "yellow", children: " Auto-refresh paused \u2014 press r to retry" }) : null
5708
5622
  ] }),
5709
5623
  error ? /* @__PURE__ */ jsxs21(Text20, { color: "red", children: [
@@ -5800,17 +5714,19 @@ function Dashboard({ config: config2, options, activeProfile }) {
5800
5714
  multiSelectCount: multiSelect.count,
5801
5715
  searchQuery,
5802
5716
  mineOnly,
5803
- hasUndoable
5717
+ hasUndoable,
5718
+ onHeader: isHeaderId(nav.selectedId)
5804
5719
  }
5805
5720
  )
5806
5721
  ] });
5807
5722
  }
5808
- var TERMINAL_STATUS_RE3, PRIORITY_RANK, CHROME_ROWS;
5723
+ var PRIORITY_RANK, CHROME_ROWS;
5809
5724
  var init_dashboard = __esm({
5810
5725
  "src/board/components/dashboard.tsx"() {
5811
5726
  "use strict";
5812
5727
  init_clipboard();
5813
5728
  init_github();
5729
+ init_constants();
5814
5730
  init_use_action_log();
5815
5731
  init_use_actions();
5816
5732
  init_use_data();
@@ -5825,7 +5741,6 @@ var init_dashboard = __esm({
5825
5741
  init_overlay_renderer();
5826
5742
  init_row_renderer();
5827
5743
  init_toast_container();
5828
- TERMINAL_STATUS_RE3 = /^(done|shipped|won't|wont|closed|complete|completed)$/i;
5829
5744
  PRIORITY_RANK = {
5830
5745
  "priority:critical": 0,
5831
5746
  "priority:high": 1,
@@ -5872,9 +5787,6 @@ function extractSlackUrl(body) {
5872
5787
  const match = body.match(SLACK_URL_RE2);
5873
5788
  return match?.[0];
5874
5789
  }
5875
- function formatError2(err) {
5876
- return err instanceof Error ? err.message : String(err);
5877
- }
5878
5790
  function fetchRecentActivity(repoName, shortName) {
5879
5791
  try {
5880
5792
  const output = execFileSync4(
@@ -5954,28 +5866,34 @@ async function fetchDashboard(config2, options = {}) {
5954
5866
  fetchOpts.assignee = config2.board.assignee;
5955
5867
  }
5956
5868
  const issues = fetchRepoIssues(repo.name, fetchOpts);
5869
+ let enrichedIssues = issues;
5957
5870
  let statusOptions = [];
5958
5871
  try {
5959
5872
  const enrichMap = fetchProjectEnrichment(repo.name, repo.projectNumber);
5960
- for (const issue of issues) {
5873
+ enrichedIssues = issues.map((issue) => {
5961
5874
  const e = enrichMap.get(issue.number);
5962
- if (e?.targetDate) issue.targetDate = e.targetDate;
5963
- if (e?.projectStatus) issue.projectStatus = e.projectStatus;
5964
- }
5875
+ const slackUrl = extractSlackUrl(issue.body ?? "");
5876
+ return {
5877
+ ...issue,
5878
+ ...e?.targetDate !== void 0 ? { targetDate: e.targetDate } : {},
5879
+ ...e?.projectStatus !== void 0 ? { projectStatus: e.projectStatus } : {},
5880
+ ...slackUrl ? { slackThreadUrl: slackUrl } : {}
5881
+ };
5882
+ });
5965
5883
  statusOptions = fetchProjectStatusOptions(
5966
5884
  repo.name,
5967
5885
  repo.projectNumber,
5968
5886
  repo.statusFieldId
5969
5887
  );
5970
5888
  } catch {
5889
+ enrichedIssues = issues.map((issue) => {
5890
+ const slackUrl = extractSlackUrl(issue.body ?? "");
5891
+ return slackUrl ? { ...issue, slackThreadUrl: slackUrl } : issue;
5892
+ });
5971
5893
  }
5972
- for (const issue of issues) {
5973
- const slackUrl = extractSlackUrl(issue.body);
5974
- if (slackUrl) issue.slackThreadUrl = slackUrl;
5975
- }
5976
- return { repo, issues, statusOptions, error: null };
5894
+ return { repo, issues: enrichedIssues, statusOptions, error: null };
5977
5895
  } catch (err) {
5978
- return { repo, issues: [], statusOptions: [], error: formatError2(err) };
5896
+ return { repo, issues: [], statusOptions: [], error: formatError(err) };
5979
5897
  }
5980
5898
  });
5981
5899
  let ticktick = [];
@@ -5990,7 +5908,7 @@ async function fetchDashboard(config2, options = {}) {
5990
5908
  ticktick = tasks.filter((t) => t.status !== 2 /* Completed */);
5991
5909
  }
5992
5910
  } catch (err) {
5993
- ticktickError = formatError2(err);
5911
+ ticktickError = formatError(err);
5994
5912
  }
5995
5913
  }
5996
5914
  const activity = [];
@@ -6015,6 +5933,7 @@ var init_fetch = __esm({
6015
5933
  init_config();
6016
5934
  init_github();
6017
5935
  init_types();
5936
+ init_constants();
6018
5937
  SLACK_URL_RE2 = /https:\/\/[^/]+\.slack\.com\/archives\/[A-Z0-9]+\/p[0-9]+/i;
6019
5938
  }
6020
5939
  });
@@ -6193,7 +6112,9 @@ function renderBoardJson(data, selfLogin) {
6193
6112
  labels: i.labels.map((l) => l.name),
6194
6113
  updatedAt: i.updatedAt,
6195
6114
  isMine: (i.assignees ?? []).some((a) => a.login === selfLogin),
6196
- slackThreadUrl: i.slackThreadUrl ?? null
6115
+ slackThreadUrl: i.slackThreadUrl ?? null,
6116
+ projectStatus: i.projectStatus ?? null,
6117
+ targetDate: i.targetDate ?? null
6197
6118
  }))
6198
6119
  })),
6199
6120
  ticktick: {
@@ -6206,6 +6127,7 @@ function renderBoardJson(data, selfLogin) {
6206
6127
  tags: t.tags
6207
6128
  }))
6208
6129
  },
6130
+ activity: data.activity,
6209
6131
  fetchedAt: data.fetchedAt.toISOString()
6210
6132
  }
6211
6133
  };
@@ -6224,8 +6146,8 @@ var init_format_static = __esm({
6224
6146
  init_ai();
6225
6147
  init_api();
6226
6148
  init_config();
6227
- import { execFile as execFile4, execFileSync as execFileSync5 } from "child_process";
6228
- import { promisify as promisify4 } from "util";
6149
+ import { execFile as execFile2, execFileSync as execFileSync5 } from "child_process";
6150
+ import { promisify as promisify2 } from "util";
6229
6151
  import { Command } from "commander";
6230
6152
 
6231
6153
  // src/init.ts
@@ -6710,6 +6632,7 @@ function printSyncStatus(state, repos) {
6710
6632
 
6711
6633
  // src/sync.ts
6712
6634
  init_api();
6635
+ init_constants();
6713
6636
  init_config();
6714
6637
  init_github();
6715
6638
  init_sync_state();
@@ -6717,9 +6640,6 @@ init_types();
6717
6640
  function emptySyncResult() {
6718
6641
  return { created: [], updated: [], completed: [], ghUpdated: [], errors: [] };
6719
6642
  }
6720
- function formatError(err) {
6721
- return err instanceof Error ? err.message : String(err);
6722
- }
6723
6643
  function repoShortName(repo) {
6724
6644
  return repo.split("/")[1] ?? repo;
6725
6645
  }
@@ -6778,23 +6698,29 @@ async function syncGitHubToTickTick(config2, state, api, result, dryRun) {
6778
6698
  failedRepos.add(repoConfig.name);
6779
6699
  continue;
6780
6700
  }
6701
+ let enrichMap;
6702
+ try {
6703
+ enrichMap = fetchProjectEnrichment(repoConfig.name, repoConfig.projectNumber);
6704
+ } catch {
6705
+ enrichMap = /* @__PURE__ */ new Map();
6706
+ }
6781
6707
  for (const issue of issues) {
6782
6708
  const key = `${repoConfig.name}#${issue.number}`;
6783
6709
  openIssueKeys.add(key);
6784
- await syncSingleIssue(state, api, result, dryRun, repoConfig, issue, key);
6710
+ await syncSingleIssue(state, api, result, dryRun, repoConfig, issue, key, enrichMap);
6785
6711
  }
6786
6712
  }
6787
6713
  return { openIssueKeys, failedRepos };
6788
6714
  }
6789
- async function syncSingleIssue(state, api, result, dryRun, repoConfig, issue, key) {
6715
+ async function syncSingleIssue(state, api, result, dryRun, repoConfig, issue, key, enrichMap) {
6790
6716
  try {
6791
6717
  const existing = findMapping(state, repoConfig.name, issue.number);
6792
6718
  if (existing && existing.githubUpdatedAt === issue.updatedAt) return;
6793
- const projectFields = fetchProjectFields(
6794
- repoConfig.name,
6795
- issue.number,
6796
- repoConfig.projectNumber
6797
- );
6719
+ const enrichment = enrichMap.get(issue.number);
6720
+ const projectFields = {
6721
+ ...enrichment?.targetDate !== void 0 && { targetDate: enrichment.targetDate },
6722
+ ...enrichment?.projectStatus !== void 0 && { status: enrichment.projectStatus }
6723
+ };
6798
6724
  if (!existing) {
6799
6725
  await createTickTickTask(
6800
6726
  state,
@@ -6953,7 +6879,7 @@ if (major < 22) {
6953
6879
  );
6954
6880
  process.exit(1);
6955
6881
  }
6956
- var execFileAsync4 = promisify4(execFile4);
6882
+ var execFileAsync2 = promisify2(execFile2);
6957
6883
  async function resolveRef(ref, config2) {
6958
6884
  const { parseIssueRef: parseIssueRef2 } = await Promise.resolve().then(() => (init_pick(), pick_exports));
6959
6885
  try {
@@ -6981,8 +6907,7 @@ var PRIORITY_MAP = {
6981
6907
  function parsePriority(value) {
6982
6908
  const p = PRIORITY_MAP[value.toLowerCase()];
6983
6909
  if (p === void 0) {
6984
- console.error(`Invalid priority: ${value}. Use: none, low, medium, high`);
6985
- process.exit(1);
6910
+ errorOut(`Invalid priority: "${value}". Valid values: none, low, medium, high`);
6986
6911
  }
6987
6912
  return p;
6988
6913
  }
@@ -6998,7 +6923,7 @@ function resolveProjectId(projectId) {
6998
6923
  process.exit(1);
6999
6924
  }
7000
6925
  var program = new Command();
7001
- program.name("hog").description("Personal command deck \u2014 unified task dashboard for GitHub Projects + TickTick").version("1.7.2").option("--json", "Force JSON output").option("--human", "Force human-readable output").hook("preAction", (thisCommand) => {
6926
+ program.name("hog").description("Personal command deck \u2014 unified task dashboard for GitHub Projects + TickTick").version("1.8.1").option("--json", "Force JSON output").option("--human", "Force human-readable output").hook("preAction", (thisCommand) => {
7002
6927
  const opts = thisCommand.opts();
7003
6928
  if (opts.json) setFormat("json");
7004
6929
  if (opts.human) setFormat("human");
@@ -7020,9 +6945,7 @@ task.command("add <title>").description("Create a new task").option("-p, --prior
7020
6945
  if (opts.tags) input2.tags = opts.tags.split(",").map((t) => t.trim());
7021
6946
  if (opts.allDay) input2.isAllDay = true;
7022
6947
  const created = await api.createTask(input2);
7023
- printSuccess(`Created: ${created.title}`, {
7024
- task: created
7025
- });
6948
+ printSuccess(`Created: ${created.title}`, { task: created });
7026
6949
  });
7027
6950
  task.command("list").description("List tasks in a project").option("--project <id>", "Project ID (overrides default)").option("--all", "Include completed tasks").option("-p, --priority <level>", "Filter by minimum priority").option("-t, --tag <tag>", "Filter by tag").action(async (opts) => {
7028
6951
  const api = createClient();
@@ -7063,9 +6986,7 @@ task.command("update <taskId>").description("Update a task").option("--title <ti
7063
6986
  if (opts.content) input2.content = opts.content;
7064
6987
  if (opts.tags) input2.tags = opts.tags.split(",").map((t) => t.trim());
7065
6988
  const updated = await api.updateTask(input2);
7066
- printSuccess(`Updated: ${updated.title}`, {
7067
- task: updated
7068
- });
6989
+ printSuccess(`Updated: ${updated.title}`, { task: updated });
7069
6990
  });
7070
6991
  task.command("delete <taskId>").description("Delete a task").option("--project <id>", "Project ID (overrides default)").action(async (taskId, opts) => {
7071
6992
  const api = createClient();
@@ -7442,7 +7363,7 @@ issueCommand.command("create <text>").description("Create a GitHub issue from na
7442
7363
  const repoArg = repo;
7443
7364
  try {
7444
7365
  if (useJson()) {
7445
- const output = await execFileAsync4("gh", args, { encoding: "utf-8", timeout: 6e4 });
7366
+ const output = await execFileAsync2("gh", args, { encoding: "utf-8", timeout: 6e4 });
7446
7367
  const url = output.stdout.trim();
7447
7368
  const issueNumber = Number.parseInt(url.split("/").pop() ?? "0", 10);
7448
7369
  jsonOut({ ok: true, data: { url, issueNumber, repo: repoArg } });
@@ -7494,7 +7415,20 @@ issueCommand.command("move <issueRef> <status>").description("Change project sta
7494
7415
  errorOut(`Invalid status "${status}". Valid: ${valid}`, { status, validStatuses: valid });
7495
7416
  }
7496
7417
  if (opts.dryRun) {
7497
- console.log(`[dry-run] Would move ${rc.shortName}#${ref.issueNumber} \u2192 "${target.name}"`);
7418
+ if (useJson()) {
7419
+ jsonOut({
7420
+ ok: true,
7421
+ dryRun: true,
7422
+ would: {
7423
+ action: "move",
7424
+ issue: ref.issueNumber,
7425
+ repo: rc.shortName,
7426
+ status: target.name
7427
+ }
7428
+ });
7429
+ } else {
7430
+ console.log(`[dry-run] Would move ${rc.shortName}#${ref.issueNumber} \u2192 "${target.name}"`);
7431
+ }
7498
7432
  return;
7499
7433
  }
7500
7434
  await updateProjectItemStatusAsync2(rc.name, ref.issueNumber, {
@@ -7517,7 +7451,15 @@ issueCommand.command("assign <issueRef>").description("Assign issue to self or a
7517
7451
  process.exit(1);
7518
7452
  }
7519
7453
  if (opts.dryRun) {
7520
- console.log(`[dry-run] Would assign ${ref.repo.shortName}#${ref.issueNumber} to @${user}`);
7454
+ if (useJson()) {
7455
+ jsonOut({
7456
+ ok: true,
7457
+ dryRun: true,
7458
+ would: { action: "assign", issue: ref.issueNumber, repo: ref.repo.shortName, user }
7459
+ });
7460
+ } else {
7461
+ console.log(`[dry-run] Would assign ${ref.repo.shortName}#${ref.issueNumber} to @${user}`);
7462
+ }
7521
7463
  return;
7522
7464
  }
7523
7465
  const { assignIssueToAsync: assignIssueToAsync2 } = await Promise.resolve().then(() => (init_github(), github_exports));
@@ -7537,7 +7479,17 @@ issueCommand.command("unassign <issueRef>").description("Remove assignee from is
7537
7479
  process.exit(1);
7538
7480
  }
7539
7481
  if (opts.dryRun) {
7540
- console.log(`[dry-run] Would remove @${user} from ${ref.repo.shortName}#${ref.issueNumber}`);
7482
+ if (useJson()) {
7483
+ jsonOut({
7484
+ ok: true,
7485
+ dryRun: true,
7486
+ would: { action: "unassign", issue: ref.issueNumber, repo: ref.repo.shortName, user }
7487
+ });
7488
+ } else {
7489
+ console.log(
7490
+ `[dry-run] Would remove @${user} from ${ref.repo.shortName}#${ref.issueNumber}`
7491
+ );
7492
+ }
7541
7493
  return;
7542
7494
  }
7543
7495
  const { unassignIssueAsync: unassignIssueAsync2 } = await Promise.resolve().then(() => (init_github(), github_exports));
@@ -7552,7 +7504,17 @@ issueCommand.command("comment <issueRef> <text>").description("Post a comment on
7552
7504
  const cfg = loadFullConfig();
7553
7505
  const ref = await resolveRef(issueRef, cfg);
7554
7506
  if (opts.dryRun) {
7555
- console.log(`[dry-run] Would comment on ${ref.repo.shortName}#${ref.issueNumber}: "${text}"`);
7507
+ if (useJson()) {
7508
+ jsonOut({
7509
+ ok: true,
7510
+ dryRun: true,
7511
+ would: { action: "comment", issue: ref.issueNumber, repo: ref.repo.shortName, text }
7512
+ });
7513
+ } else {
7514
+ console.log(
7515
+ `[dry-run] Would comment on ${ref.repo.shortName}#${ref.issueNumber}: "${text}"`
7516
+ );
7517
+ }
7556
7518
  return;
7557
7519
  }
7558
7520
  const { addCommentAsync: addCommentAsync2 } = await Promise.resolve().then(() => (init_github(), github_exports));
@@ -7588,9 +7550,17 @@ issueCommand.command("edit <issueRef>").description("Edit issue fields (title, b
7588
7550
  process.exit(1);
7589
7551
  }
7590
7552
  if (opts.dryRun) {
7591
- console.log(
7592
- `[dry-run] Would edit ${ref.repo.shortName}#${ref.issueNumber}: ${changes.join("; ")}`
7593
- );
7553
+ if (useJson()) {
7554
+ jsonOut({
7555
+ ok: true,
7556
+ dryRun: true,
7557
+ would: { action: "edit", issue: ref.issueNumber, repo: ref.repo.shortName, changes }
7558
+ });
7559
+ } else {
7560
+ console.log(
7561
+ `[dry-run] Would edit ${ref.repo.shortName}#${ref.issueNumber}: ${changes.join("; ")}`
7562
+ );
7563
+ }
7594
7564
  return;
7595
7565
  }
7596
7566
  const ghArgs = ["issue", "edit", String(ref.issueNumber), "--repo", ref.repo.name];
@@ -7601,7 +7571,7 @@ issueCommand.command("edit <issueRef>").description("Edit issue fields (title, b
7601
7571
  if (opts.assignee) ghArgs.push("--add-assignee", opts.assignee);
7602
7572
  if (opts.removeAssignee) ghArgs.push("--remove-assignee", opts.removeAssignee);
7603
7573
  if (useJson()) {
7604
- await execFileAsync4("gh", ghArgs, { encoding: "utf-8", timeout: 3e4 });
7574
+ await execFileAsync2("gh", ghArgs, { encoding: "utf-8", timeout: 3e4 });
7605
7575
  jsonOut({ ok: true, data: { issue: ref.issueNumber, changes } });
7606
7576
  } else {
7607
7577
  execFileSync5("gh", ghArgs, { stdio: "inherit" });
@@ -7613,9 +7583,22 @@ issueCommand.command("label <issueRef> <label>").description("Add or remove a la
7613
7583
  const ref = await resolveRef(issueRef, cfg);
7614
7584
  const verb = opts.remove ? "remove" : "add";
7615
7585
  if (opts.dryRun) {
7616
- console.log(
7617
- `[dry-run] Would ${verb} label "${label}" on ${ref.repo.shortName}#${ref.issueNumber}`
7618
- );
7586
+ if (useJson()) {
7587
+ jsonOut({
7588
+ ok: true,
7589
+ dryRun: true,
7590
+ would: {
7591
+ action: `${verb}Label`,
7592
+ issue: ref.issueNumber,
7593
+ repo: ref.repo.shortName,
7594
+ label
7595
+ }
7596
+ });
7597
+ } else {
7598
+ console.log(
7599
+ `[dry-run] Would ${verb} label "${label}" on ${ref.repo.shortName}#${ref.issueNumber}`
7600
+ );
7601
+ }
7619
7602
  return;
7620
7603
  }
7621
7604
  if (opts.remove) {
@@ -7651,6 +7634,140 @@ issueCommand.command("statuses").description("List available project statuses fo
7651
7634
  console.log(`Available statuses for ${repo}: ${statuses.map((s) => s.name).join(", ")}`);
7652
7635
  }
7653
7636
  });
7637
+ async function moveSingleIssue(r, status, cfg) {
7638
+ try {
7639
+ const ref = await resolveRef(r, cfg);
7640
+ const rc = ref.repo;
7641
+ if (!(rc.statusFieldId && rc.projectNumber)) {
7642
+ throw new Error(`${rc.name} is not configured with a project board. Run: hog init`);
7643
+ }
7644
+ const { fetchProjectStatusOptions: fetchProjectStatusOptions2, updateProjectItemStatusAsync: updateProjectItemStatusAsync2 } = await Promise.resolve().then(() => (init_github(), github_exports));
7645
+ const options = fetchProjectStatusOptions2(rc.name, rc.projectNumber, rc.statusFieldId);
7646
+ const target = options.find((o) => o.name.toLowerCase() === status.toLowerCase());
7647
+ if (!target) {
7648
+ const valid = options.map((o) => o.name).join(", ");
7649
+ throw new Error(`Invalid status "${status}". Valid: ${valid}`);
7650
+ }
7651
+ await updateProjectItemStatusAsync2(rc.name, ref.issueNumber, {
7652
+ projectNumber: rc.projectNumber,
7653
+ statusFieldId: rc.statusFieldId,
7654
+ optionId: target.id
7655
+ });
7656
+ return { ref: r, success: true };
7657
+ } catch (err) {
7658
+ return { ref: r, success: false, error: err instanceof Error ? err.message : String(err) };
7659
+ }
7660
+ }
7661
+ function outputBulkResults(results) {
7662
+ const allOk = results.every((r) => r.success);
7663
+ if (useJson()) {
7664
+ jsonOut({ ok: allOk, results });
7665
+ } else {
7666
+ for (const r of results) {
7667
+ if (!r.success) {
7668
+ console.error(
7669
+ `Failed ${r.ref}: ${r.error}`
7670
+ );
7671
+ }
7672
+ }
7673
+ }
7674
+ }
7675
+ issueCommand.command("bulk-assign <refs...>").description(
7676
+ "Assign multiple issues to self or a specific user (e.g., hog issue bulk-assign myrepo/42 myrepo/43)"
7677
+ ).option("--user <username>", "GitHub username to assign (default: configured assignee)").option("--dry-run", "Print what would change without mutating").action(async (refs, opts) => {
7678
+ const cfg = loadFullConfig();
7679
+ const user = opts.user ?? cfg.board.assignee;
7680
+ if (!user) {
7681
+ errorOut("no user specified. Use --user or configure board.assignee in hog init");
7682
+ }
7683
+ if (opts.dryRun) {
7684
+ if (useJson()) {
7685
+ jsonOut({ ok: true, dryRun: true, would: { action: "bulk-assign", refs, user } });
7686
+ } else {
7687
+ for (const r of refs) {
7688
+ console.log(`[dry-run] Would assign ${r} to @${user}`);
7689
+ }
7690
+ }
7691
+ return;
7692
+ }
7693
+ const { assignIssueToAsync: assignIssueToAsync2 } = await Promise.resolve().then(() => (init_github(), github_exports));
7694
+ const results = [];
7695
+ for (const r of refs) {
7696
+ try {
7697
+ const ref = await resolveRef(r, cfg);
7698
+ await assignIssueToAsync2(ref.repo.name, ref.issueNumber, user);
7699
+ results.push({ ref: r, success: true });
7700
+ if (!useJson()) console.log(`Assigned ${r} to @${user}`);
7701
+ } catch (err) {
7702
+ results.push({
7703
+ ref: r,
7704
+ success: false,
7705
+ error: err instanceof Error ? err.message : String(err)
7706
+ });
7707
+ }
7708
+ }
7709
+ outputBulkResults(results);
7710
+ });
7711
+ issueCommand.command("bulk-unassign <refs...>").description(
7712
+ "Remove assignee from multiple issues (e.g., hog issue bulk-unassign myrepo/42 myrepo/43)"
7713
+ ).option("--user <username>", "GitHub username to remove (default: configured assignee)").option("--dry-run", "Print what would change without mutating").action(async (refs, opts) => {
7714
+ const cfg = loadFullConfig();
7715
+ const user = opts.user ?? cfg.board.assignee;
7716
+ if (!user) {
7717
+ errorOut("no user specified. Use --user or configure board.assignee in hog init");
7718
+ }
7719
+ if (opts.dryRun) {
7720
+ if (useJson()) {
7721
+ jsonOut({ ok: true, dryRun: true, would: { action: "bulk-unassign", refs, user } });
7722
+ } else {
7723
+ for (const r of refs) {
7724
+ console.log(`[dry-run] Would remove @${user} from ${r}`);
7725
+ }
7726
+ }
7727
+ return;
7728
+ }
7729
+ const { unassignIssueAsync: unassignIssueAsync2 } = await Promise.resolve().then(() => (init_github(), github_exports));
7730
+ const results = [];
7731
+ for (const r of refs) {
7732
+ try {
7733
+ const ref = await resolveRef(r, cfg);
7734
+ await unassignIssueAsync2(ref.repo.name, ref.issueNumber, user);
7735
+ results.push({ ref: r, success: true });
7736
+ if (!useJson()) console.log(`Removed @${user} from ${r}`);
7737
+ } catch (err) {
7738
+ results.push({
7739
+ ref: r,
7740
+ success: false,
7741
+ error: err instanceof Error ? err.message : String(err)
7742
+ });
7743
+ }
7744
+ }
7745
+ outputBulkResults(results);
7746
+ });
7747
+ issueCommand.command("bulk-move <status> <refs...>").description(
7748
+ "Move multiple issues to a project status (e.g., hog issue bulk-move 'In Review' myrepo/42 myrepo/43)"
7749
+ ).option("--dry-run", "Print what would change without mutating").action(async (status, refs, opts) => {
7750
+ const cfg = loadFullConfig();
7751
+ if (opts.dryRun) {
7752
+ if (useJson()) {
7753
+ jsonOut({ ok: true, dryRun: true, would: { action: "bulk-move", refs, status } });
7754
+ } else {
7755
+ for (const r of refs) {
7756
+ console.log(`[dry-run] Would move ${r} \u2192 "${status}"`);
7757
+ }
7758
+ }
7759
+ return;
7760
+ }
7761
+ const results = await Promise.all(
7762
+ refs.map((r) => moveSingleIssue(r, status, cfg))
7763
+ );
7764
+ if (!useJson()) {
7765
+ for (const r of results) {
7766
+ if (r.success) console.log(`Moved ${r.ref} \u2192 ${status}`);
7767
+ }
7768
+ }
7769
+ outputBulkResults(results);
7770
+ });
7654
7771
  program.addCommand(issueCommand);
7655
7772
  var logCommand = program.command("log").description("Action log commands");
7656
7773
  logCommand.command("show").description("Show recent action log entries").option("--limit <n>", "number of entries to show", "50").action((opts) => {