hungry-ghost-hive 0.43.0 → 0.43.2

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.
Files changed (133) hide show
  1. package/dist/cli/commands/agents.d.ts.map +1 -1
  2. package/dist/cli/commands/agents.js +4 -11
  3. package/dist/cli/commands/agents.js.map +1 -1
  4. package/dist/cli/commands/approach.d.ts.map +1 -1
  5. package/dist/cli/commands/approach.js +2 -6
  6. package/dist/cli/commands/approach.js.map +1 -1
  7. package/dist/cli/commands/init.d.ts.map +1 -1
  8. package/dist/cli/commands/init.js +9 -0
  9. package/dist/cli/commands/init.js.map +1 -1
  10. package/dist/cli/commands/init.test.js +3 -0
  11. package/dist/cli/commands/init.test.js.map +1 -1
  12. package/dist/cli/commands/manager/index.d.ts +2 -27
  13. package/dist/cli/commands/manager/index.d.ts.map +1 -1
  14. package/dist/cli/commands/manager/index.js +23 -1519
  15. package/dist/cli/commands/manager/index.js.map +1 -1
  16. package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
  17. package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
  18. package/dist/cli/commands/manager/manager-utils.js +49 -0
  19. package/dist/cli/commands/manager/manager-utils.js.map +1 -0
  20. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
  21. package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
  22. package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
  23. package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
  24. package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
  25. package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
  26. package/dist/cli/commands/manager/qa-review-handler.js +290 -0
  27. package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
  28. package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
  29. package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
  30. package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
  31. package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
  32. package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
  33. package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
  34. package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
  35. package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
  36. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
  37. package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
  38. package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
  39. package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
  40. package/dist/cli/commands/my-stories.d.ts.map +1 -1
  41. package/dist/cli/commands/my-stories.js +5 -20
  42. package/dist/cli/commands/my-stories.js.map +1 -1
  43. package/dist/cli/commands/pr.js +7 -22
  44. package/dist/cli/commands/pr.js.map +1 -1
  45. package/dist/cli/commands/progress.d.ts.map +1 -1
  46. package/dist/cli/commands/progress.js +2 -5
  47. package/dist/cli/commands/progress.js.map +1 -1
  48. package/dist/cli/commands/resume.d.ts.map +1 -1
  49. package/dist/cli/commands/resume.js +3 -6
  50. package/dist/cli/commands/resume.js.map +1 -1
  51. package/dist/cli/commands/status.d.ts.map +1 -1
  52. package/dist/cli/commands/status.js +2 -5
  53. package/dist/cli/commands/status.js.map +1 -1
  54. package/dist/cli/commands/stories.d.ts.map +1 -1
  55. package/dist/cli/commands/stories.js +2 -5
  56. package/dist/cli/commands/stories.js.map +1 -1
  57. package/dist/cluster/adapters.d.ts +3 -2
  58. package/dist/cluster/adapters.d.ts.map +1 -1
  59. package/dist/cluster/adapters.js +2 -11
  60. package/dist/cluster/adapters.js.map +1 -1
  61. package/dist/cluster/cluster-http-server.d.ts +20 -0
  62. package/dist/cluster/cluster-http-server.d.ts.map +1 -0
  63. package/dist/cluster/cluster-http-server.js +140 -0
  64. package/dist/cluster/cluster-http-server.js.map +1 -0
  65. package/dist/cluster/heartbeat-manager.d.ts +24 -0
  66. package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
  67. package/dist/cluster/heartbeat-manager.js +74 -0
  68. package/dist/cluster/heartbeat-manager.js.map +1 -0
  69. package/dist/cluster/raft-state-machine.d.ts +48 -0
  70. package/dist/cluster/raft-state-machine.d.ts.map +1 -0
  71. package/dist/cluster/raft-state-machine.js +207 -0
  72. package/dist/cluster/raft-state-machine.js.map +1 -0
  73. package/dist/cluster/runtime.d.ts +5 -29
  74. package/dist/cluster/runtime.d.ts.map +1 -1
  75. package/dist/cluster/runtime.js +58 -406
  76. package/dist/cluster/runtime.js.map +1 -1
  77. package/dist/integrations/jira/sync.d.ts +2 -5
  78. package/dist/integrations/jira/sync.d.ts.map +1 -1
  79. package/dist/integrations/jira/sync.js +116 -178
  80. package/dist/integrations/jira/sync.js.map +1 -1
  81. package/dist/utils/cli-helpers.d.ts +19 -0
  82. package/dist/utils/cli-helpers.d.ts.map +1 -0
  83. package/dist/utils/cli-helpers.js +51 -0
  84. package/dist/utils/cli-helpers.js.map +1 -0
  85. package/dist/utils/cli-helpers.test.d.ts +2 -0
  86. package/dist/utils/cli-helpers.test.d.ts.map +1 -0
  87. package/dist/utils/cli-helpers.test.js +100 -0
  88. package/dist/utils/cli-helpers.test.js.map +1 -0
  89. package/dist/utils/github-cli.d.ts +3 -0
  90. package/dist/utils/github-cli.d.ts.map +1 -0
  91. package/dist/utils/github-cli.js +4 -0
  92. package/dist/utils/github-cli.js.map +1 -0
  93. package/dist/utils/pr-sync.d.ts.map +1 -1
  94. package/dist/utils/pr-sync.js +1 -2
  95. package/dist/utils/pr-sync.js.map +1 -1
  96. package/dist/utils/story-status.d.ts +19 -0
  97. package/dist/utils/story-status.d.ts.map +1 -0
  98. package/dist/utils/story-status.js +58 -0
  99. package/dist/utils/story-status.js.map +1 -0
  100. package/dist/utils/story-status.test.d.ts +2 -0
  101. package/dist/utils/story-status.test.d.ts.map +1 -0
  102. package/dist/utils/story-status.test.js +65 -0
  103. package/dist/utils/story-status.test.js.map +1 -0
  104. package/package.json +1 -1
  105. package/src/cli/commands/agents.ts +3 -11
  106. package/src/cli/commands/approach.ts +2 -7
  107. package/src/cli/commands/init.test.ts +4 -0
  108. package/src/cli/commands/init.ts +9 -0
  109. package/src/cli/commands/manager/index.ts +166 -2236
  110. package/src/cli/commands/manager/manager-utils.ts +85 -0
  111. package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
  112. package/src/cli/commands/manager/qa-review-handler.ts +399 -0
  113. package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
  114. package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
  115. package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
  116. package/src/cli/commands/my-stories.ts +5 -30
  117. package/src/cli/commands/pr.ts +6 -22
  118. package/src/cli/commands/progress.ts +2 -7
  119. package/src/cli/commands/resume.ts +3 -6
  120. package/src/cli/commands/status.ts +2 -5
  121. package/src/cli/commands/stories.ts +2 -5
  122. package/src/cluster/adapters.ts +3 -12
  123. package/src/cluster/cluster-http-server.ts +187 -0
  124. package/src/cluster/heartbeat-manager.ts +112 -0
  125. package/src/cluster/raft-state-machine.ts +267 -0
  126. package/src/cluster/runtime.ts +71 -515
  127. package/src/integrations/jira/sync.ts +157 -215
  128. package/src/utils/cli-helpers.test.ts +138 -0
  129. package/src/utils/cli-helpers.ts +61 -0
  130. package/src/utils/github-cli.ts +4 -0
  131. package/src/utils/pr-sync.ts +1 -3
  132. package/src/utils/story-status.test.ts +74 -0
  133. package/src/utils/story-status.ts +62 -0
@@ -18,66 +18,13 @@ import { createLog } from '../../db/queries/logs.js';
18
18
  import { getStoryById, updateStory, type StoryStatus } from '../../db/queries/stories.js';
19
19
  import * as logger from '../../utils/logger.js';
20
20
  import { getHivePaths } from '../../utils/paths.js';
21
+ import { isForwardTransition, isStatusRegression } from '../../utils/story-status.js';
21
22
  import { JiraClient } from './client.js';
22
23
  import { createSubtask, postComment } from './comments.js';
23
24
  import { getIssue } from './issues.js';
24
25
  import { syncRequirementToJira, tryMoveToActiveSprint } from './stories.js';
25
26
  import { syncStoryStatusToJira, transitionJiraIssue } from './transitions.js';
26
-
27
- /**
28
- * Ordered lifecycle stages for Hive stories.
29
- * Used to prevent bidirectional sync from regressing story status.
30
- */
31
- const STATUS_LIFECYCLE_ORDER: string[] = [
32
- 'draft',
33
- 'estimated',
34
- 'planned',
35
- 'in_progress',
36
- 'review',
37
- 'qa',
38
- 'qa_failed',
39
- 'pr_submitted',
40
- 'merged',
41
- ];
42
-
43
- /**
44
- * Check if transitioning from currentStatus to newStatus would be a regression.
45
- * Returns true if the new status is earlier in the lifecycle than the current one.
46
- */
47
- function isStatusRegression(currentStatus: string, newStatus: string): boolean {
48
- const currentIdx = STATUS_LIFECYCLE_ORDER.indexOf(currentStatus);
49
- const newIdx = STATUS_LIFECYCLE_ORDER.indexOf(newStatus);
50
- // If either status is unknown, allow the transition
51
- if (currentIdx === -1 || newIdx === -1) return false;
52
- return newIdx < currentIdx;
53
- }
54
-
55
- /**
56
- * Status progression order for the Hive pipeline.
57
- * Higher numbers represent further progress. Used to prevent
58
- * Jira sync from regressing stories backward.
59
- */
60
- const STATUS_ORDER: Record<string, number> = {
61
- draft: 0,
62
- estimated: 1,
63
- planned: 2,
64
- in_progress: 3,
65
- review: 4,
66
- pr_submitted: 5,
67
- qa: 6,
68
- qa_failed: 4, // allowed as a backward step from qa
69
- merged: 7,
70
- };
71
-
72
- /**
73
- * Check whether transitioning from currentStatus to newStatus is a
74
- * forward (or lateral) move in the pipeline.
75
- */
76
- export function isForwardTransition(currentStatus: string, newStatus: string): boolean {
77
- const currentOrder = STATUS_ORDER[currentStatus] ?? -1;
78
- const newOrder = STATUS_ORDER[newStatus] ?? -1;
79
- return newOrder >= currentOrder;
80
- }
27
+ export { isForwardTransition };
81
28
 
82
29
  /**
83
30
  * Convert a Jira status name to a Hive status using the configured status mapping.
@@ -101,6 +48,81 @@ export function jiraStatusToHiveStatus(
101
48
  return null;
102
49
  }
103
50
 
51
+ /**
52
+ * Create a JiraClient from environment variables.
53
+ * Shared by all sync functions in this module.
54
+ */
55
+ function createJiraClient(tokenStore: TokenStore): JiraClient {
56
+ loadEnvIntoProcess();
57
+ return new JiraClient({
58
+ tokenStore,
59
+ clientId: process.env.JIRA_CLIENT_ID || '',
60
+ clientSecret: process.env.JIRA_CLIENT_SECRET || '',
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Shared loop used by both bidirectional sync functions.
66
+ *
67
+ * For each story, fetches the current Jira status, converts it to a Hive status
68
+ * via the configured mapping, skips if unmapped, then delegates to `onMappedStatus`
69
+ * for the direction-specific action. Catches and logs per-story errors so one
70
+ * failing story does not abort the entire batch.
71
+ *
72
+ * @param db - Database instance (used for error logging)
73
+ * @param client - Authenticated JiraClient
74
+ * @param config - Jira configuration with status_mapping
75
+ * @param stories - Stories to process
76
+ * @param getIssueKey - Returns the Jira issue key for a given story row
77
+ * @param onMappedStatus - Called when a Jira status is successfully mapped;
78
+ * returns true if the story was acted upon (counted toward the return value)
79
+ * @returns Number of stories acted upon
80
+ */
81
+ async function processBidirectionalStatusSync(
82
+ db: Database,
83
+ client: JiraClient,
84
+ config: JiraConfig,
85
+ stories: StoryRow[],
86
+ getIssueKey: (story: StoryRow) => string,
87
+ onMappedStatus: (
88
+ story: StoryRow,
89
+ issueKey: string,
90
+ jiraStatusName: string,
91
+ mappedHiveStatus: string
92
+ ) => Promise<boolean>
93
+ ): Promise<number> {
94
+ let count = 0;
95
+ for (const story of stories) {
96
+ const issueKey = getIssueKey(story);
97
+ try {
98
+ const jiraIssue = await getIssue(client, issueKey, ['status']);
99
+ const jiraStatusName = jiraIssue.fields.status.name;
100
+ const mappedHiveStatus = jiraStatusToHiveStatus(jiraStatusName, config.status_mapping);
101
+ if (!mappedHiveStatus) {
102
+ logger.debug(
103
+ `No Hive status mapping for Jira status "${jiraStatusName}" (${issueKey}), skipping`
104
+ );
105
+ continue;
106
+ }
107
+ if (await onMappedStatus(story, issueKey, jiraStatusName, mappedHiveStatus)) {
108
+ count++;
109
+ }
110
+ } catch (err) {
111
+ const message = err instanceof Error ? err.message : String(err);
112
+ logger.warn(`Failed to sync Jira status for story ${story.id} (${issueKey}): ${message}`);
113
+ createLog(db, {
114
+ agentId: 'manager',
115
+ storyId: story.id,
116
+ eventType: 'JIRA_SYNC_WARNING',
117
+ status: 'warn',
118
+ message: `Failed to sync status: ${message}`,
119
+ metadata: { jiraKey: issueKey, error: message },
120
+ });
121
+ }
122
+ }
123
+ return count;
124
+ }
125
+
104
126
  /**
105
127
  * Sync Jira issue statuses back to the Hive database.
106
128
  * This detects manual status changes in Jira (e.g., dragging cards on the board)
@@ -132,93 +154,59 @@ export async function syncJiraStatusesToHive(
132
154
  return 0;
133
155
  }
134
156
 
135
- loadEnvIntoProcess();
136
-
137
- const client = new JiraClient({
138
- tokenStore,
139
- clientId: process.env.JIRA_CLIENT_ID || '',
140
- clientSecret: process.env.JIRA_CLIENT_SECRET || '',
141
- });
142
-
143
- let updatedCount = 0;
144
-
145
- for (const story of storiesWithJira) {
146
- try {
147
- // Fetch current Jira issue status
148
- const jiraIssue = await getIssue(client, story.external_issue_key!, ['status']);
149
- const jiraStatusName = jiraIssue.fields.status.name;
150
-
151
- // Convert Jira status to Hive status
152
- const mappedHiveStatus = jiraStatusToHiveStatus(jiraStatusName, config.status_mapping);
153
-
154
- if (!mappedHiveStatus) {
155
- logger.debug(
156
- `No Hive status mapping for Jira status "${jiraStatusName}" (${story.external_issue_key}), skipping`
157
- );
158
- continue;
159
- }
157
+ const client = createJiraClient(tokenStore);
160
158
 
161
- // Check if status differs from current Hive status
159
+ return processBidirectionalStatusSync(
160
+ db,
161
+ client,
162
+ config,
163
+ storiesWithJira,
164
+ story => story.external_issue_key!,
165
+ async (story, issueKey, jiraStatusName, mappedHiveStatus) => {
162
166
  // Guard: never regress status backward in the lifecycle
163
167
  if (isStatusRegression(story.status, mappedHiveStatus)) {
164
168
  logger.debug(
165
169
  `Skipping Jira sync for ${story.id}: would regress ${story.status} → ${mappedHiveStatus}`
166
170
  );
167
- continue;
171
+ return false;
168
172
  }
169
173
 
170
- if (mappedHiveStatus !== story.status) {
171
- // Only allow forward transitions — never regress stories backward
172
- if (!isForwardTransition(story.status, mappedHiveStatus)) {
173
- logger.debug(
174
- `Skipping backward Jira sync for story ${story.id} (${story.external_issue_key}): ` +
175
- `would regress ${story.status} → ${mappedHiveStatus} (Jira: "${jiraStatusName}")`
176
- );
177
- continue;
178
- }
179
-
180
- // Update the story status in Hive
181
- await withTransaction(db, () => {
182
- updateStory(db, story.id, { status: mappedHiveStatus as StoryStatus });
183
-
184
- createLog(db, {
185
- agentId: 'manager',
186
- storyId: story.id,
187
- eventType: 'JIRA_SYNC_COMPLETED',
188
- message: `Synced status from Jira: ${story.status} → ${mappedHiveStatus} (Jira: "${jiraStatusName}")`,
189
- metadata: {
190
- jiraKey: story.external_issue_key,
191
- oldHiveStatus: story.status,
192
- newHiveStatus: mappedHiveStatus,
193
- jiraStatus: jiraStatusName,
194
- },
195
- });
196
- });
174
+ if (mappedHiveStatus === story.status) return false;
197
175
 
176
+ // Only allow forward transitions — never regress stories backward
177
+ if (!isForwardTransition(story.status, mappedHiveStatus)) {
198
178
  logger.debug(
199
- `Synced Jira status for story ${story.id} (${story.external_issue_key}): ${story.status} → ${mappedHiveStatus}`
179
+ `Skipping backward Jira sync for story ${story.id} (${issueKey}): ` +
180
+ `would regress ${story.status} → ${mappedHiveStatus} (Jira: "${jiraStatusName}")`
200
181
  );
201
-
202
- updatedCount++;
182
+ return false;
203
183
  }
204
- } catch (err) {
205
- const message = err instanceof Error ? err.message : String(err);
206
- logger.warn(
207
- `Failed to sync Jira status for story ${story.id} (${story.external_issue_key}): ${message}`
208
- );
209
184
 
210
- createLog(db, {
211
- agentId: 'manager',
212
- storyId: story.id,
213
- eventType: 'JIRA_SYNC_WARNING',
214
- status: 'warn',
215
- message: `Failed to sync status from Jira: ${message}`,
216
- metadata: { jiraKey: story.external_issue_key, error: message },
185
+ // Update the story status in Hive
186
+ await withTransaction(db, () => {
187
+ updateStory(db, story.id, { status: mappedHiveStatus as StoryStatus });
188
+
189
+ createLog(db, {
190
+ agentId: 'manager',
191
+ storyId: story.id,
192
+ eventType: 'JIRA_SYNC_COMPLETED',
193
+ message: `Synced status from Jira: ${story.status} → ${mappedHiveStatus} (Jira: "${jiraStatusName}")`,
194
+ metadata: {
195
+ jiraKey: issueKey,
196
+ oldHiveStatus: story.status,
197
+ newHiveStatus: mappedHiveStatus,
198
+ jiraStatus: jiraStatusName,
199
+ },
200
+ });
217
201
  });
218
- }
219
- }
220
202
 
221
- return updatedCount;
203
+ logger.debug(
204
+ `Synced Jira status for story ${story.id} (${issueKey}): ${story.status} → ${mappedHiveStatus}`
205
+ );
206
+
207
+ return true;
208
+ }
209
+ );
222
210
  }
223
211
 
224
212
  /**
@@ -374,13 +362,7 @@ export async function repairMissedAssignmentHooks(
374
362
  `Found ${storiesMissingSubtasks.length} assigned story(ies) missing Jira subtasks — repairing`
375
363
  );
376
364
 
377
- loadEnvIntoProcess();
378
-
379
- const client = new JiraClient({
380
- tokenStore,
381
- clientId: process.env.JIRA_CLIENT_ID || '',
382
- clientSecret: process.env.JIRA_CLIENT_SECRET || '',
383
- });
365
+ const client = createJiraClient(tokenStore);
384
366
 
385
367
  let repairedCount = 0;
386
368
 
@@ -482,13 +464,7 @@ export async function retrySprintAssignment(
482
464
 
483
465
  logger.info(`Found ${storiesNotInSprint.length} story(ies) not in sprint — retrying assignment`);
484
466
 
485
- loadEnvIntoProcess();
486
-
487
- const client = new JiraClient({
488
- tokenStore,
489
- clientId: process.env.JIRA_CLIENT_ID || '',
490
- clientSecret: process.env.JIRA_CLIENT_SECRET || '',
491
- });
467
+ const client = createJiraClient(tokenStore);
492
468
 
493
469
  const issueKeys = storiesNotInSprint.map(s => s.jira_issue_key!).filter(Boolean);
494
470
 
@@ -534,90 +510,56 @@ export async function syncHiveStatusesToJira(
534
510
  return 0;
535
511
  }
536
512
 
537
- loadEnvIntoProcess();
513
+ const client = createJiraClient(tokenStore);
538
514
 
539
- const client = new JiraClient({
540
- tokenStore,
541
- clientId: process.env.JIRA_CLIENT_ID || '',
542
- clientSecret: process.env.JIRA_CLIENT_SECRET || '',
543
- });
544
-
545
- let pushedCount = 0;
546
-
547
- for (const story of storiesWithJira) {
548
- try {
549
- // Fetch current Jira issue status
550
- const jiraIssue = await getIssue(client, story.jira_issue_key!, ['status']);
551
- const jiraStatusName = jiraIssue.fields.status.name;
552
-
553
- // Convert Jira status to Hive status to compare
554
- const jiraStatusAsHiveStatus = jiraStatusToHiveStatus(jiraStatusName, config.status_mapping);
555
-
556
- if (!jiraStatusAsHiveStatus) {
515
+ return processBidirectionalStatusSync(
516
+ db,
517
+ client,
518
+ config,
519
+ storiesWithJira,
520
+ story => story.jira_issue_key!,
521
+ async (story, issueKey, jiraStatusName, jiraStatusAsHiveStatus) => {
522
+ if (story.status === jiraStatusAsHiveStatus) return false;
523
+
524
+ // Only push forward transitions — never regress Jira backward
525
+ if (!isForwardTransition(jiraStatusAsHiveStatus, story.status)) {
557
526
  logger.debug(
558
- `No Hive status mapping for Jira status "${jiraStatusName}" (${story.jira_issue_key}), skipping`
527
+ `Skipping Hive-to-Jira push for story ${story.id} (${issueKey}): ` +
528
+ `would regress Jira from ${jiraStatusAsHiveStatus} → ${story.status} (Jira: "${jiraStatusName}")`
559
529
  );
560
- continue;
530
+ return false;
561
531
  }
562
532
 
563
- // Check if Hive status is ahead of Jira status
564
- if (story.status !== jiraStatusAsHiveStatus) {
565
- // Only push forward transitions — never regress Jira backward
566
- if (!isForwardTransition(jiraStatusAsHiveStatus, story.status)) {
567
- logger.debug(
568
- `Skipping Hive-to-Jira push for story ${story.id} (${story.jira_issue_key}): ` +
569
- `would regress Jira from ${jiraStatusAsHiveStatus} → ${story.status} (Jira: "${jiraStatusName}")`
570
- );
571
- continue;
572
- }
533
+ // Push the Hive status to Jira
534
+ const transitioned = await transitionJiraIssue(
535
+ client,
536
+ issueKey,
537
+ story.status,
538
+ config.status_mapping
539
+ );
573
540
 
574
- // Push the Hive status to Jira
575
- const transitioned = await transitionJiraIssue(
576
- client,
577
- story.jira_issue_key!,
578
- story.status,
579
- config.status_mapping
580
- );
541
+ if (transitioned) {
542
+ createLog(db, {
543
+ agentId: 'manager',
544
+ storyId: story.id,
545
+ eventType: 'JIRA_SYNC_COMPLETED',
546
+ message: `Pushed status to Jira: ${jiraStatusAsHiveStatus} → ${story.status} (was Jira: "${jiraStatusName}")`,
547
+ metadata: {
548
+ jiraKey: issueKey,
549
+ oldJiraStatus: jiraStatusName,
550
+ oldHiveStatus: jiraStatusAsHiveStatus,
551
+ newHiveStatus: story.status,
552
+ },
553
+ });
581
554
 
582
- if (transitioned) {
583
- createLog(db, {
584
- agentId: 'manager',
585
- storyId: story.id,
586
- eventType: 'JIRA_SYNC_COMPLETED',
587
- message: `Pushed status to Jira: ${jiraStatusAsHiveStatus} → ${story.status} (was Jira: "${jiraStatusName}")`,
588
- metadata: {
589
- jiraKey: story.jira_issue_key,
590
- oldJiraStatus: jiraStatusName,
591
- oldHiveStatus: jiraStatusAsHiveStatus,
592
- newHiveStatus: story.status,
593
- },
594
- });
595
-
596
- logger.debug(
597
- `Pushed Hive status to Jira for story ${story.id} (${story.jira_issue_key}): ${jiraStatusAsHiveStatus} → ${story.status}`
598
- );
599
-
600
- pushedCount++;
601
- }
555
+ logger.debug(
556
+ `Pushed Hive status to Jira for story ${story.id} (${issueKey}): ${jiraStatusAsHiveStatus} → ${story.status}`
557
+ );
602
558
  }
603
- } catch (err) {
604
- const message = err instanceof Error ? err.message : String(err);
605
- logger.warn(
606
- `Failed to push Hive status to Jira for story ${story.id} (${story.jira_issue_key}): ${message}`
607
- );
608
559
 
609
- createLog(db, {
610
- agentId: 'manager',
611
- storyId: story.id,
612
- eventType: 'JIRA_SYNC_WARNING',
613
- status: 'warn',
614
- message: `Failed to push status to Jira: ${message}`,
615
- metadata: { jiraKey: story.jira_issue_key, error: message },
616
- });
560
+ return transitioned;
617
561
  }
618
- }
619
-
620
- return pushedCount;
562
+ );
621
563
  }
622
564
 
623
565
  /**
@@ -0,0 +1,138 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ vi.mock('../db/client.js', () => ({
6
+ queryOne: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('../db/queries/agents.js', () => ({
10
+ getAgentById: vi.fn(),
11
+ }));
12
+
13
+ vi.mock('../db/queries/pull-requests.js', () => ({
14
+ getPullRequestById: vi.fn(),
15
+ }));
16
+
17
+ vi.mock('../db/queries/stories.js', () => ({
18
+ getStoryById: vi.fn(),
19
+ }));
20
+
21
+ import { queryOne } from '../db/client.js';
22
+ import { getAgentById } from '../db/queries/agents.js';
23
+ import { getPullRequestById } from '../db/queries/pull-requests.js';
24
+ import { getStoryById } from '../db/queries/stories.js';
25
+ import {
26
+ requireAgent,
27
+ requireAgentBySession,
28
+ requirePullRequest,
29
+ requireStory,
30
+ } from './cli-helpers.js';
31
+
32
+ const mockDb = {} as any;
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ });
37
+
38
+ describe('requireStory', () => {
39
+ it('returns the story when found', () => {
40
+ const story = { id: 'STORY-1', title: 'Test' };
41
+ vi.mocked(getStoryById).mockReturnValue(story as any);
42
+
43
+ const result = requireStory(mockDb, 'STORY-1');
44
+
45
+ expect(result).toBe(story);
46
+ expect(getStoryById).toHaveBeenCalledWith(mockDb, 'STORY-1');
47
+ });
48
+
49
+ it('exits with error when story not found', () => {
50
+ vi.mocked(getStoryById).mockReturnValue(undefined);
51
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
52
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
53
+ throw new Error('process.exit called');
54
+ }) as any);
55
+
56
+ expect(() => requireStory(mockDb, 'STORY-99')).toThrow('process.exit called');
57
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Story not found: STORY-99'));
58
+ expect(exitSpy).toHaveBeenCalledWith(1);
59
+ });
60
+ });
61
+
62
+ describe('requireAgent', () => {
63
+ it('returns the agent when found', () => {
64
+ const agent = { id: 'agent-1', type: 'senior' };
65
+ vi.mocked(getAgentById).mockReturnValue(agent as any);
66
+
67
+ const result = requireAgent(mockDb, 'agent-1');
68
+
69
+ expect(result).toBe(agent);
70
+ expect(getAgentById).toHaveBeenCalledWith(mockDb, 'agent-1');
71
+ });
72
+
73
+ it('exits with error when agent not found', () => {
74
+ vi.mocked(getAgentById).mockReturnValue(undefined);
75
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
76
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
77
+ throw new Error('process.exit called');
78
+ }) as any);
79
+
80
+ expect(() => requireAgent(mockDb, 'agent-99')).toThrow('process.exit called');
81
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('Agent not found: agent-99'));
82
+ expect(exitSpy).toHaveBeenCalledWith(1);
83
+ });
84
+ });
85
+
86
+ describe('requireAgentBySession', () => {
87
+ it('returns the agent when session matches', () => {
88
+ const agent = { id: 'agent-1', tmux_session: 'hive-senior-team' };
89
+ vi.mocked(queryOne).mockReturnValue(agent as any);
90
+
91
+ const result = requireAgentBySession(mockDb, 'hive-senior-team');
92
+
93
+ expect(result).toBe(agent);
94
+ expect(queryOne).toHaveBeenCalledWith(
95
+ mockDb,
96
+ expect.stringContaining("status != 'terminated'"),
97
+ ['hive-senior-team']
98
+ );
99
+ });
100
+
101
+ it('exits with error when session not found', () => {
102
+ vi.mocked(queryOne).mockReturnValue(undefined);
103
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
104
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
105
+ throw new Error('process.exit called');
106
+ }) as any);
107
+
108
+ expect(() => requireAgentBySession(mockDb, 'unknown-session')).toThrow('process.exit called');
109
+ expect(errorSpy).toHaveBeenCalledWith(
110
+ expect.stringContaining('No agent found with session: unknown-session')
111
+ );
112
+ expect(exitSpy).toHaveBeenCalledWith(1);
113
+ });
114
+ });
115
+
116
+ describe('requirePullRequest', () => {
117
+ it('returns the PR when found', () => {
118
+ const pr = { id: 'pr-1', branch_name: 'feature/test' };
119
+ vi.mocked(getPullRequestById).mockReturnValue(pr as any);
120
+
121
+ const result = requirePullRequest(mockDb, 'pr-1');
122
+
123
+ expect(result).toBe(pr);
124
+ expect(getPullRequestById).toHaveBeenCalledWith(mockDb, 'pr-1');
125
+ });
126
+
127
+ it('exits with error when PR not found', () => {
128
+ vi.mocked(getPullRequestById).mockReturnValue(undefined);
129
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
130
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
131
+ throw new Error('process.exit called');
132
+ }) as any);
133
+
134
+ expect(() => requirePullRequest(mockDb, 'pr-99')).toThrow('process.exit called');
135
+ expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PR not found: pr-99'));
136
+ expect(exitSpy).toHaveBeenCalledWith(1);
137
+ });
138
+ });
@@ -0,0 +1,61 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ import chalk from 'chalk';
4
+ import type { Database } from 'sql.js';
5
+ import type { AgentRow, PullRequestRow, StoryRow } from '../db/client.js';
6
+ import { queryOne } from '../db/client.js';
7
+ import { getAgentById } from '../db/queries/agents.js';
8
+ import { getPullRequestById } from '../db/queries/pull-requests.js';
9
+ import { getStoryById } from '../db/queries/stories.js';
10
+
11
+ /**
12
+ * Require a story by ID, exit with error if not found.
13
+ */
14
+ export function requireStory(db: Database, storyId: string): StoryRow {
15
+ const story = getStoryById(db, storyId);
16
+ if (!story) {
17
+ console.error(chalk.red(`Story not found: ${storyId}`));
18
+ process.exit(1);
19
+ }
20
+ return story;
21
+ }
22
+
23
+ /**
24
+ * Require an agent by ID, exit with error if not found.
25
+ */
26
+ export function requireAgent(db: Database, agentId: string): AgentRow {
27
+ const agent = getAgentById(db, agentId);
28
+ if (!agent) {
29
+ console.error(chalk.red(`Agent not found: ${agentId}`));
30
+ process.exit(1);
31
+ }
32
+ return agent;
33
+ }
34
+
35
+ /**
36
+ * Require an active agent by tmux session name, exit with error if not found or terminated.
37
+ */
38
+ export function requireAgentBySession(db: Database, session: string): AgentRow {
39
+ const agent = queryOne<AgentRow>(
40
+ db,
41
+ "SELECT * FROM agents WHERE tmux_session = ? AND status != 'terminated'",
42
+ [session]
43
+ );
44
+ if (!agent) {
45
+ console.error(chalk.red(`No agent found with session: ${session}`));
46
+ process.exit(1);
47
+ }
48
+ return agent;
49
+ }
50
+
51
+ /**
52
+ * Require a pull request by ID, exit with error if not found.
53
+ */
54
+ export function requirePullRequest(db: Database, prId: string): PullRequestRow {
55
+ const pr = getPullRequestById(db, prId);
56
+ if (!pr) {
57
+ console.error(chalk.red(`PR not found: ${prId}`));
58
+ process.exit(1);
59
+ }
60
+ return pr;
61
+ }
@@ -0,0 +1,4 @@
1
+ // Licensed under the Hungry Ghost Hive License. See LICENSE.
2
+
3
+ /** Default timeout in ms for GitHub CLI operations */
4
+ export const GH_CLI_TIMEOUT_MS = 30000;
@@ -8,13 +8,11 @@ import { createLog } from '../db/queries/logs.js';
8
8
  import { createPullRequest } from '../db/queries/pull-requests.js';
9
9
  import { updateStory } from '../db/queries/stories.js';
10
10
  import { getAllTeams } from '../db/queries/teams.js';
11
+ import { GH_CLI_TIMEOUT_MS } from './github-cli.js';
11
12
  import { extractStoryIdFromBranch } from './story-id.js';
12
13
 
13
14
  const GITHUB_PR_LIST_LIMIT = 20;
14
15
 
15
- /** Default timeout in ms for GitHub CLI operations */
16
- const GH_CLI_TIMEOUT_MS = 30000;
17
-
18
16
  /**
19
17
  * Extract 'owner/repo' slug from a GitHub URL for use with `gh -R`.
20
18
  * Handles https://github.com/owner/repo.git and similar variants.