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.
- package/dist/cli/commands/agents.d.ts.map +1 -1
- package/dist/cli/commands/agents.js +4 -11
- package/dist/cli/commands/agents.js.map +1 -1
- package/dist/cli/commands/approach.d.ts.map +1 -1
- package/dist/cli/commands/approach.js +2 -6
- package/dist/cli/commands/approach.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +9 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/init.test.js +3 -0
- package/dist/cli/commands/init.test.js.map +1 -1
- package/dist/cli/commands/manager/index.d.ts +2 -27
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +23 -1519
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/commands/manager/manager-utils.d.ts +9 -0
- package/dist/cli/commands/manager/manager-utils.d.ts.map +1 -0
- package/dist/cli/commands/manager/manager-utils.js +49 -0
- package/dist/cli/commands/manager/manager-utils.js.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts +7 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.d.ts.map +1 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js +537 -0
- package/dist/cli/commands/manager/pr-sync-orchestrator.js.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts +15 -0
- package/dist/cli/commands/manager/qa-review-handler.d.ts.map +1 -0
- package/dist/cli/commands/manager/qa-review-handler.js +290 -0
- package/dist/cli/commands/manager/qa-review-handler.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts +32 -0
- package/dist/cli/commands/manager/stuck-story-helpers.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js +163 -0
- package/dist/cli/commands/manager/stuck-story-helpers.js.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts +8 -0
- package/dist/cli/commands/manager/stuck-story-processor.d.ts.map +1 -0
- package/dist/cli/commands/manager/stuck-story-processor.js +392 -0
- package/dist/cli/commands/manager/stuck-story-processor.js.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts +3 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.d.ts.map +1 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js +141 -0
- package/dist/cli/commands/manager/tech-lead-lifecycle.js.map +1 -0
- package/dist/cli/commands/my-stories.d.ts.map +1 -1
- package/dist/cli/commands/my-stories.js +5 -20
- package/dist/cli/commands/my-stories.js.map +1 -1
- package/dist/cli/commands/pr.js +7 -22
- package/dist/cli/commands/pr.js.map +1 -1
- package/dist/cli/commands/progress.d.ts.map +1 -1
- package/dist/cli/commands/progress.js +2 -5
- package/dist/cli/commands/progress.js.map +1 -1
- package/dist/cli/commands/resume.d.ts.map +1 -1
- package/dist/cli/commands/resume.js +3 -6
- package/dist/cli/commands/resume.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +2 -5
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/commands/stories.d.ts.map +1 -1
- package/dist/cli/commands/stories.js +2 -5
- package/dist/cli/commands/stories.js.map +1 -1
- package/dist/cluster/adapters.d.ts +3 -2
- package/dist/cluster/adapters.d.ts.map +1 -1
- package/dist/cluster/adapters.js +2 -11
- package/dist/cluster/adapters.js.map +1 -1
- package/dist/cluster/cluster-http-server.d.ts +20 -0
- package/dist/cluster/cluster-http-server.d.ts.map +1 -0
- package/dist/cluster/cluster-http-server.js +140 -0
- package/dist/cluster/cluster-http-server.js.map +1 -0
- package/dist/cluster/heartbeat-manager.d.ts +24 -0
- package/dist/cluster/heartbeat-manager.d.ts.map +1 -0
- package/dist/cluster/heartbeat-manager.js +74 -0
- package/dist/cluster/heartbeat-manager.js.map +1 -0
- package/dist/cluster/raft-state-machine.d.ts +48 -0
- package/dist/cluster/raft-state-machine.d.ts.map +1 -0
- package/dist/cluster/raft-state-machine.js +207 -0
- package/dist/cluster/raft-state-machine.js.map +1 -0
- package/dist/cluster/runtime.d.ts +5 -29
- package/dist/cluster/runtime.d.ts.map +1 -1
- package/dist/cluster/runtime.js +58 -406
- package/dist/cluster/runtime.js.map +1 -1
- package/dist/integrations/jira/sync.d.ts +2 -5
- package/dist/integrations/jira/sync.d.ts.map +1 -1
- package/dist/integrations/jira/sync.js +116 -178
- package/dist/integrations/jira/sync.js.map +1 -1
- package/dist/utils/cli-helpers.d.ts +19 -0
- package/dist/utils/cli-helpers.d.ts.map +1 -0
- package/dist/utils/cli-helpers.js +51 -0
- package/dist/utils/cli-helpers.js.map +1 -0
- package/dist/utils/cli-helpers.test.d.ts +2 -0
- package/dist/utils/cli-helpers.test.d.ts.map +1 -0
- package/dist/utils/cli-helpers.test.js +100 -0
- package/dist/utils/cli-helpers.test.js.map +1 -0
- package/dist/utils/github-cli.d.ts +3 -0
- package/dist/utils/github-cli.d.ts.map +1 -0
- package/dist/utils/github-cli.js +4 -0
- package/dist/utils/github-cli.js.map +1 -0
- package/dist/utils/pr-sync.d.ts.map +1 -1
- package/dist/utils/pr-sync.js +1 -2
- package/dist/utils/pr-sync.js.map +1 -1
- package/dist/utils/story-status.d.ts +19 -0
- package/dist/utils/story-status.d.ts.map +1 -0
- package/dist/utils/story-status.js +58 -0
- package/dist/utils/story-status.js.map +1 -0
- package/dist/utils/story-status.test.d.ts +2 -0
- package/dist/utils/story-status.test.d.ts.map +1 -0
- package/dist/utils/story-status.test.js +65 -0
- package/dist/utils/story-status.test.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/commands/agents.ts +3 -11
- package/src/cli/commands/approach.ts +2 -7
- package/src/cli/commands/init.test.ts +4 -0
- package/src/cli/commands/init.ts +9 -0
- package/src/cli/commands/manager/index.ts +166 -2236
- package/src/cli/commands/manager/manager-utils.ts +85 -0
- package/src/cli/commands/manager/pr-sync-orchestrator.ts +659 -0
- package/src/cli/commands/manager/qa-review-handler.ts +399 -0
- package/src/cli/commands/manager/stuck-story-helpers.ts +255 -0
- package/src/cli/commands/manager/stuck-story-processor.ts +604 -0
- package/src/cli/commands/manager/tech-lead-lifecycle.ts +210 -0
- package/src/cli/commands/my-stories.ts +5 -30
- package/src/cli/commands/pr.ts +6 -22
- package/src/cli/commands/progress.ts +2 -7
- package/src/cli/commands/resume.ts +3 -6
- package/src/cli/commands/status.ts +2 -5
- package/src/cli/commands/stories.ts +2 -5
- package/src/cluster/adapters.ts +3 -12
- package/src/cluster/cluster-http-server.ts +187 -0
- package/src/cluster/heartbeat-manager.ts +112 -0
- package/src/cluster/raft-state-machine.ts +267 -0
- package/src/cluster/runtime.ts +71 -515
- package/src/integrations/jira/sync.ts +157 -215
- package/src/utils/cli-helpers.test.ts +138 -0
- package/src/utils/cli-helpers.ts +61 -0
- package/src/utils/github-cli.ts +4 -0
- package/src/utils/pr-sync.ts +1 -3
- package/src/utils/story-status.test.ts +74 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
+
return false;
|
|
168
172
|
}
|
|
169
173
|
|
|
170
|
-
if (mappedHiveStatus
|
|
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
|
-
`
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
513
|
+
const client = createJiraClient(tokenStore);
|
|
538
514
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
`
|
|
527
|
+
`Skipping Hive-to-Jira push for story ${story.id} (${issueKey}): ` +
|
|
528
|
+
`would regress Jira from ${jiraStatusAsHiveStatus} → ${story.status} (Jira: "${jiraStatusName}")`
|
|
559
529
|
);
|
|
560
|
-
|
|
530
|
+
return false;
|
|
561
531
|
}
|
|
562
532
|
|
|
563
|
-
//
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
story.
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/utils/pr-sync.ts
CHANGED
|
@@ -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.
|