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
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
// Licensed under the Hungry Ghost Hive License. See LICENSE.
|
|
2
|
+
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { execa } from 'execa';
|
|
5
|
+
import { syncStatusForStory } from '../../../connectors/project-management/operations.js';
|
|
6
|
+
import { queryAll, queryOne, withTransaction } from '../../../db/client.js';
|
|
7
|
+
import { createLog } from '../../../db/queries/logs.js';
|
|
8
|
+
import {
|
|
9
|
+
createPullRequest,
|
|
10
|
+
getPullRequestsByStatus,
|
|
11
|
+
updatePullRequest,
|
|
12
|
+
} from '../../../db/queries/pull-requests.js';
|
|
13
|
+
import { updateStory } from '../../../db/queries/stories.js';
|
|
14
|
+
import { GH_CLI_TIMEOUT_MS } from '../../../utils/github-cli.js';
|
|
15
|
+
import {
|
|
16
|
+
fetchOpenGitHubPRs,
|
|
17
|
+
getExistingPRIdentifiers,
|
|
18
|
+
ghRepoSlug,
|
|
19
|
+
} from '../../../utils/pr-sync.js';
|
|
20
|
+
import { extractStoryIdFromBranch } from '../../../utils/story-id.js';
|
|
21
|
+
import { sendManagerNudge, verboseLogCtx } from './manager-utils.js';
|
|
22
|
+
import { cleanupAgentsReferencingMergedStory } from './merged-story-cleanup.js';
|
|
23
|
+
import type { ManagerCheckContext } from './types.js';
|
|
24
|
+
|
|
25
|
+
const GH_PR_VIEW_TIMEOUT_MS = 30_000;
|
|
26
|
+
const REVIEWING_PR_VALIDATION_MIN_AGE_MS = 5 * 60 * 1000;
|
|
27
|
+
|
|
28
|
+
interface ReviewingPRValidationCandidate {
|
|
29
|
+
id: string;
|
|
30
|
+
storyId: string | null;
|
|
31
|
+
teamId: string;
|
|
32
|
+
branchName: string;
|
|
33
|
+
githubPrNumber: number;
|
|
34
|
+
reviewedBy: string | null;
|
|
35
|
+
repoDir: string;
|
|
36
|
+
repoSlug: string | null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ReviewingPRValidationResult {
|
|
40
|
+
candidate: ReviewingPRValidationCandidate;
|
|
41
|
+
githubState: string;
|
|
42
|
+
githubUrl: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function syncMergedPRs(ctx: ManagerCheckContext): Promise<void> {
|
|
46
|
+
// Phase 1: Read teams (brief lock)
|
|
47
|
+
const teamInfos = await ctx.withDb(async db => {
|
|
48
|
+
const { getAllTeams } = await import('../../../db/queries/teams.js');
|
|
49
|
+
return getAllTeams(db.db)
|
|
50
|
+
.filter(t => t.repo_path)
|
|
51
|
+
.map(t => ({
|
|
52
|
+
repoDir: `${ctx.root}/${t.repo_path}`,
|
|
53
|
+
slug: ghRepoSlug(t.repo_url),
|
|
54
|
+
}));
|
|
55
|
+
});
|
|
56
|
+
if (teamInfos.length === 0) return;
|
|
57
|
+
|
|
58
|
+
// Phase 2: GitHub CLI calls (no lock)
|
|
59
|
+
const GITHUB_PR_LIST_LIMIT = 20;
|
|
60
|
+
const ghResults: Array<{
|
|
61
|
+
mergedPRs: Array<{ number: number; headRefName: string; mergedAt: string }>;
|
|
62
|
+
}> = [];
|
|
63
|
+
for (const team of teamInfos) {
|
|
64
|
+
try {
|
|
65
|
+
const args = [
|
|
66
|
+
'pr',
|
|
67
|
+
'list',
|
|
68
|
+
'--json',
|
|
69
|
+
'number,headRefName,mergedAt',
|
|
70
|
+
'--state',
|
|
71
|
+
'merged',
|
|
72
|
+
'--limit',
|
|
73
|
+
String(GITHUB_PR_LIST_LIMIT),
|
|
74
|
+
];
|
|
75
|
+
if (team.slug) args.push('-R', team.slug);
|
|
76
|
+
const result = await execa('gh', args, { cwd: team.repoDir, timeout: GH_CLI_TIMEOUT_MS });
|
|
77
|
+
ghResults.push({ mergedPRs: JSON.parse(result.stdout) });
|
|
78
|
+
} catch {
|
|
79
|
+
ghResults.push({ mergedPRs: [] });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Phase 3: DB reads + writes (brief lock)
|
|
84
|
+
const mergedSynced = await ctx.withDb(async db => {
|
|
85
|
+
let storiesUpdated = 0;
|
|
86
|
+
for (const ghResult of ghResults) {
|
|
87
|
+
const candidateStoryIds = Array.from(
|
|
88
|
+
new Set(
|
|
89
|
+
ghResult.mergedPRs
|
|
90
|
+
.map(pr => extractStoryIdFromBranch(pr.headRefName))
|
|
91
|
+
.filter((id): id is string => Boolean(id))
|
|
92
|
+
)
|
|
93
|
+
);
|
|
94
|
+
if (candidateStoryIds.length === 0) continue;
|
|
95
|
+
|
|
96
|
+
const placeholders = candidateStoryIds.map(() => '?').join(',');
|
|
97
|
+
const updatableStories = queryAll<{ id: string }>(
|
|
98
|
+
db.db,
|
|
99
|
+
`SELECT id FROM stories WHERE status != 'merged' AND id IN (${placeholders})`,
|
|
100
|
+
candidateStoryIds
|
|
101
|
+
);
|
|
102
|
+
const updatableStoryIds = new Set(updatableStories.map(s => s.id));
|
|
103
|
+
const toUpdate: Array<{ storyId: string; prNumber: number }> = [];
|
|
104
|
+
|
|
105
|
+
for (const pr of ghResult.mergedPRs) {
|
|
106
|
+
const storyId = extractStoryIdFromBranch(pr.headRefName);
|
|
107
|
+
if (!storyId || !updatableStoryIds.has(storyId)) continue;
|
|
108
|
+
updatableStoryIds.delete(storyId);
|
|
109
|
+
toUpdate.push({ storyId, prNumber: pr.number });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (toUpdate.length > 0) {
|
|
113
|
+
await withTransaction(db.db, () => {
|
|
114
|
+
for (const update of toUpdate) {
|
|
115
|
+
updateStory(db.db, update.storyId, { status: 'merged', assignedAgentId: null });
|
|
116
|
+
const cleanup = cleanupAgentsReferencingMergedStory(db.db, update.storyId);
|
|
117
|
+
createLog(db.db, {
|
|
118
|
+
agentId: 'manager',
|
|
119
|
+
storyId: update.storyId,
|
|
120
|
+
eventType: 'STORY_MERGED',
|
|
121
|
+
message: `Story synced to merged from GitHub PR #${update.prNumber}`,
|
|
122
|
+
metadata: {
|
|
123
|
+
merged_agent_cleanup_cleared: cleanup.cleared,
|
|
124
|
+
merged_agent_cleanup_reassigned: cleanup.reassigned,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
for (const update of toUpdate) {
|
|
130
|
+
syncStatusForStory(ctx.root, db.db, update.storyId, 'merged');
|
|
131
|
+
}
|
|
132
|
+
storiesUpdated += toUpdate.length;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (storiesUpdated > 0) db.save();
|
|
136
|
+
return storiesUpdated;
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
verboseLogCtx(ctx, `syncMergedPRs: synced=${mergedSynced}`);
|
|
140
|
+
if (mergedSynced > 0) {
|
|
141
|
+
console.log(chalk.green(` Synced ${mergedSynced} merged story(ies) from GitHub`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function reconcileAgentsOnMergedStories(ctx: ManagerCheckContext): Promise<void> {
|
|
146
|
+
const result = await ctx.withDb(async db => {
|
|
147
|
+
const mergedStoryIds = queryAll<{ id: string }>(
|
|
148
|
+
db.db,
|
|
149
|
+
`
|
|
150
|
+
SELECT DISTINCT s.id
|
|
151
|
+
FROM stories s
|
|
152
|
+
JOIN agents a ON a.current_story_id = s.id
|
|
153
|
+
WHERE s.status = 'merged'
|
|
154
|
+
AND a.status != 'terminated'
|
|
155
|
+
`
|
|
156
|
+
).map(row => row.id);
|
|
157
|
+
|
|
158
|
+
if (mergedStoryIds.length === 0) {
|
|
159
|
+
return { storyCount: 0, cleared: 0, reassigned: 0 };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let cleared = 0;
|
|
163
|
+
let reassigned = 0;
|
|
164
|
+
for (const storyId of mergedStoryIds) {
|
|
165
|
+
const cleanup = cleanupAgentsReferencingMergedStory(db.db, storyId);
|
|
166
|
+
if (cleanup.cleared > 0) {
|
|
167
|
+
createLog(db.db, {
|
|
168
|
+
agentId: 'manager',
|
|
169
|
+
storyId,
|
|
170
|
+
eventType: 'STORY_PROGRESS_UPDATE',
|
|
171
|
+
message: `Reconciled stale merged-story agent assignments`,
|
|
172
|
+
metadata: {
|
|
173
|
+
story_id: storyId,
|
|
174
|
+
cleared_agents: cleanup.cleared,
|
|
175
|
+
reassigned_agents: cleanup.reassigned,
|
|
176
|
+
recovery: 'merged_story_agent_reconcile',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
cleared += cleanup.cleared;
|
|
181
|
+
reassigned += cleanup.reassigned;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (cleared > 0) {
|
|
185
|
+
db.save();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return { storyCount: mergedStoryIds.length, cleared, reassigned };
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
verboseLogCtx(
|
|
192
|
+
ctx,
|
|
193
|
+
`reconcileAgentsOnMergedStories: stories=${result.storyCount}, cleared=${result.cleared}, reassigned=${result.reassigned}`
|
|
194
|
+
);
|
|
195
|
+
if (result.cleared > 0) {
|
|
196
|
+
console.log(
|
|
197
|
+
chalk.yellow(
|
|
198
|
+
` Reconciled ${result.cleared} stale merged-story agent assignment(s) (${result.reassigned} reassigned, ${result.cleared - result.reassigned} idled)`
|
|
199
|
+
)
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function syncOpenPRs(ctx: ManagerCheckContext): Promise<void> {
|
|
205
|
+
const maxAgeHours = ctx.config.merge_queue?.max_age_hours;
|
|
206
|
+
|
|
207
|
+
// Phase 1: Read teams + existing identifiers (brief lock)
|
|
208
|
+
const setupData = await ctx.withDb(async db => {
|
|
209
|
+
const { getAllTeams } = await import('../../../db/queries/teams.js');
|
|
210
|
+
const teams = getAllTeams(db.db);
|
|
211
|
+
const { existingBranches, existingPrNumbers } = getExistingPRIdentifiers(db.db, true);
|
|
212
|
+
return {
|
|
213
|
+
teams: teams
|
|
214
|
+
.filter(t => t.repo_path)
|
|
215
|
+
.map(t => ({
|
|
216
|
+
id: t.id,
|
|
217
|
+
repoDir: `${ctx.root}/${t.repo_path}`,
|
|
218
|
+
slug: ghRepoSlug(t.repo_url),
|
|
219
|
+
})),
|
|
220
|
+
existingBranches,
|
|
221
|
+
existingPrNumbers,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
if (setupData.teams.length === 0) return;
|
|
225
|
+
|
|
226
|
+
// Phase 2: GitHub CLI calls (no lock)
|
|
227
|
+
const teamPRs = new Map<string, import('../../../utils/pr-sync.js').GitHubPR[]>();
|
|
228
|
+
for (const team of setupData.teams) {
|
|
229
|
+
try {
|
|
230
|
+
const prs = await fetchOpenGitHubPRs(team.repoDir, team.slug);
|
|
231
|
+
teamPRs.set(team.id, prs);
|
|
232
|
+
} catch {
|
|
233
|
+
// gh CLI might not be authenticated
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Phase 3: Import into DB (brief lock)
|
|
238
|
+
const syncedPRs = await ctx.withDb(async (db, scheduler) => {
|
|
239
|
+
// Re-read identifiers in case another process synced in the meantime
|
|
240
|
+
const { existingBranches, existingPrNumbers } = getExistingPRIdentifiers(db.db, true);
|
|
241
|
+
let totalSynced = 0;
|
|
242
|
+
|
|
243
|
+
for (const team of setupData.teams) {
|
|
244
|
+
const prs = teamPRs.get(team.id);
|
|
245
|
+
if (!prs) continue;
|
|
246
|
+
|
|
247
|
+
for (const ghPR of prs) {
|
|
248
|
+
if (existingBranches.has(ghPR.headRefName) || existingPrNumbers.has(ghPR.number)) continue;
|
|
249
|
+
|
|
250
|
+
// Age filtering
|
|
251
|
+
if (maxAgeHours !== undefined) {
|
|
252
|
+
const ageHours = (Date.now() - new Date(ghPR.createdAt).getTime()) / (1000 * 60 * 60);
|
|
253
|
+
if (ageHours > maxAgeHours) {
|
|
254
|
+
createLog(db.db, {
|
|
255
|
+
agentId: 'manager',
|
|
256
|
+
eventType: 'PR_SYNC_SKIPPED',
|
|
257
|
+
status: 'info',
|
|
258
|
+
message: `Skipped syncing old PR #${ghPR.number} (${ghPR.headRefName}): created ${ageHours.toFixed(1)}h ago (max: ${maxAgeHours}h)`,
|
|
259
|
+
metadata: {
|
|
260
|
+
pr_number: ghPR.number,
|
|
261
|
+
branch: ghPR.headRefName,
|
|
262
|
+
age_hours: ageHours,
|
|
263
|
+
max_age_hours: maxAgeHours,
|
|
264
|
+
reason: 'too_old',
|
|
265
|
+
},
|
|
266
|
+
});
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const storyId = extractStoryIdFromBranch(ghPR.headRefName);
|
|
272
|
+
if (storyId) {
|
|
273
|
+
const storyRows = queryAll<{ id: string; status: string }>(
|
|
274
|
+
db.db,
|
|
275
|
+
`SELECT id, status FROM stories WHERE id = ? AND status != 'merged'`,
|
|
276
|
+
[storyId]
|
|
277
|
+
);
|
|
278
|
+
if (storyRows.length === 0) {
|
|
279
|
+
createLog(db.db, {
|
|
280
|
+
agentId: 'manager',
|
|
281
|
+
eventType: 'PR_SYNC_SKIPPED',
|
|
282
|
+
status: 'info',
|
|
283
|
+
message: `Skipped syncing PR #${ghPR.number} (${ghPR.headRefName}): story ${storyId} not found or already merged`,
|
|
284
|
+
metadata: {
|
|
285
|
+
pr_number: ghPR.number,
|
|
286
|
+
branch: ghPR.headRefName,
|
|
287
|
+
story_id: storyId,
|
|
288
|
+
reason: 'inactive_story',
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
createPullRequest(db.db, {
|
|
296
|
+
storyId,
|
|
297
|
+
teamId: team.id,
|
|
298
|
+
branchName: ghPR.headRefName,
|
|
299
|
+
githubPrNumber: ghPR.number,
|
|
300
|
+
githubPrUrl: ghPR.url,
|
|
301
|
+
submittedBy: null,
|
|
302
|
+
});
|
|
303
|
+
existingBranches.add(ghPR.headRefName);
|
|
304
|
+
existingPrNumbers.add(ghPR.number);
|
|
305
|
+
totalSynced++;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (totalSynced > 0) {
|
|
310
|
+
db.save();
|
|
311
|
+
await scheduler.checkMergeQueue();
|
|
312
|
+
db.save();
|
|
313
|
+
}
|
|
314
|
+
return totalSynced;
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
verboseLogCtx(ctx, `syncOpenPRs: synced=${syncedPRs}`);
|
|
318
|
+
if (syncedPRs > 0) {
|
|
319
|
+
console.log(chalk.yellow(` Synced ${syncedPRs} GitHub PR(s) into merge queue`));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export async function closeStalePRs(ctx: ManagerCheckContext): Promise<void> {
|
|
324
|
+
// Phase 1: Read teams + PR data (brief lock)
|
|
325
|
+
const { teamInfos, prsByStory } = await ctx.withDb(async db => {
|
|
326
|
+
const { getAllTeams } = await import('../../../db/queries/teams.js');
|
|
327
|
+
const teams = getAllTeams(db.db).filter(t => t.repo_path);
|
|
328
|
+
// Pre-fetch all non-closed PR data grouped by story
|
|
329
|
+
const allPRs = queryAll<{
|
|
330
|
+
story_id: string | null;
|
|
331
|
+
id: string;
|
|
332
|
+
github_pr_number: number | null;
|
|
333
|
+
}>(
|
|
334
|
+
db.db,
|
|
335
|
+
`SELECT story_id, id, github_pr_number FROM pull_requests WHERE status NOT IN ('closed') ORDER BY created_at DESC`
|
|
336
|
+
);
|
|
337
|
+
const prsByStory = new Map<string, Array<{ id: string; github_pr_number: number | null }>>();
|
|
338
|
+
for (const pr of allPRs) {
|
|
339
|
+
if (!pr.story_id) continue;
|
|
340
|
+
const existing = prsByStory.get(pr.story_id) || [];
|
|
341
|
+
existing.push({ id: pr.id, github_pr_number: pr.github_pr_number });
|
|
342
|
+
prsByStory.set(pr.story_id, existing);
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
teamInfos: teams.map(t => ({
|
|
346
|
+
repoDir: `${ctx.root}/${t.repo_path}`,
|
|
347
|
+
})),
|
|
348
|
+
prsByStory,
|
|
349
|
+
};
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
if (teamInfos.length === 0) return;
|
|
353
|
+
|
|
354
|
+
// Phase 2: GitHub CLI calls (no lock)
|
|
355
|
+
const baseBranch = ctx.config.github?.base_branch ?? 'main';
|
|
356
|
+
const closed: import('../../../utils/pr-sync.js').ClosedPRInfo[] = [];
|
|
357
|
+
|
|
358
|
+
for (const team of teamInfos) {
|
|
359
|
+
try {
|
|
360
|
+
const openGHPRs = await fetchOpenGitHubPRs(team.repoDir);
|
|
361
|
+
for (const ghPR of openGHPRs) {
|
|
362
|
+
// Skip PRs that don't target the configured base branch
|
|
363
|
+
if (ghPR.baseRefName !== baseBranch) continue;
|
|
364
|
+
|
|
365
|
+
const storyId = extractStoryIdFromBranch(ghPR.headRefName);
|
|
366
|
+
if (!storyId) continue;
|
|
367
|
+
const prsForStory = prsByStory.get(storyId);
|
|
368
|
+
if (!prsForStory || prsForStory.length === 0) continue;
|
|
369
|
+
const hasUnsyncedEntry = prsForStory.some(pr => pr.github_pr_number == null);
|
|
370
|
+
if (hasUnsyncedEntry) continue;
|
|
371
|
+
const isInQueue = prsForStory.some(pr => pr.github_pr_number === ghPR.number);
|
|
372
|
+
if (!isInQueue) {
|
|
373
|
+
const supersededByPrNumber =
|
|
374
|
+
prsForStory.find(pr => pr.github_pr_number !== null)?.github_pr_number ?? null;
|
|
375
|
+
try {
|
|
376
|
+
await execa('gh', ['pr', 'close', String(ghPR.number)], {
|
|
377
|
+
cwd: team.repoDir,
|
|
378
|
+
timeout: GH_CLI_TIMEOUT_MS,
|
|
379
|
+
});
|
|
380
|
+
closed.push({
|
|
381
|
+
storyId,
|
|
382
|
+
closedPrNumber: ghPR.number,
|
|
383
|
+
branch: ghPR.headRefName,
|
|
384
|
+
supersededByPrNumber,
|
|
385
|
+
});
|
|
386
|
+
} catch {
|
|
387
|
+
// Non-fatal
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
} catch {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Phase 3: Write logs (brief lock)
|
|
397
|
+
if (closed.length > 0) {
|
|
398
|
+
await ctx.withDb(async db => {
|
|
399
|
+
for (const info of closed) {
|
|
400
|
+
const supersededDesc =
|
|
401
|
+
info.supersededByPrNumber !== null ? ` by PR #${info.supersededByPrNumber}` : '';
|
|
402
|
+
createLog(db.db, {
|
|
403
|
+
agentId: 'manager',
|
|
404
|
+
storyId: info.storyId,
|
|
405
|
+
eventType: 'PR_CLOSED',
|
|
406
|
+
message: `Auto-closed stale GitHub PR #${info.closedPrNumber} (${info.branch}) - superseded${supersededDesc}`,
|
|
407
|
+
metadata: {
|
|
408
|
+
github_pr_number: info.closedPrNumber,
|
|
409
|
+
branch: info.branch,
|
|
410
|
+
reason: 'stale',
|
|
411
|
+
superseded_by_pr_number: info.supersededByPrNumber,
|
|
412
|
+
},
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
db.save();
|
|
416
|
+
});
|
|
417
|
+
console.log(chalk.yellow(` Closed ${closed.length} stale GitHub PR(s):`));
|
|
418
|
+
for (const info of closed) {
|
|
419
|
+
const supersededDesc =
|
|
420
|
+
info.supersededByPrNumber !== null
|
|
421
|
+
? ` (superseded by PR #${info.supersededByPrNumber})`
|
|
422
|
+
: '';
|
|
423
|
+
console.log(
|
|
424
|
+
chalk.gray(
|
|
425
|
+
` PR #${info.closedPrNumber} [${info.storyId}] ${info.branch}${supersededDesc}`
|
|
426
|
+
)
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
verboseLogCtx(ctx, `closeStalePRs: closed=${closed.length}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function recoverStaleReviewingPRs(ctx: ManagerCheckContext): Promise<void> {
|
|
434
|
+
const now = Date.now();
|
|
435
|
+
|
|
436
|
+
// Phase 1: Read stale reviewing PRs and resolve repo metadata (brief lock)
|
|
437
|
+
const candidates = await ctx.withDb(async db => {
|
|
438
|
+
const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(pr => {
|
|
439
|
+
if (!pr.github_pr_number || !pr.team_id) return false;
|
|
440
|
+
const updatedAtMs = Date.parse(pr.updated_at);
|
|
441
|
+
if (Number.isNaN(updatedAtMs)) return true;
|
|
442
|
+
return now - updatedAtMs >= REVIEWING_PR_VALIDATION_MIN_AGE_MS;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
verboseLogCtx(ctx, `recoverStaleReviewingPRs: staleCandidates=${reviewingPRs.length}`);
|
|
446
|
+
if (reviewingPRs.length === 0) {
|
|
447
|
+
return [] as ReviewingPRValidationCandidate[];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const { getAllTeams } = await import('../../../db/queries/teams.js');
|
|
451
|
+
const teams = getAllTeams(db.db);
|
|
452
|
+
const teamsById = new Map(teams.map(team => [team.id, team]));
|
|
453
|
+
|
|
454
|
+
const result: ReviewingPRValidationCandidate[] = [];
|
|
455
|
+
for (const pr of reviewingPRs) {
|
|
456
|
+
const team = teamsById.get(pr.team_id!);
|
|
457
|
+
if (!team?.repo_path) continue;
|
|
458
|
+
|
|
459
|
+
result.push({
|
|
460
|
+
id: pr.id,
|
|
461
|
+
storyId: pr.story_id,
|
|
462
|
+
teamId: pr.team_id!,
|
|
463
|
+
branchName: pr.branch_name,
|
|
464
|
+
githubPrNumber: pr.github_pr_number!,
|
|
465
|
+
reviewedBy: pr.reviewed_by,
|
|
466
|
+
repoDir: `${ctx.root}/${team.repo_path}`,
|
|
467
|
+
repoSlug: ghRepoSlug(team.repo_url),
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return result;
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
if (candidates.length === 0) return;
|
|
475
|
+
|
|
476
|
+
// Phase 2: Check GitHub state for each stale reviewing PR (no lock)
|
|
477
|
+
const mergedResults: ReviewingPRValidationResult[] = [];
|
|
478
|
+
const rejectedResults: ReviewingPRValidationResult[] = [];
|
|
479
|
+
|
|
480
|
+
for (const candidate of candidates) {
|
|
481
|
+
try {
|
|
482
|
+
const args = ['pr', 'view', String(candidate.githubPrNumber), '--json', 'state,url'];
|
|
483
|
+
if (candidate.repoSlug) args.push('-R', candidate.repoSlug);
|
|
484
|
+
const result = await execa('gh', args, {
|
|
485
|
+
cwd: candidate.repoDir,
|
|
486
|
+
timeout: GH_PR_VIEW_TIMEOUT_MS,
|
|
487
|
+
});
|
|
488
|
+
const parsed = JSON.parse(result.stdout) as { state?: string; url?: string };
|
|
489
|
+
const state = parsed.state?.toUpperCase();
|
|
490
|
+
const url = parsed.url || null;
|
|
491
|
+
|
|
492
|
+
if (state === 'OPEN') {
|
|
493
|
+
// PR is still open on GitHub but stale in 'reviewing' — the QA agent
|
|
494
|
+
// may have missed the original nudge. Re-nudge if QA agent is idle.
|
|
495
|
+
if (candidate.reviewedBy) {
|
|
496
|
+
const qaAgent = ctx.agentsBySessionName.get(candidate.reviewedBy);
|
|
497
|
+
if (qaAgent && qaAgent.status === 'idle') {
|
|
498
|
+
const githubLine = candidate.repoSlug
|
|
499
|
+
? `\n# GitHub: https://github.com/${candidate.repoSlug}/pull/${candidate.githubPrNumber}`
|
|
500
|
+
: '';
|
|
501
|
+
await sendManagerNudge(
|
|
502
|
+
ctx,
|
|
503
|
+
candidate.reviewedBy,
|
|
504
|
+
`# [REMINDER] You are assigned PR review ${candidate.id} (${candidate.storyId || 'no-story'}).${githubLine}
|
|
505
|
+
# This PR has been waiting for review. Execute now:
|
|
506
|
+
# hive pr show ${candidate.id}
|
|
507
|
+
# hive pr approve ${candidate.id}
|
|
508
|
+
# or reject:
|
|
509
|
+
# hive pr reject ${candidate.id} -r "reason"`
|
|
510
|
+
);
|
|
511
|
+
verboseLogCtx(
|
|
512
|
+
ctx,
|
|
513
|
+
`recoverStaleReviewingPRs: re-nudged idle QA ${candidate.reviewedBy} for stale pr=${candidate.id}`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (state === 'MERGED') {
|
|
520
|
+
mergedResults.push({
|
|
521
|
+
candidate,
|
|
522
|
+
githubState: 'MERGED',
|
|
523
|
+
githubUrl: url,
|
|
524
|
+
});
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (state) {
|
|
529
|
+
rejectedResults.push({
|
|
530
|
+
candidate,
|
|
531
|
+
githubState: state,
|
|
532
|
+
githubUrl: url,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
} catch (err) {
|
|
536
|
+
verboseLogCtx(
|
|
537
|
+
ctx,
|
|
538
|
+
`recoverStaleReviewingPRs: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (mergedResults.length === 0 && rejectedResults.length === 0) return;
|
|
544
|
+
|
|
545
|
+
const mergedStoryIds: string[] = [];
|
|
546
|
+
|
|
547
|
+
// Phase 3: Apply DB updates (brief lock)
|
|
548
|
+
await ctx.withDb(async db => {
|
|
549
|
+
for (const result of mergedResults) {
|
|
550
|
+
await withTransaction(
|
|
551
|
+
db.db,
|
|
552
|
+
() => {
|
|
553
|
+
const currentPR = queryOne<{ status: string }>(
|
|
554
|
+
db.db,
|
|
555
|
+
`SELECT status FROM pull_requests WHERE id = ?`,
|
|
556
|
+
[result.candidate.id]
|
|
557
|
+
);
|
|
558
|
+
if (!currentPR || currentPR.status !== 'reviewing') return;
|
|
559
|
+
|
|
560
|
+
updatePullRequest(db.db, result.candidate.id, {
|
|
561
|
+
status: 'merged',
|
|
562
|
+
reviewedBy: result.candidate.reviewedBy || 'manager',
|
|
563
|
+
});
|
|
564
|
+
createLog(db.db, {
|
|
565
|
+
agentId: 'manager',
|
|
566
|
+
storyId: result.candidate.storyId || undefined,
|
|
567
|
+
eventType: 'PR_MERGED',
|
|
568
|
+
message: `Auto-closed reviewing PR ${result.candidate.id}: GitHub PR #${result.candidate.githubPrNumber} is already merged`,
|
|
569
|
+
metadata: {
|
|
570
|
+
pr_id: result.candidate.id,
|
|
571
|
+
github_pr_number: result.candidate.githubPrNumber,
|
|
572
|
+
github_state: result.githubState,
|
|
573
|
+
github_url: result.githubUrl,
|
|
574
|
+
},
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
if (!result.candidate.storyId) return;
|
|
578
|
+
updateStory(db.db, result.candidate.storyId, { status: 'merged', assignedAgentId: null });
|
|
579
|
+
const cleanup = cleanupAgentsReferencingMergedStory(db.db, result.candidate.storyId);
|
|
580
|
+
createLog(db.db, {
|
|
581
|
+
agentId: 'manager',
|
|
582
|
+
storyId: result.candidate.storyId,
|
|
583
|
+
eventType: 'STORY_MERGED',
|
|
584
|
+
message: `Story auto-synced to merged (GitHub PR #${result.candidate.githubPrNumber} already merged)`,
|
|
585
|
+
metadata: {
|
|
586
|
+
pr_id: result.candidate.id,
|
|
587
|
+
github_pr_number: result.candidate.githubPrNumber,
|
|
588
|
+
github_url: result.githubUrl,
|
|
589
|
+
merged_agent_cleanup_cleared: cleanup.cleared,
|
|
590
|
+
merged_agent_cleanup_reassigned: cleanup.reassigned,
|
|
591
|
+
},
|
|
592
|
+
});
|
|
593
|
+
mergedStoryIds.push(result.candidate.storyId);
|
|
594
|
+
},
|
|
595
|
+
() => db.save()
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
for (const result of rejectedResults) {
|
|
600
|
+
await withTransaction(
|
|
601
|
+
db.db,
|
|
602
|
+
() => {
|
|
603
|
+
const currentPR = queryOne<{ status: string }>(
|
|
604
|
+
db.db,
|
|
605
|
+
`SELECT status FROM pull_requests WHERE id = ?`,
|
|
606
|
+
[result.candidate.id]
|
|
607
|
+
);
|
|
608
|
+
if (!currentPR || currentPR.status !== 'reviewing') return;
|
|
609
|
+
|
|
610
|
+
const reason = `GitHub PR #${result.candidate.githubPrNumber} is ${result.githubState.toLowerCase()} on GitHub${result.githubUrl ? ` (${result.githubUrl})` : ''}. Reopen/create a new PR and resubmit.`;
|
|
611
|
+
updatePullRequest(db.db, result.candidate.id, {
|
|
612
|
+
status: 'rejected',
|
|
613
|
+
reviewedBy: result.candidate.reviewedBy || 'manager',
|
|
614
|
+
reviewNotes: reason,
|
|
615
|
+
});
|
|
616
|
+
createLog(db.db, {
|
|
617
|
+
agentId: 'manager',
|
|
618
|
+
storyId: result.candidate.storyId || undefined,
|
|
619
|
+
eventType: 'PR_REJECTED',
|
|
620
|
+
status: 'warn',
|
|
621
|
+
message: `Auto-rejected stale review ${result.candidate.id}: ${reason}`,
|
|
622
|
+
metadata: {
|
|
623
|
+
pr_id: result.candidate.id,
|
|
624
|
+
github_pr_number: result.candidate.githubPrNumber,
|
|
625
|
+
github_state: result.githubState,
|
|
626
|
+
github_url: result.githubUrl,
|
|
627
|
+
branch: result.candidate.branchName,
|
|
628
|
+
team_id: result.candidate.teamId,
|
|
629
|
+
},
|
|
630
|
+
});
|
|
631
|
+
},
|
|
632
|
+
() => db.save()
|
|
633
|
+
);
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Sync merged stories to PM provider outside lock
|
|
638
|
+
const uniqueMergedStoryIds = Array.from(new Set(mergedStoryIds));
|
|
639
|
+
for (const storyId of uniqueMergedStoryIds) {
|
|
640
|
+
await ctx.withDb(async db => {
|
|
641
|
+
await syncStatusForStory(ctx.root, db.db, storyId, 'merged');
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (mergedResults.length > 0) {
|
|
646
|
+
console.log(
|
|
647
|
+
chalk.green(
|
|
648
|
+
` Auto-synced ${mergedResults.length} reviewing PR(s) that were already merged on GitHub`
|
|
649
|
+
)
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
if (rejectedResults.length > 0) {
|
|
653
|
+
console.log(
|
|
654
|
+
chalk.yellow(
|
|
655
|
+
` Auto-rejected ${rejectedResults.length} stale reviewing PR(s) with non-open GitHub PR state`
|
|
656
|
+
)
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|