hungry-ghost-hive 0.40.3 → 0.41.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +8 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/manager/auto-assignment.js +4 -4
- package/dist/cli/commands/manager/auto-assignment.js.map +1 -1
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts +2 -0
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.d.ts.map +1 -0
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js +462 -0
- package/dist/cli/commands/manager/auto-reject-comment-only-reviews.test.js.map +1 -0
- package/dist/cli/commands/manager/index.d.ts +12 -0
- package/dist/cli/commands/manager/index.d.ts.map +1 -1
- package/dist/cli/commands/manager/index.js +171 -3
- package/dist/cli/commands/manager/index.js.map +1 -1
- package/dist/cli/wizard/init-wizard.d.ts +2 -1
- package/dist/cli/wizard/init-wizard.d.ts.map +1 -1
- package/dist/cli/wizard/init-wizard.js +56 -3
- package/dist/cli/wizard/init-wizard.js.map +1 -1
- package/dist/cli/wizard/init-wizard.test.js +56 -9
- package/dist/cli/wizard/init-wizard.test.js.map +1 -1
- package/dist/config/schema.d.ts +389 -0
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +18 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/context-files/index.test.js +6 -0
- package/dist/context-files/index.test.js.map +1 -1
- package/dist/git/github.d.ts +9 -0
- package/dist/git/github.d.ts.map +1 -1
- package/dist/git/github.js +15 -0
- package/dist/git/github.js.map +1 -1
- package/dist/git/github.test.js +77 -0
- package/dist/git/github.test.js.map +1 -1
- package/dist/orchestrator/scheduler.d.ts.map +1 -1
- package/dist/orchestrator/scheduler.js +1 -0
- package/dist/orchestrator/scheduler.js.map +1 -1
- package/dist/tmux/manager.d.ts.map +1 -1
- package/dist/tmux/manager.js +15 -8
- package/dist/tmux/manager.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/init.ts +8 -0
- package/src/cli/commands/manager/auto-assignment.ts +4 -4
- package/src/cli/commands/manager/auto-reject-comment-only-reviews.test.ts +589 -0
- package/src/cli/commands/manager/index.ts +228 -3
- package/src/cli/wizard/init-wizard.test.ts +62 -9
- package/src/cli/wizard/init-wizard.ts +66 -4
- package/src/config/schema.ts +20 -0
- package/src/context-files/index.test.ts +6 -0
- package/src/git/github.test.ts +93 -0
- package/src/git/github.ts +28 -0
- package/src/orchestrator/scheduler.ts +1 -0
- package/src/tmux/manager.ts +19 -8
|
@@ -16,6 +16,7 @@ import { createLog } from '../../../db/queries/logs.js';
|
|
|
16
16
|
import { getAllPendingMessages, markMessagesRead, } from '../../../db/queries/messages.js';
|
|
17
17
|
import { backfillGithubPrNumbers, createPullRequest, getMergeQueue, getOpenPullRequestsByStory, getPullRequestsByStatus, updatePullRequest, } from '../../../db/queries/pull-requests.js';
|
|
18
18
|
import { getStoriesByStatus, getStoryById, updateStory } from '../../../db/queries/stories.js';
|
|
19
|
+
import { getPullRequestComments, getPullRequestReviews } from '../../../git/github.js';
|
|
19
20
|
import { Scheduler } from '../../../orchestrator/scheduler.js';
|
|
20
21
|
import { AgentState } from '../../../state-detectors/types.js';
|
|
21
22
|
import { captureTmuxPane, getHiveSessions, isManagerRunning, isTmuxSessionRunning, killTmuxSession, sendToTmuxSession, spawnTmuxSession, stopManager as stopManagerSession, } from '../../../tmux/manager.js';
|
|
@@ -688,6 +689,8 @@ async function managerCheck(root, config, clusterRuntime, verbose = false) {
|
|
|
688
689
|
await batchMarkMessagesRead(ctx);
|
|
689
690
|
verboseLogCtx(ctx, 'Step: notify QA about queued PRs');
|
|
690
691
|
await notifyQAOfQueuedPRs(ctx);
|
|
692
|
+
verboseLogCtx(ctx, 'Step: auto-reject comment-only reviews');
|
|
693
|
+
await autoRejectCommentOnlyReviews(ctx);
|
|
691
694
|
verboseLogCtx(ctx, 'Step: handle rejected PRs');
|
|
692
695
|
await handleRejectedPRs(ctx);
|
|
693
696
|
verboseLogCtx(ctx, 'Step: recover unassigned qa_failed stories');
|
|
@@ -1160,8 +1163,26 @@ async function recoverStaleReviewingPRs(ctx) {
|
|
|
1160
1163
|
const parsed = JSON.parse(result.stdout);
|
|
1161
1164
|
const state = parsed.state?.toUpperCase();
|
|
1162
1165
|
const url = parsed.url || null;
|
|
1163
|
-
if (state === 'OPEN')
|
|
1166
|
+
if (state === 'OPEN') {
|
|
1167
|
+
// PR is still open on GitHub but stale in 'reviewing' — the QA agent
|
|
1168
|
+
// may have missed the original nudge. Re-nudge if QA agent is idle.
|
|
1169
|
+
if (candidate.reviewedBy) {
|
|
1170
|
+
const qaAgent = ctx.agentsBySessionName.get(candidate.reviewedBy);
|
|
1171
|
+
if (qaAgent && qaAgent.status === 'idle') {
|
|
1172
|
+
const githubLine = candidate.repoSlug
|
|
1173
|
+
? `\n# GitHub: https://github.com/${candidate.repoSlug}/pull/${candidate.githubPrNumber}`
|
|
1174
|
+
: '';
|
|
1175
|
+
await sendManagerNudge(ctx, candidate.reviewedBy, `# [REMINDER] You are assigned PR review ${candidate.id} (${candidate.storyId || 'no-story'}).${githubLine}
|
|
1176
|
+
# This PR has been waiting for review. Execute now:
|
|
1177
|
+
# hive pr show ${candidate.id}
|
|
1178
|
+
# hive pr approve ${candidate.id}
|
|
1179
|
+
# or reject:
|
|
1180
|
+
# hive pr reject ${candidate.id} -r "reason"`);
|
|
1181
|
+
verboseLogCtx(ctx, `recoverStaleReviewingPRs: re-nudged idle QA ${candidate.reviewedBy} for stale pr=${candidate.id}`);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1164
1184
|
continue;
|
|
1185
|
+
}
|
|
1165
1186
|
if (state === 'MERGED') {
|
|
1166
1187
|
mergedResults.push({
|
|
1167
1188
|
candidate,
|
|
@@ -1412,9 +1433,16 @@ async function scanAgentSessions(ctx) {
|
|
|
1412
1433
|
for (const session of ctx.hiveSessions) {
|
|
1413
1434
|
if (session.name === 'hive-manager')
|
|
1414
1435
|
continue;
|
|
1415
|
-
activeSessionNames.add(session.name);
|
|
1416
1436
|
const agent = ctx.agentsBySessionName.get(session.name);
|
|
1417
|
-
|
|
1437
|
+
// Skip sessions not registered in our DB (cross-project sessions).
|
|
1438
|
+
// This prevents escalation noise from sessions belonging to other
|
|
1439
|
+
// teams/projects sharing the same tmux server.
|
|
1440
|
+
if (!agent) {
|
|
1441
|
+
verboseLogCtx(ctx, `Skipping ${session.name}: no agent registered in DB (cross-project)`);
|
|
1442
|
+
continue;
|
|
1443
|
+
}
|
|
1444
|
+
activeSessionNames.add(session.name);
|
|
1445
|
+
const agentCliTool = (agent.cli_tool || 'claude');
|
|
1418
1446
|
const safetyMode = getAgentSafetyMode(ctx.config, agent);
|
|
1419
1447
|
verboseLogCtx(ctx, `Agent check: ${session.name} (cli=${agentCliTool}, safety=${safetyMode}, story=${agent?.current_story_id || '-'})`);
|
|
1420
1448
|
// Forward unread messages (tmux I/O, no lock needed)
|
|
@@ -1650,6 +1678,146 @@ async function notifyQAOfQueuedPRs(ctx) {
|
|
|
1650
1678
|
}
|
|
1651
1679
|
}
|
|
1652
1680
|
}
|
|
1681
|
+
/**
|
|
1682
|
+
* Auto-reject PRs where the QA agent posted review comments/feedback on GitHub
|
|
1683
|
+
* but never formally approved or rejected via `hive pr approve/reject`.
|
|
1684
|
+
*
|
|
1685
|
+
* Detection: PR is in 'reviewing' status, the assigned QA agent is idle,
|
|
1686
|
+
* and there are GitHub comments or CHANGES_REQUESTED reviews on the PR.
|
|
1687
|
+
*
|
|
1688
|
+
* Action: Auto-reject the PR with the QA's feedback as the rejection reason,
|
|
1689
|
+
* which triggers the standard qa_failed flow back to the developer agent.
|
|
1690
|
+
*/
|
|
1691
|
+
export async function autoRejectCommentOnlyReviews(ctx) {
|
|
1692
|
+
// Phase 1: Identify reviewing PRs with idle QA agents (brief lock)
|
|
1693
|
+
const candidates = await ctx.withDb(async (db) => {
|
|
1694
|
+
const reviewingPRs = getPullRequestsByStatus(db.db, 'reviewing').filter(pr => pr.github_pr_number && pr.team_id && pr.reviewed_by);
|
|
1695
|
+
verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: reviewingWithQA=${reviewingPRs.length}`);
|
|
1696
|
+
if (reviewingPRs.length === 0)
|
|
1697
|
+
return [];
|
|
1698
|
+
// Only consider PRs whose QA agent is idle (finished reviewing but didn't approve/reject)
|
|
1699
|
+
const idlePRs = reviewingPRs.filter(pr => {
|
|
1700
|
+
const qaAgent = ctx.agentsBySessionName.get(pr.reviewed_by);
|
|
1701
|
+
if (!qaAgent)
|
|
1702
|
+
return false;
|
|
1703
|
+
// Check if the QA agent is idle or if their session shows idle state
|
|
1704
|
+
const qaState = agentStates.get(pr.reviewed_by);
|
|
1705
|
+
return qaAgent.status === 'idle' || qaState?.lastState === AgentState.IDLE_AT_PROMPT;
|
|
1706
|
+
});
|
|
1707
|
+
verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: idleQACandidates=${idlePRs.length}`);
|
|
1708
|
+
if (idlePRs.length === 0)
|
|
1709
|
+
return [];
|
|
1710
|
+
const { getAllTeams } = await import('../../../db/queries/teams.js');
|
|
1711
|
+
const teams = getAllTeams(db.db);
|
|
1712
|
+
const teamsById = new Map(teams.map(team => [team.id, team]));
|
|
1713
|
+
return idlePRs
|
|
1714
|
+
.map(pr => {
|
|
1715
|
+
const team = teamsById.get(pr.team_id);
|
|
1716
|
+
if (!team?.repo_path)
|
|
1717
|
+
return null;
|
|
1718
|
+
return {
|
|
1719
|
+
id: pr.id,
|
|
1720
|
+
storyId: pr.story_id,
|
|
1721
|
+
teamId: pr.team_id,
|
|
1722
|
+
branchName: pr.branch_name,
|
|
1723
|
+
githubPrNumber: pr.github_pr_number,
|
|
1724
|
+
reviewedBy: pr.reviewed_by,
|
|
1725
|
+
submittedBy: pr.submitted_by,
|
|
1726
|
+
repoDir: `${ctx.root}/${team.repo_path}`,
|
|
1727
|
+
};
|
|
1728
|
+
})
|
|
1729
|
+
.filter(Boolean);
|
|
1730
|
+
});
|
|
1731
|
+
if (candidates.length === 0)
|
|
1732
|
+
return;
|
|
1733
|
+
// Phase 2: Check GitHub for comments/reviews on each candidate (no lock)
|
|
1734
|
+
const toReject = [];
|
|
1735
|
+
for (const candidate of candidates) {
|
|
1736
|
+
try {
|
|
1737
|
+
// Fetch both reviews and comments from GitHub
|
|
1738
|
+
const [reviews, comments] = await Promise.all([
|
|
1739
|
+
getPullRequestReviews(candidate.repoDir, candidate.githubPrNumber).catch(() => []),
|
|
1740
|
+
getPullRequestComments(candidate.repoDir, candidate.githubPrNumber).catch(() => []),
|
|
1741
|
+
]);
|
|
1742
|
+
// If there's a formal APPROVED review, skip (QA approved via GitHub directly)
|
|
1743
|
+
const hasApproval = reviews.some(r => r.state === 'APPROVED');
|
|
1744
|
+
if (hasApproval) {
|
|
1745
|
+
verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: pr=${candidate.id} has GitHub approval, skipping`);
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
// Check for CHANGES_REQUESTED reviews
|
|
1749
|
+
const changesRequested = reviews.filter(r => r.state === 'CHANGES_REQUESTED');
|
|
1750
|
+
// Check for substantive issue comments (filter out bot noise and very short comments)
|
|
1751
|
+
const substantiveComments = comments.filter(c => {
|
|
1752
|
+
if (c.body.length < 20)
|
|
1753
|
+
return false;
|
|
1754
|
+
// Skip known bot comments (Ellipsis, etc.)
|
|
1755
|
+
if (c.body.includes('Looks good to me') && c.body.length < 100)
|
|
1756
|
+
return false;
|
|
1757
|
+
return true;
|
|
1758
|
+
});
|
|
1759
|
+
// If there are review feedback items, auto-reject
|
|
1760
|
+
if (changesRequested.length > 0 || substantiveComments.length > 0) {
|
|
1761
|
+
// Build rejection reason from the feedback
|
|
1762
|
+
const feedbackParts = [];
|
|
1763
|
+
for (const review of changesRequested) {
|
|
1764
|
+
if (review.body)
|
|
1765
|
+
feedbackParts.push(review.body);
|
|
1766
|
+
}
|
|
1767
|
+
for (const comment of substantiveComments) {
|
|
1768
|
+
feedbackParts.push(comment.body);
|
|
1769
|
+
}
|
|
1770
|
+
const reason = feedbackParts.length > 0
|
|
1771
|
+
? feedbackParts.join('\n---\n').slice(0, 2000)
|
|
1772
|
+
: 'QA posted review feedback on GitHub without formal approval. See PR comments.';
|
|
1773
|
+
toReject.push({ candidate, reason });
|
|
1774
|
+
verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: pr=${candidate.id} has ${changesRequested.length} changes_requested + ${substantiveComments.length} comments, will auto-reject`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
catch (err) {
|
|
1778
|
+
verboseLogCtx(ctx, `autoRejectCommentOnlyReviews: skip pr=${candidate.id} github_check_failed=${err instanceof Error ? err.message : String(err)}`);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (toReject.length === 0)
|
|
1782
|
+
return;
|
|
1783
|
+
// Phase 3: Reject PRs in DB (brief lock)
|
|
1784
|
+
await ctx.withDb(async (db) => {
|
|
1785
|
+
for (const { candidate, reason } of toReject) {
|
|
1786
|
+
await withTransaction(db.db, () => {
|
|
1787
|
+
updatePullRequest(db.db, candidate.id, {
|
|
1788
|
+
status: 'rejected',
|
|
1789
|
+
reviewNotes: reason,
|
|
1790
|
+
});
|
|
1791
|
+
if (candidate.storyId) {
|
|
1792
|
+
updateStory(db.db, candidate.storyId, { status: 'qa_failed' });
|
|
1793
|
+
}
|
|
1794
|
+
createLog(db.db, {
|
|
1795
|
+
agentId: 'manager',
|
|
1796
|
+
eventType: 'PR_REJECTED',
|
|
1797
|
+
message: `Auto-rejected PR ${candidate.id}: QA posted review comments without formal approve/reject`,
|
|
1798
|
+
storyId: candidate.storyId || undefined,
|
|
1799
|
+
metadata: { pr_id: candidate.id, auto_rejected: true },
|
|
1800
|
+
});
|
|
1801
|
+
}, () => db.save());
|
|
1802
|
+
console.log(chalk.yellow(` Auto-rejected PR ${candidate.id} (story: ${candidate.storyId || '-'}): QA left review comments without approving`));
|
|
1803
|
+
}
|
|
1804
|
+
});
|
|
1805
|
+
// Phase 4: Notify developer agents via tmux (no lock)
|
|
1806
|
+
for (const { candidate, reason } of toReject) {
|
|
1807
|
+
if (candidate.submittedBy) {
|
|
1808
|
+
const devSession = ctx.hiveSessions.find(s => s.name === candidate.submittedBy);
|
|
1809
|
+
if (devSession) {
|
|
1810
|
+
await sendManagerNudge(ctx, devSession.name, `# ⚠️ PR AUTO-REJECTED - QA REVIEW FEEDBACK ⚠️
|
|
1811
|
+
# Story: ${candidate.storyId || 'Unknown'}
|
|
1812
|
+
# QA agent (${candidate.reviewedBy}) posted review feedback without formally approving.
|
|
1813
|
+
# Feedback:
|
|
1814
|
+
# ${reason.split('\n').slice(0, 10).join('\n# ')}
|
|
1815
|
+
#
|
|
1816
|
+
# Fix the issues and resubmit: hive pr submit -b ${candidate.branchName} -s ${candidate.storyId || 'STORY-ID'} --from ${devSession.name}`);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1653
1821
|
async function handleRejectedPRs(ctx) {
|
|
1654
1822
|
// Phase 1: Read rejected PRs and update DB (brief lock)
|
|
1655
1823
|
const rejectedPRData = await ctx.withDb(async (db) => {
|