@jungjaehoon/mama-os 0.9.2 → 0.9.4
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/README.md +41 -7
- package/dist/agent/agent-loop.d.ts.map +1 -1
- package/dist/agent/agent-loop.js +2 -3
- package/dist/agent/agent-loop.js.map +1 -1
- package/dist/agent/claude-cli-wrapper.d.ts +4 -4
- package/dist/agent/claude-cli-wrapper.d.ts.map +1 -1
- package/dist/agent/claude-cli-wrapper.js +17 -5
- package/dist/agent/claude-cli-wrapper.js.map +1 -1
- package/dist/agent/claude-client.js +3 -3
- package/dist/agent/claude-client.js.map +1 -1
- package/dist/agent/codex-mcp-process.d.ts +10 -0
- package/dist/agent/codex-mcp-process.d.ts.map +1 -1
- package/dist/agent/codex-mcp-process.js +226 -58
- package/dist/agent/codex-mcp-process.js.map +1 -1
- package/dist/agent/gateway-tool-executor.d.ts +15 -1
- package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
- package/dist/agent/gateway-tool-executor.js +37 -3
- package/dist/agent/gateway-tool-executor.js.map +1 -1
- package/dist/agent/gateway-tools.md +1 -0
- package/dist/agent/persistent-cli-process.d.ts +2 -0
- package/dist/agent/persistent-cli-process.d.ts.map +1 -1
- package/dist/agent/persistent-cli-process.js +15 -0
- package/dist/agent/persistent-cli-process.js.map +1 -1
- package/dist/agent/types.d.ts +3 -3
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/agent/types.js.map +1 -1
- package/dist/api/graph-api.d.ts.map +1 -1
- package/dist/api/graph-api.js +31 -5
- package/dist/api/graph-api.js.map +1 -1
- package/dist/cli/commands/start.d.ts.map +1 -1
- package/dist/cli/commands/start.js +91 -6
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/commands/stop.d.ts +7 -1
- package/dist/cli/commands/stop.d.ts.map +1 -1
- package/dist/cli/commands/stop.js +49 -0
- package/dist/cli/commands/stop.js.map +1 -1
- package/dist/cli/config/config-manager.d.ts.map +1 -1
- package/dist/cli/config/config-manager.js +60 -15
- package/dist/cli/config/config-manager.js.map +1 -1
- package/dist/cli/config/types.d.ts +19 -5
- package/dist/cli/config/types.d.ts.map +1 -1
- package/dist/cli/config/types.js +3 -3
- package/dist/cli/config/types.js.map +1 -1
- package/dist/gateways/image-analyzer.js +1 -1
- package/dist/gateways/image-analyzer.js.map +1 -1
- package/dist/gateways/slack.d.ts.map +1 -1
- package/dist/gateways/slack.js +8 -19
- package/dist/gateways/slack.js.map +1 -1
- package/dist/multi-agent/agent-process-manager.d.ts +15 -1
- package/dist/multi-agent/agent-process-manager.d.ts.map +1 -1
- package/dist/multi-agent/agent-process-manager.js +121 -22
- package/dist/multi-agent/agent-process-manager.js.map +1 -1
- package/dist/multi-agent/background-task-manager.d.ts +2 -2
- package/dist/multi-agent/background-task-manager.js +2 -2
- package/dist/multi-agent/bmad-templates.d.ts +67 -0
- package/dist/multi-agent/bmad-templates.d.ts.map +1 -0
- package/dist/multi-agent/bmad-templates.js +248 -0
- package/dist/multi-agent/bmad-templates.js.map +1 -0
- package/dist/multi-agent/council-engine.d.ts +60 -0
- package/dist/multi-agent/council-engine.d.ts.map +1 -0
- package/dist/multi-agent/council-engine.js +284 -0
- package/dist/multi-agent/council-engine.js.map +1 -0
- package/dist/multi-agent/multi-agent-base.d.ts +18 -9
- package/dist/multi-agent/multi-agent-base.d.ts.map +1 -1
- package/dist/multi-agent/multi-agent-base.js +116 -33
- package/dist/multi-agent/multi-agent-base.js.map +1 -1
- package/dist/multi-agent/multi-agent-discord.d.ts +3 -35
- package/dist/multi-agent/multi-agent-discord.d.ts.map +1 -1
- package/dist/multi-agent/multi-agent-discord.js +81 -302
- package/dist/multi-agent/multi-agent-discord.js.map +1 -1
- package/dist/multi-agent/multi-agent-slack.d.ts +2 -25
- package/dist/multi-agent/multi-agent-slack.d.ts.map +1 -1
- package/dist/multi-agent/multi-agent-slack.js +173 -253
- package/dist/multi-agent/multi-agent-slack.js.map +1 -1
- package/dist/multi-agent/runtime-process.d.ts +3 -0
- package/dist/multi-agent/runtime-process.d.ts.map +1 -1
- package/dist/multi-agent/runtime-process.js +4 -0
- package/dist/multi-agent/runtime-process.js.map +1 -1
- package/dist/multi-agent/shared-context.d.ts.map +1 -1
- package/dist/multi-agent/shared-context.js +4 -4
- package/dist/multi-agent/shared-context.js.map +1 -1
- package/dist/multi-agent/system-reminder.d.ts +1 -1
- package/dist/multi-agent/system-reminder.js +1 -1
- package/dist/multi-agent/types.d.ts +31 -15
- package/dist/multi-agent/types.d.ts.map +1 -1
- package/dist/multi-agent/types.js +1 -3
- package/dist/multi-agent/types.js.map +1 -1
- package/dist/multi-agent/ultrawork-state.d.ts +57 -0
- package/dist/multi-agent/ultrawork-state.d.ts.map +1 -0
- package/dist/multi-agent/ultrawork-state.js +191 -0
- package/dist/multi-agent/ultrawork-state.js.map +1 -0
- package/dist/multi-agent/ultrawork.d.ts +37 -19
- package/dist/multi-agent/ultrawork.d.ts.map +1 -1
- package/dist/multi-agent/ultrawork.js +587 -41
- package/dist/multi-agent/ultrawork.js.map +1 -1
- package/dist/multi-agent/workflow-engine.d.ts +7 -0
- package/dist/multi-agent/workflow-engine.d.ts.map +1 -1
- package/dist/multi-agent/workflow-engine.js +238 -33
- package/dist/multi-agent/workflow-engine.js.map +1 -1
- package/dist/multi-agent/workflow-types.d.ts +74 -1
- package/dist/multi-agent/workflow-types.d.ts.map +1 -1
- package/dist/onboarding/complete-autonomous-prompt.d.ts +1 -1
- package/dist/onboarding/complete-autonomous-prompt.d.ts.map +1 -1
- package/dist/onboarding/complete-autonomous-prompt.js +27 -10
- package/dist/onboarding/complete-autonomous-prompt.js.map +1 -1
- package/dist/onboarding/phase-7-integrations.d.ts.map +1 -1
- package/dist/onboarding/phase-7-integrations.js +23 -3
- package/dist/onboarding/phase-7-integrations.js.map +1 -1
- package/dist/onboarding/phase-9-finalization.d.ts.map +1 -1
- package/dist/onboarding/phase-9-finalization.js +33 -0
- package/dist/onboarding/phase-9-finalization.js.map +1 -1
- package/dist/setup/setup-prompt.d.ts +1 -1
- package/dist/setup/setup-prompt.d.ts.map +1 -1
- package/dist/setup/setup-prompt.js +1 -1
- package/package.json +1 -1
- package/public/viewer/js/modules/settings.js +110 -15
- package/public/viewer/js/utils/format.js +10 -7
- package/public/viewer/src/modules/settings.ts +133 -16
- package/public/viewer/src/utils/api.ts +2 -1
- package/public/viewer/src/utils/format.ts +10 -7
- package/public/viewer/viewer.html +1 -0
- package/templates/bmad/LICENSE +28 -0
- package/templates/bmad/architecture.md +343 -0
- package/templates/bmad/bmm-workflow-status.template.yaml +66 -0
- package/templates/bmad/prd.md +198 -0
- package/templates/bmad/product-brief.md +149 -0
- package/templates/bmad/sprint-status.template.yaml +35 -0
- package/templates/bmad/tech-spec.md +151 -0
- package/templates/personas/architect.md +70 -0
- package/templates/personas/conductor.md +373 -0
- package/templates/personas/developer.md +20 -7
- package/templates/personas/pm.md +49 -33
- package/templates/personas/reviewer.md +18 -5
- package/dist/multi-agent/pr-review-poller.d.ts +0 -197
- package/dist/multi-agent/pr-review-poller.d.ts.map +0 -1
- package/dist/multi-agent/pr-review-poller.js +0 -972
- package/dist/multi-agent/pr-review-poller.js.map +0 -1
- package/templates/personas/sisyphus-builtin-en.md +0 -161
- package/templates/personas/sisyphus.md +0 -218
|
@@ -1,972 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* PR Review Poller
|
|
4
|
-
*
|
|
5
|
-
* Polls GitHub PR for new review comments and injects them into Slack channel.
|
|
6
|
-
* Enables autonomous Sisyphus → DevBot → push → review → fix → push loop.
|
|
7
|
-
*
|
|
8
|
-
* Flow:
|
|
9
|
-
* 1. Agent pushes and posts PR URL in channel
|
|
10
|
-
* 2. Poller detects URL → starts polling `gh api` every 60s
|
|
11
|
-
* 3. New review comments → posted through callback (e.g., as lightweight reminders)
|
|
12
|
-
* 4. Sisyphus analyzes severity → delegates fixes to @DevBot
|
|
13
|
-
* 5. DevBot fixes → @Reviewer → approve or request changes
|
|
14
|
-
* 6. Poller detects new comments or Approved → loop continues or ends
|
|
15
|
-
*/
|
|
16
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
17
|
-
if (k2 === undefined) k2 = k;
|
|
18
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
19
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
20
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
21
|
-
}
|
|
22
|
-
Object.defineProperty(o, k2, desc);
|
|
23
|
-
}) : (function(o, m, k, k2) {
|
|
24
|
-
if (k2 === undefined) k2 = k;
|
|
25
|
-
o[k2] = m[k];
|
|
26
|
-
}));
|
|
27
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
28
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
29
|
-
}) : function(o, v) {
|
|
30
|
-
o["default"] = v;
|
|
31
|
-
});
|
|
32
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
33
|
-
var ownKeys = function(o) {
|
|
34
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
35
|
-
var ar = [];
|
|
36
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
37
|
-
return ar;
|
|
38
|
-
};
|
|
39
|
-
return ownKeys(o);
|
|
40
|
-
};
|
|
41
|
-
return function (mod) {
|
|
42
|
-
if (mod && mod.__esModule) return mod;
|
|
43
|
-
var result = {};
|
|
44
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
45
|
-
__setModuleDefault(result, mod);
|
|
46
|
-
return result;
|
|
47
|
-
};
|
|
48
|
-
})();
|
|
49
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
50
|
-
exports.PRReviewPoller = void 0;
|
|
51
|
-
const child_process_1 = require("child_process");
|
|
52
|
-
const util_1 = require("util");
|
|
53
|
-
const path_1 = require("path");
|
|
54
|
-
const os_1 = require("os");
|
|
55
|
-
const fs_1 = require("fs");
|
|
56
|
-
const promises_1 = require("fs/promises");
|
|
57
|
-
const debugLogger = __importStar(require("@jungjaehoon/mama-core/debug-logger"));
|
|
58
|
-
const message_splitter_js_1 = require("../gateways/message-splitter.js");
|
|
59
|
-
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
60
|
-
const { DebugLogger } = debugLogger;
|
|
61
|
-
/** Default polling interval (60 seconds) */
|
|
62
|
-
const POLL_INTERVAL_MS = 60 * 1000;
|
|
63
|
-
/** Reminder interval for unresolved threads (15 minutes) */
|
|
64
|
-
const REMIND_INTERVAL_MS = 15 * 60 * 1000;
|
|
65
|
-
/** Max polling duration before auto-stop (2 hours) */
|
|
66
|
-
const MAX_POLL_DURATION_MS = 2 * 60 * 60 * 1000;
|
|
67
|
-
/** Marker prefix for auto-reply comments */
|
|
68
|
-
const FIXED_REPLY_PREFIX = '✅ Fixed in';
|
|
69
|
-
/**
|
|
70
|
-
* PR Review Poller
|
|
71
|
-
*
|
|
72
|
-
* Watches GitHub PRs for new review comments and routes them to agents.
|
|
73
|
-
*/
|
|
74
|
-
class PRReviewPoller {
|
|
75
|
-
sessions = new Map();
|
|
76
|
-
messageSender = null;
|
|
77
|
-
onBatchItem = null;
|
|
78
|
-
onBatchComplete = null;
|
|
79
|
-
logger = new DebugLogger('PRReviewPoller');
|
|
80
|
-
/**
|
|
81
|
-
* Check if a message sender is already configured
|
|
82
|
-
*/
|
|
83
|
-
hasMessageSender() {
|
|
84
|
-
return this.messageSender !== null;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Set the message sender callback (Slack WebClient wrapper)
|
|
88
|
-
*/
|
|
89
|
-
setMessageSender(sender) {
|
|
90
|
-
this.messageSender = sender;
|
|
91
|
-
}
|
|
92
|
-
/**
|
|
93
|
-
* Set callback fired once per logical poll item (before chunking/sending).
|
|
94
|
-
* Useful for counting poll items and passing compact summaries upstream.
|
|
95
|
-
*/
|
|
96
|
-
setOnBatchItem(callback) {
|
|
97
|
-
this.onBatchItem = callback;
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Set callback fired after all message chunks for a poll cycle are sent.
|
|
101
|
-
* Used by handlers to trigger lead wake-up once per poll cycle (not per-chunk).
|
|
102
|
-
*/
|
|
103
|
-
setOnBatchComplete(callback) {
|
|
104
|
-
this.onBatchComplete = callback;
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Set the target agent's Slack user ID for @mentions in review messages
|
|
108
|
-
* (typically the orchestrator/Sisyphus, who analyzes and delegates to DevBot)
|
|
109
|
-
*/
|
|
110
|
-
targetAgentUserId;
|
|
111
|
-
setTargetAgentUserId(userId) {
|
|
112
|
-
this.targetAgentUserId = userId;
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Start polling a PR for review comments
|
|
116
|
-
*
|
|
117
|
-
* @param prUrl - GitHub PR URL (e.g., https://github.com/owner/repo/pull/14)
|
|
118
|
-
* @param channelId - Slack channel to post updates to
|
|
119
|
-
*/
|
|
120
|
-
async startPolling(prUrl, channelId) {
|
|
121
|
-
const parsed = this.parsePRUrl(prUrl);
|
|
122
|
-
if (!parsed) {
|
|
123
|
-
this.logger.error(`[PRPoller] Invalid PR URL: ${prUrl}`);
|
|
124
|
-
return false;
|
|
125
|
-
}
|
|
126
|
-
const sessionKey = `${parsed.owner}/${parsed.repo}#${parsed.prNumber}`;
|
|
127
|
-
// Already polling this PR
|
|
128
|
-
if (this.sessions.has(sessionKey)) {
|
|
129
|
-
this.logger.info(`[PRPoller] Already polling ${sessionKey}`);
|
|
130
|
-
return true;
|
|
131
|
-
}
|
|
132
|
-
// Load existing comments to avoid re-reporting
|
|
133
|
-
const seenCommentIds = new Set();
|
|
134
|
-
const seenReviewIds = new Set();
|
|
135
|
-
try {
|
|
136
|
-
const existingComments = await this.fetchComments(parsed.owner, parsed.repo, parsed.prNumber);
|
|
137
|
-
for (const c of existingComments) {
|
|
138
|
-
seenCommentIds.add(c.id);
|
|
139
|
-
}
|
|
140
|
-
const existingReviews = await this.fetchReviews(parsed.owner, parsed.repo, parsed.prNumber);
|
|
141
|
-
for (const r of existingReviews) {
|
|
142
|
-
seenReviewIds.add(r.id);
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
this.logger.error(`[PRPoller] Failed to load existing comments — aborting start:`, err);
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
// Fetch initial HEAD SHA
|
|
150
|
-
let lastHeadSha = null;
|
|
151
|
-
try {
|
|
152
|
-
lastHeadSha = await this.fetchHeadSha(parsed.owner, parsed.repo, parsed.prNumber);
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
// Non-critical, will be fetched on first poll
|
|
156
|
-
}
|
|
157
|
-
// Checkout PR branch before starting work (isolated workspace)
|
|
158
|
-
const workspaceRoot = process.env.MAMA_WORKSPACE || (0, path_1.join)((0, os_1.homedir)(), '.mama', 'workspace');
|
|
159
|
-
const workspaceDir = await this.prepareWorkspace(workspaceRoot, parsed);
|
|
160
|
-
try {
|
|
161
|
-
await this.ensureGhAuth();
|
|
162
|
-
await this.ensureRepo(workspaceDir, parsed.owner, parsed.repo);
|
|
163
|
-
await this.withWorkspaceLock(workspaceDir, async () => {
|
|
164
|
-
await execFileAsync('gh', ['pr', 'checkout', String(parsed.prNumber)], {
|
|
165
|
-
timeout: 30000,
|
|
166
|
-
cwd: workspaceDir,
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
this.logger.info(`[PRPoller] Checked out PR #${parsed.prNumber} branch in ${workspaceDir}`);
|
|
170
|
-
}
|
|
171
|
-
catch (err) {
|
|
172
|
-
this.logger.error(`[PRPoller] Failed to checkout PR branch — aborting start:`, err);
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
const session = {
|
|
176
|
-
...parsed,
|
|
177
|
-
channelId,
|
|
178
|
-
seenCommentIds,
|
|
179
|
-
seenReviewIds,
|
|
180
|
-
addressedCommentIds: new Set(),
|
|
181
|
-
seenUnresolvedThreadIds: new Map(),
|
|
182
|
-
lastUnresolvedReminderAt: 0,
|
|
183
|
-
lastHeadSha,
|
|
184
|
-
startedAt: Date.now(),
|
|
185
|
-
isPolling: false,
|
|
186
|
-
timeoutId: null,
|
|
187
|
-
workspaceDir,
|
|
188
|
-
};
|
|
189
|
-
this.sessions.set(sessionKey, session);
|
|
190
|
-
this.logger.info(`[PRPoller] Started polling ${sessionKey} (${seenCommentIds.size} existing comments, interval: ${POLL_INTERVAL_MS / 1000}s)`);
|
|
191
|
-
// Run first poll immediately, then schedule next
|
|
192
|
-
this.poll(sessionKey).catch((err) => {
|
|
193
|
-
this.logger.error(`[PRPoller] Initial poll error for ${sessionKey}:`, err);
|
|
194
|
-
});
|
|
195
|
-
return true;
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Stop polling a PR
|
|
199
|
-
*/
|
|
200
|
-
stopPolling(prUrl) {
|
|
201
|
-
const parsed = this.parsePRUrl(prUrl);
|
|
202
|
-
if (!parsed)
|
|
203
|
-
return;
|
|
204
|
-
const sessionKey = `${parsed.owner}/${parsed.repo}#${parsed.prNumber}`;
|
|
205
|
-
const session = this.sessions.get(sessionKey);
|
|
206
|
-
if (session) {
|
|
207
|
-
if (session.timeoutId) {
|
|
208
|
-
clearTimeout(session.timeoutId);
|
|
209
|
-
}
|
|
210
|
-
this.sessions.delete(sessionKey);
|
|
211
|
-
this.logger.info(`[PRPoller] Stopped polling ${sessionKey}`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
/**
|
|
215
|
-
* Stop all polling sessions
|
|
216
|
-
*/
|
|
217
|
-
stopAll() {
|
|
218
|
-
for (const [key, session] of this.sessions) {
|
|
219
|
-
if (session.timeoutId) {
|
|
220
|
-
clearTimeout(session.timeoutId);
|
|
221
|
-
}
|
|
222
|
-
this.logger.info(`[PRPoller] Stopped polling ${key}`);
|
|
223
|
-
}
|
|
224
|
-
this.sessions.clear();
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Get active polling sessions
|
|
228
|
-
*/
|
|
229
|
-
getActiveSessions() {
|
|
230
|
-
return Array.from(this.sessions.keys());
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Get session details for active polling sessions (for auto-commit)
|
|
234
|
-
*/
|
|
235
|
-
getSessionDetails() {
|
|
236
|
-
return Array.from(this.sessions.values()).map((s) => ({
|
|
237
|
-
owner: s.owner,
|
|
238
|
-
repo: s.repo,
|
|
239
|
-
prNumber: s.prNumber,
|
|
240
|
-
channelId: s.channelId,
|
|
241
|
-
workspaceDir: s.workspaceDir,
|
|
242
|
-
}));
|
|
243
|
-
}
|
|
244
|
-
/**
|
|
245
|
-
* Schedule the next poll cycle for a session
|
|
246
|
-
*/
|
|
247
|
-
scheduleNextPoll(sessionKey) {
|
|
248
|
-
const session = this.sessions.get(sessionKey);
|
|
249
|
-
if (!session)
|
|
250
|
-
return;
|
|
251
|
-
session.timeoutId = setTimeout(() => {
|
|
252
|
-
this.poll(sessionKey).catch((err) => {
|
|
253
|
-
this.logger.error(`[PRPoller] Poll error for ${sessionKey}:`, err);
|
|
254
|
-
});
|
|
255
|
-
}, POLL_INTERVAL_MS);
|
|
256
|
-
}
|
|
257
|
-
/**
|
|
258
|
-
* Poll a single PR for new comments/reviews
|
|
259
|
-
*/
|
|
260
|
-
async poll(sessionKey) {
|
|
261
|
-
const session = this.sessions.get(sessionKey);
|
|
262
|
-
if (!session)
|
|
263
|
-
return;
|
|
264
|
-
// Prevent concurrent polling
|
|
265
|
-
if (session.isPolling) {
|
|
266
|
-
this.logger.info(`[PRPoller] Skipping concurrent poll for ${sessionKey} (already in progress)`);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
session.isPolling = true;
|
|
270
|
-
// Track batch items discovered in this poll cycle.
|
|
271
|
-
const cycleDigest = {
|
|
272
|
-
items: [],
|
|
273
|
-
newItems: [],
|
|
274
|
-
reminderItems: [],
|
|
275
|
-
};
|
|
276
|
-
const seenItemIds = new Set();
|
|
277
|
-
const registerItem = async (text, item) => {
|
|
278
|
-
if (!this.addPollerBatchItem(session.channelId, item, cycleDigest, seenItemIds)) {
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
await this.sendMessage(session.channelId, text, item);
|
|
282
|
-
this.logger.info(`[PRPoller] Sent ${item.kind} item for ${sessionKey}${item.isReminder ? ' (reminder)' : ''}: ${item.id}`);
|
|
283
|
-
};
|
|
284
|
-
try {
|
|
285
|
-
// Auto-stop after max duration
|
|
286
|
-
if (Date.now() - session.startedAt > MAX_POLL_DURATION_MS) {
|
|
287
|
-
this.logger.info(`[PRPoller] Max duration reached for ${sessionKey}, stopping`);
|
|
288
|
-
if (session.timeoutId) {
|
|
289
|
-
clearTimeout(session.timeoutId);
|
|
290
|
-
}
|
|
291
|
-
this.sessions.delete(sessionKey);
|
|
292
|
-
await this.sendMessage(session.channelId, `⏰ *PR Review Poller* — ${sessionKey} auto-stopped after 2h. Re-post the PR URL to restart.`);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
// Check PR state first
|
|
296
|
-
try {
|
|
297
|
-
const prState = await this.fetchPRState(session.owner, session.repo, session.prNumber);
|
|
298
|
-
if (prState === 'MERGED' || prState === 'CLOSED') {
|
|
299
|
-
this.logger.info(`[PRPoller] PR ${sessionKey} is ${prState}, stopping`);
|
|
300
|
-
if (session.timeoutId) {
|
|
301
|
-
clearTimeout(session.timeoutId);
|
|
302
|
-
}
|
|
303
|
-
this.sessions.delete(sessionKey);
|
|
304
|
-
await this.sendMessage(session.channelId, `✅ *PR Review Poller* — ${sessionKey} ${prState}. Polling stopped.`);
|
|
305
|
-
return;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
catch {
|
|
309
|
-
// Ignore state check errors, continue polling
|
|
310
|
-
}
|
|
311
|
-
// Fetch new reviews
|
|
312
|
-
try {
|
|
313
|
-
const reviews = await this.fetchReviews(session.owner, session.repo, session.prNumber);
|
|
314
|
-
const newReviews = reviews.filter((r) => !session.seenReviewIds.has(r.id));
|
|
315
|
-
for (const review of newReviews) {
|
|
316
|
-
session.seenReviewIds.add(review.id);
|
|
317
|
-
if (review.state === 'APPROVED') {
|
|
318
|
-
const summary = `✅ *PR Review* — ${sessionKey} **APPROVED** by ${review.user.login}. Polling stopped.`;
|
|
319
|
-
const item = {
|
|
320
|
-
id: `${sessionKey}:review:${review.id}`,
|
|
321
|
-
kind: 'review',
|
|
322
|
-
severity: 'high',
|
|
323
|
-
summary,
|
|
324
|
-
isReminder: false,
|
|
325
|
-
};
|
|
326
|
-
try {
|
|
327
|
-
await registerItem(summary, item);
|
|
328
|
-
// Trigger onBatchComplete for follow-up workflow.
|
|
329
|
-
if (this.onBatchComplete) {
|
|
330
|
-
try {
|
|
331
|
-
await this.onBatchComplete(session.channelId, cycleDigest);
|
|
332
|
-
}
|
|
333
|
-
catch (err) {
|
|
334
|
-
this.logger.error(`[PRPoller] onBatchComplete error after APPROVE:`, err);
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
}
|
|
338
|
-
finally {
|
|
339
|
-
if (session.timeoutId) {
|
|
340
|
-
clearTimeout(session.timeoutId);
|
|
341
|
-
}
|
|
342
|
-
this.sessions.delete(sessionKey);
|
|
343
|
-
}
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
if (review.state === 'CHANGES_REQUESTED') {
|
|
347
|
-
const summary = `🔴 *PR Review* — ${sessionKey} **CHANGES REQUESTED** by ${review.user.login}`;
|
|
348
|
-
const item = {
|
|
349
|
-
id: `${sessionKey}:review:${review.id}`,
|
|
350
|
-
kind: 'review',
|
|
351
|
-
severity: 'high',
|
|
352
|
-
summary,
|
|
353
|
-
isReminder: false,
|
|
354
|
-
};
|
|
355
|
-
await registerItem(summary, item);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
catch (err) {
|
|
360
|
-
this.logger.error(`[PRPoller] Failed to fetch reviews:`, err);
|
|
361
|
-
}
|
|
362
|
-
// Detect new push (HEAD SHA changed)
|
|
363
|
-
let newPush = false;
|
|
364
|
-
let changedFiles = [];
|
|
365
|
-
try {
|
|
366
|
-
const currentSha = await this.fetchHeadSha(session.owner, session.repo, session.prNumber);
|
|
367
|
-
if (session.lastHeadSha && currentSha !== session.lastHeadSha) {
|
|
368
|
-
newPush = true;
|
|
369
|
-
changedFiles = await this.fetchChangedFiles(session.owner, session.repo, session.lastHeadSha, currentSha);
|
|
370
|
-
this.logger.info(`[PRPoller] New push detected for ${sessionKey}: ${session.lastHeadSha.substring(0, 7)} → ${currentSha.substring(0, 7)} (${changedFiles.length} files changed)`);
|
|
371
|
-
session.lastHeadSha = currentSha;
|
|
372
|
-
}
|
|
373
|
-
else if (!session.lastHeadSha) {
|
|
374
|
-
session.lastHeadSha = currentSha;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
catch (err) {
|
|
378
|
-
this.logger.error(`[PRPoller] Failed to detect push:`, err);
|
|
379
|
-
}
|
|
380
|
-
// Fetch comments once (avoid N+1 API calls between handlePostPush and standard flow)
|
|
381
|
-
let allComments = [];
|
|
382
|
-
try {
|
|
383
|
-
allComments = await this.fetchComments(session.owner, session.repo, session.prNumber);
|
|
384
|
-
}
|
|
385
|
-
catch (err) {
|
|
386
|
-
this.logger.error(`[PRPoller] Failed to fetch comments:`, err);
|
|
387
|
-
return; // Cannot proceed without comments
|
|
388
|
-
}
|
|
389
|
-
// After a push, check unresolved threads and auto-reply to addressed ones
|
|
390
|
-
if (newPush && changedFiles.length > 0) {
|
|
391
|
-
try {
|
|
392
|
-
await this.handlePostPush(session, changedFiles, allComments);
|
|
393
|
-
}
|
|
394
|
-
catch (err) {
|
|
395
|
-
this.logger.error(`[PRPoller] Failed to handle post-push:`, err);
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
// Standard flow: filter and send new comments
|
|
399
|
-
try {
|
|
400
|
-
const newComments = allComments.filter((c) => !session.seenCommentIds.has(c.id) && !session.addressedCommentIds.has(c.id));
|
|
401
|
-
if (newComments.length > 0) {
|
|
402
|
-
// Format and send new comments
|
|
403
|
-
const formatted = this.formatComments(sessionKey, newComments);
|
|
404
|
-
const mention = this.targetAgentUserId ? `<@${this.targetAgentUserId}> ` : '';
|
|
405
|
-
const summary = `${mention}${formatted}`;
|
|
406
|
-
const item = {
|
|
407
|
-
id: `${sessionKey}:comments:${newComments.map((comment) => comment.id).join(',')}`,
|
|
408
|
-
kind: 'comment',
|
|
409
|
-
severity: this.classifyCommentBatchSeverity(newComments),
|
|
410
|
-
summary: summary,
|
|
411
|
-
isReminder: false,
|
|
412
|
-
};
|
|
413
|
-
await registerItem(summary, item);
|
|
414
|
-
// Mark as seen only after successful send
|
|
415
|
-
for (const c of newComments) {
|
|
416
|
-
session.seenCommentIds.add(c.id);
|
|
417
|
-
}
|
|
418
|
-
this.logger.info(`[PRPoller] Sent ${newComments.length} new comments for ${sessionKey}`);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
catch (err) {
|
|
422
|
-
this.logger.error(`[PRPoller] Failed to process comments:`, err);
|
|
423
|
-
}
|
|
424
|
-
// Check unresolved threads every cycle (not just after push)
|
|
425
|
-
try {
|
|
426
|
-
const threads = await this.fetchUnresolvedThreads(session.owner, session.repo, session.prNumber);
|
|
427
|
-
const allUnresolved = threads.filter((t) => !t.isResolved);
|
|
428
|
-
const now = Date.now();
|
|
429
|
-
// Report unresolved threads only when they are first seen, and remind at bounded intervals.
|
|
430
|
-
const toReport = allUnresolved.filter((t) => {
|
|
431
|
-
const lastReported = session.seenUnresolvedThreadIds.get(t.id);
|
|
432
|
-
return !lastReported;
|
|
433
|
-
});
|
|
434
|
-
const needsReminder = toReport.length === 0 &&
|
|
435
|
-
now - session.lastUnresolvedReminderAt >= REMIND_INTERVAL_MS &&
|
|
436
|
-
allUnresolved.length > 0;
|
|
437
|
-
if (needsReminder) {
|
|
438
|
-
toReport.push(...allUnresolved.slice(0, 20));
|
|
439
|
-
}
|
|
440
|
-
if (toReport.length > 0) {
|
|
441
|
-
const isReminder = needsReminder;
|
|
442
|
-
const prefix = isReminder ? '🔔 *Reminder*: ' : '';
|
|
443
|
-
const formatted = this.formatUnresolvedThreads(sessionKey, toReport, threads.length);
|
|
444
|
-
const mention = this.targetAgentUserId ? `<@${this.targetAgentUserId}> ` : '';
|
|
445
|
-
const summary = `${mention}${prefix}${formatted}`;
|
|
446
|
-
const item = {
|
|
447
|
-
id: `${sessionKey}:threads:${toReport.map((thread) => thread.id).join(':')}`,
|
|
448
|
-
kind: 'thread',
|
|
449
|
-
severity: 'high',
|
|
450
|
-
summary,
|
|
451
|
-
isReminder,
|
|
452
|
-
};
|
|
453
|
-
await registerItem(summary, item);
|
|
454
|
-
for (const t of toReport) {
|
|
455
|
-
session.seenUnresolvedThreadIds.set(t.id, now);
|
|
456
|
-
}
|
|
457
|
-
// Update lastUnresolvedReminderAt on all notifications (not just reminders)
|
|
458
|
-
// to prevent immediate reminder on the next poll after initial report
|
|
459
|
-
session.lastUnresolvedReminderAt = now;
|
|
460
|
-
this.logger.info(`[PRPoller] Sent ${toReport.length} unresolved threads for ${sessionKey}${isReminder ? ' (reminder)' : ''}`);
|
|
461
|
-
}
|
|
462
|
-
// Clean up resolved threads from seen map
|
|
463
|
-
const unresolvedIds = new Set(allUnresolved.map((t) => t.id));
|
|
464
|
-
for (const [id] of session.seenUnresolvedThreadIds) {
|
|
465
|
-
if (!unresolvedIds.has(id)) {
|
|
466
|
-
session.seenUnresolvedThreadIds.delete(id);
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
catch (err) {
|
|
471
|
-
this.logger.error(`[PRPoller] Failed to check unresolved threads:`, err);
|
|
472
|
-
}
|
|
473
|
-
// Notify batch complete — triggers agent processing once after all chunks (only when new data found)
|
|
474
|
-
if (cycleDigest.newItems.length + cycleDigest.reminderItems.length > 0 &&
|
|
475
|
-
this.onBatchComplete) {
|
|
476
|
-
try {
|
|
477
|
-
await this.onBatchComplete(session.channelId, cycleDigest);
|
|
478
|
-
}
|
|
479
|
-
catch (err) {
|
|
480
|
-
this.logger.error(`[PRPoller] onBatchComplete error:`, err);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
finally {
|
|
485
|
-
// Always reset polling flag to allow next poll
|
|
486
|
-
session.isPolling = false;
|
|
487
|
-
// Schedule next poll if session still exists
|
|
488
|
-
if (this.sessions.has(sessionKey)) {
|
|
489
|
-
this.scheduleNextPoll(sessionKey);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Format PR comments with full details grouped by file.
|
|
495
|
-
* Enables parallel delegation — independent files can be fixed simultaneously.
|
|
496
|
-
*/
|
|
497
|
-
classifyCommentBatchSeverity(comments) {
|
|
498
|
-
const hasFixHint = comments.some((comment) => comment.body.toLowerCase().includes('must') || comment.body.toLowerCase().includes('fail'));
|
|
499
|
-
return hasFixHint ? 'high' : comments.length > 2 ? 'medium' : 'low';
|
|
500
|
-
}
|
|
501
|
-
addPollerBatchItem(channelId, item, digest, seenItemIds) {
|
|
502
|
-
if (seenItemIds.has(item.id)) {
|
|
503
|
-
return false;
|
|
504
|
-
}
|
|
505
|
-
seenItemIds.add(item.id);
|
|
506
|
-
digest.items.push(item);
|
|
507
|
-
if (item.isReminder) {
|
|
508
|
-
digest.reminderItems.push(item);
|
|
509
|
-
}
|
|
510
|
-
else {
|
|
511
|
-
digest.newItems.push(item);
|
|
512
|
-
}
|
|
513
|
-
if (this.onBatchItem) {
|
|
514
|
-
this.onBatchItem(channelId, this.extractBatchSummary(item.summary), item).catch((err) => {
|
|
515
|
-
this.logger.error('[PRPoller] Failed to report batch item:', err);
|
|
516
|
-
});
|
|
517
|
-
}
|
|
518
|
-
return true;
|
|
519
|
-
}
|
|
520
|
-
formatComments(sessionKey, comments) {
|
|
521
|
-
// Group by file
|
|
522
|
-
const byFile = new Map();
|
|
523
|
-
for (const c of comments) {
|
|
524
|
-
const key = c.path || '(general)';
|
|
525
|
-
const list = byFile.get(key) || [];
|
|
526
|
-
list.push(c);
|
|
527
|
-
byFile.set(key, list);
|
|
528
|
-
}
|
|
529
|
-
const lines = [
|
|
530
|
-
`📝 PR ${sessionKey} — ${comments.length} new review comments across ${byFile.size} file(s)`,
|
|
531
|
-
'',
|
|
532
|
-
];
|
|
533
|
-
for (const [file, fileComments] of byFile) {
|
|
534
|
-
lines.push(`**${file}**`);
|
|
535
|
-
for (const c of fileComments) {
|
|
536
|
-
const lineRef = c.line ? `:${c.line}` : '';
|
|
537
|
-
const body = c.body.length > 200 ? c.body.substring(0, 200) + '…' : c.body;
|
|
538
|
-
lines.push(` • L${lineRef} ${body}`);
|
|
539
|
-
}
|
|
540
|
-
lines.push('');
|
|
541
|
-
}
|
|
542
|
-
if (byFile.size > 1) {
|
|
543
|
-
lines.push(`💡 ${byFile.size} files — delegate in parallel when independent; keep coupled changes together (DELEGATE_BG)`);
|
|
544
|
-
}
|
|
545
|
-
return lines.join('\n');
|
|
546
|
-
}
|
|
547
|
-
/**
|
|
548
|
-
* Fetch PR review comments via gh API
|
|
549
|
-
*/
|
|
550
|
-
async fetchComments(owner, repo, prNumber) {
|
|
551
|
-
const { stdout } = await execFileAsync('gh', [
|
|
552
|
-
'api',
|
|
553
|
-
'--paginate',
|
|
554
|
-
`repos/${owner}/${repo}/pulls/${prNumber}/comments`,
|
|
555
|
-
'--jq',
|
|
556
|
-
'.[] | {id, path, line, body, user: {login: .user.login}, created_at}',
|
|
557
|
-
], { timeout: 120000, maxBuffer: 10 * 1024 * 1024 });
|
|
558
|
-
if (!stdout.trim())
|
|
559
|
-
return [];
|
|
560
|
-
const lines = stdout.trim().split('\n').filter(Boolean);
|
|
561
|
-
return lines.map((line) => JSON.parse(line));
|
|
562
|
-
}
|
|
563
|
-
/**
|
|
564
|
-
* Fetch PR reviews via gh API
|
|
565
|
-
*/
|
|
566
|
-
async fetchReviews(owner, repo, prNumber) {
|
|
567
|
-
const { stdout } = await execFileAsync('gh', [
|
|
568
|
-
'api',
|
|
569
|
-
`repos/${owner}/${repo}/pulls/${prNumber}/reviews`,
|
|
570
|
-
'--jq',
|
|
571
|
-
'[.[] | {id, state, user: {login: .user.login}, submitted_at}]',
|
|
572
|
-
], { timeout: 15000 });
|
|
573
|
-
return JSON.parse(stdout || '[]');
|
|
574
|
-
}
|
|
575
|
-
/**
|
|
576
|
-
* Handle post-push: check unresolved threads, auto-reply to addressed ones,
|
|
577
|
-
* and keep unresolved-thread state fresh for the next poll cycle.
|
|
578
|
-
*
|
|
579
|
-
* @param allComments - Pre-fetched comments (to avoid N+1 API calls)
|
|
580
|
-
*/
|
|
581
|
-
async handlePostPush(session, changedFiles, allComments) {
|
|
582
|
-
const changedFileSet = new Set(changedFiles);
|
|
583
|
-
// Fetch unresolved threads via GraphQL
|
|
584
|
-
const threads = await this.fetchUnresolvedThreads(session.owner, session.repo, session.prNumber);
|
|
585
|
-
const addressed = [];
|
|
586
|
-
const stillUnresolved = [];
|
|
587
|
-
for (const thread of threads) {
|
|
588
|
-
if (thread.isResolved)
|
|
589
|
-
continue;
|
|
590
|
-
// Check if any comment in the thread has our "Fixed" reply already
|
|
591
|
-
const hasFixedReply = thread.comments.some((c) => c.body.startsWith(FIXED_REPLY_PREFIX));
|
|
592
|
-
if (hasFixedReply)
|
|
593
|
-
continue;
|
|
594
|
-
// Check if the file was changed in the latest push
|
|
595
|
-
const threadPath = thread.comments[0]?.path;
|
|
596
|
-
if (threadPath && changedFileSet.has(threadPath)) {
|
|
597
|
-
addressed.push(thread);
|
|
598
|
-
}
|
|
599
|
-
else {
|
|
600
|
-
stillUnresolved.push(thread);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
// Auto-reply to addressed threads
|
|
604
|
-
if (addressed.length > 0) {
|
|
605
|
-
const shortSha = session.lastHeadSha?.substring(0, 7) ?? 'latest';
|
|
606
|
-
// Use pre-fetched comments (passed as parameter to avoid N+1 API calls)
|
|
607
|
-
for (const thread of addressed) {
|
|
608
|
-
try {
|
|
609
|
-
await this.replyToThread(session.owner, session.repo, session.prNumber, thread, `${FIXED_REPLY_PREFIX} ${shortSha}`, allComments);
|
|
610
|
-
// Mark all comments in this thread as addressed (by matching path/line)
|
|
611
|
-
const threadPath = thread.comments[0]?.path;
|
|
612
|
-
const threadLine = thread.comments[0]?.line;
|
|
613
|
-
if (threadPath) {
|
|
614
|
-
for (const comment of allComments) {
|
|
615
|
-
if (comment.path === threadPath && (!threadLine || comment.line === threadLine)) {
|
|
616
|
-
session.addressedCommentIds.add(comment.id);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
}
|
|
621
|
-
catch (err) {
|
|
622
|
-
this.logger.error(`[PRPoller] Failed to reply to thread:`, err);
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
this.logger.info(`[PRPoller] Auto-replied to ${addressed.length} addressed threads for ${session.owner}/${session.repo}#${session.prNumber}`);
|
|
626
|
-
}
|
|
627
|
-
if (stillUnresolved.length > 0) {
|
|
628
|
-
this.logger.info(`[PRPoller] Detected ${stillUnresolved.length} still-unresolved threads in push context for ${session.owner}/${session.repo}#${session.prNumber}`);
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
/**
|
|
632
|
-
* Format unresolved threads with details grouped by file.
|
|
633
|
-
*/
|
|
634
|
-
formatUnresolvedThreads(sessionKey, threads, totalThreads) {
|
|
635
|
-
const resolvedCount = totalThreads !== undefined ? totalThreads - threads.length : 0;
|
|
636
|
-
const header = `⚠️ PR ${sessionKey} — ${threads.length} unresolved thread(s)${totalThreads !== undefined ? ` (${resolvedCount} resolved)` : ''}`;
|
|
637
|
-
// Group by file
|
|
638
|
-
const byFile = new Map();
|
|
639
|
-
for (const t of threads) {
|
|
640
|
-
const file = t.comments[0]?.path || '(general)';
|
|
641
|
-
const list = byFile.get(file) || [];
|
|
642
|
-
list.push(t);
|
|
643
|
-
byFile.set(file, list);
|
|
644
|
-
}
|
|
645
|
-
const lines = [header, ''];
|
|
646
|
-
for (const [file, fileThreads] of byFile) {
|
|
647
|
-
lines.push(`**${file}**`);
|
|
648
|
-
for (const t of fileThreads) {
|
|
649
|
-
const first = t.comments[0];
|
|
650
|
-
if (!first)
|
|
651
|
-
continue;
|
|
652
|
-
const lineRef = first.line ? `:${first.line}` : '';
|
|
653
|
-
const body = first.body.length > 200 ? first.body.substring(0, 200) + '…' : first.body;
|
|
654
|
-
lines.push(` • L${lineRef} ${body}`);
|
|
655
|
-
}
|
|
656
|
-
lines.push('');
|
|
657
|
-
}
|
|
658
|
-
if (byFile.size > 1) {
|
|
659
|
-
lines.push(`💡 ${byFile.size} files — delegate in parallel when independent; keep coupled changes together (DELEGATE_BG)`);
|
|
660
|
-
}
|
|
661
|
-
return lines.join('\n');
|
|
662
|
-
}
|
|
663
|
-
/**
|
|
664
|
-
* Fetch unresolved review threads via GitHub GraphQL API
|
|
665
|
-
*/
|
|
666
|
-
async fetchUnresolvedThreads(owner, repo, prNumber) {
|
|
667
|
-
const query = `
|
|
668
|
-
query($owner: String!, $repo: String!, $prNumber: Int!) {
|
|
669
|
-
repository(owner: $owner, name: $repo) {
|
|
670
|
-
pullRequest(number: $prNumber) {
|
|
671
|
-
reviewThreads(last: 100) {
|
|
672
|
-
totalCount
|
|
673
|
-
nodes {
|
|
674
|
-
id
|
|
675
|
-
isResolved
|
|
676
|
-
comments(first: 10) {
|
|
677
|
-
totalCount
|
|
678
|
-
nodes {
|
|
679
|
-
path
|
|
680
|
-
line
|
|
681
|
-
body
|
|
682
|
-
author { login }
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
`;
|
|
691
|
-
const { stdout } = await execFileAsync('gh', [
|
|
692
|
-
'api',
|
|
693
|
-
'graphql',
|
|
694
|
-
'-f',
|
|
695
|
-
`query=${query}`,
|
|
696
|
-
'-F',
|
|
697
|
-
`owner=${owner}`,
|
|
698
|
-
'-F',
|
|
699
|
-
`repo=${repo}`,
|
|
700
|
-
'-F',
|
|
701
|
-
`prNumber=${prNumber}`,
|
|
702
|
-
'--jq',
|
|
703
|
-
'.data.repository.pullRequest.reviewThreads | {totalCount, nodes}',
|
|
704
|
-
], { timeout: 20000 });
|
|
705
|
-
const result = JSON.parse(stdout || '{"totalCount":0,"nodes":[]}');
|
|
706
|
-
const nodes = result.nodes || [];
|
|
707
|
-
// Warn if GraphQL pagination caps are hit
|
|
708
|
-
if (result.totalCount > 100) {
|
|
709
|
-
this.logger.warn(`[PRPoller] Warning: PR has ${result.totalCount} review threads but only 100 fetched`);
|
|
710
|
-
}
|
|
711
|
-
return nodes.map((node) => ({
|
|
712
|
-
id: node.id,
|
|
713
|
-
isResolved: node.isResolved,
|
|
714
|
-
comments: (node.comments?.nodes || []).map((c) => ({
|
|
715
|
-
path: c.path,
|
|
716
|
-
line: c.line,
|
|
717
|
-
body: c.body,
|
|
718
|
-
author: c.author?.login ?? 'unknown',
|
|
719
|
-
})),
|
|
720
|
-
}));
|
|
721
|
-
}
|
|
722
|
-
/**
|
|
723
|
-
* Reply to a review thread (uses the first comment's ID as in_reply_to)
|
|
724
|
-
*/
|
|
725
|
-
async replyToThread(owner, repo, prNumber, thread, body, cachedComments) {
|
|
726
|
-
// GraphQL: addPullRequestReviewComment is complex.
|
|
727
|
-
// Simpler: use REST API to reply to the thread's first comment.
|
|
728
|
-
// We need the REST comment ID, but we have GraphQL ID.
|
|
729
|
-
// Fetch comments and match by path+body to find the REST ID.
|
|
730
|
-
const comments = cachedComments ?? (await this.fetchComments(owner, repo, prNumber));
|
|
731
|
-
const firstThreadComment = thread.comments[0];
|
|
732
|
-
if (!firstThreadComment)
|
|
733
|
-
return;
|
|
734
|
-
const matching = comments.find((c) => c.path === firstThreadComment.path && c.body === firstThreadComment.body);
|
|
735
|
-
if (!matching)
|
|
736
|
-
return;
|
|
737
|
-
await execFileAsync('gh', [
|
|
738
|
-
'api',
|
|
739
|
-
`repos/${owner}/${repo}/pulls/${prNumber}/comments`,
|
|
740
|
-
'-X',
|
|
741
|
-
'POST',
|
|
742
|
-
'-f',
|
|
743
|
-
`body=${body}`,
|
|
744
|
-
'-F',
|
|
745
|
-
`in_reply_to=${matching.id}`,
|
|
746
|
-
], { timeout: 15000 });
|
|
747
|
-
}
|
|
748
|
-
/**
|
|
749
|
-
* Fetch HEAD commit SHA of the PR branch
|
|
750
|
-
*/
|
|
751
|
-
async fetchHeadSha(owner, repo, prNumber) {
|
|
752
|
-
const { stdout } = await execFileAsync('gh', ['api', `repos/${owner}/${repo}/pulls/${prNumber}`, '--jq', '.head.sha'], { timeout: 10000 });
|
|
753
|
-
return stdout.trim();
|
|
754
|
-
}
|
|
755
|
-
/**
|
|
756
|
-
* Fetch list of files changed between two commits
|
|
757
|
-
*/
|
|
758
|
-
async fetchChangedFiles(owner, repo, baseSha, headSha) {
|
|
759
|
-
const { stdout } = await execFileAsync('gh', [
|
|
760
|
-
'api',
|
|
761
|
-
`repos/${owner}/${repo}/compare/${baseSha}...${headSha}`,
|
|
762
|
-
'--jq',
|
|
763
|
-
'[.files[].filename]',
|
|
764
|
-
], { timeout: 15000 });
|
|
765
|
-
return JSON.parse(stdout || '[]');
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* Fetch PR state (OPEN, MERGED, CLOSED)
|
|
769
|
-
*/
|
|
770
|
-
async fetchPRState(owner, repo, prNumber) {
|
|
771
|
-
const { stdout } = await execFileAsync('gh', [
|
|
772
|
-
'api',
|
|
773
|
-
`repos/${owner}/${repo}/pulls/${prNumber}`,
|
|
774
|
-
'--jq',
|
|
775
|
-
'.state + (if .merged then "_MERGED" else "" end)',
|
|
776
|
-
], { timeout: 10000 });
|
|
777
|
-
const state = stdout.trim();
|
|
778
|
-
if (state.includes('MERGED'))
|
|
779
|
-
return 'MERGED';
|
|
780
|
-
return state.toUpperCase(); // "open" → "OPEN", "closed" → "CLOSED"
|
|
781
|
-
}
|
|
782
|
-
async prepareWorkspace(workspaceRoot, parsed) {
|
|
783
|
-
const sanitize = (value) => value.replace(/[^a-zA-Z0-9_.-]/g, '-');
|
|
784
|
-
const slug = `${sanitize(parsed.owner)}-${sanitize(parsed.repo)}-pr-${parsed.prNumber}`;
|
|
785
|
-
const workspaceDir = (0, path_1.join)(workspaceRoot, 'pr-reviews', slug);
|
|
786
|
-
await (0, promises_1.mkdir)(workspaceDir, { recursive: true });
|
|
787
|
-
return workspaceDir;
|
|
788
|
-
}
|
|
789
|
-
async ensureGhAuth() {
|
|
790
|
-
try {
|
|
791
|
-
await execFileAsync('gh', ['auth', 'status', '-h', 'github.com'], { timeout: 10000 });
|
|
792
|
-
}
|
|
793
|
-
catch (err) {
|
|
794
|
-
throw new Error(`GitHub CLI not authenticated. Run "gh auth login". ${String(err)}`);
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
async ensureRepo(workspaceDir, owner, repo) {
|
|
798
|
-
const gitDir = (0, path_1.join)(workspaceDir, '.git');
|
|
799
|
-
if (!(0, fs_1.existsSync)(gitDir)) {
|
|
800
|
-
await execFileAsync('gh', ['repo', 'clone', `${owner}/${repo}`, '.'], {
|
|
801
|
-
timeout: 60000,
|
|
802
|
-
cwd: workspaceDir,
|
|
803
|
-
});
|
|
804
|
-
// Set default git identity for new clones
|
|
805
|
-
await this.setGitIdentity(workspaceDir);
|
|
806
|
-
return;
|
|
807
|
-
}
|
|
808
|
-
const remoteUrl = await this.getRemoteUrl(workspaceDir);
|
|
809
|
-
if (!remoteUrl || !this.matchesRepo(remoteUrl, owner, repo)) {
|
|
810
|
-
throw new Error(`Workspace repo mismatch. Expected ${owner}/${repo} but found ${remoteUrl || 'unknown'}`);
|
|
811
|
-
}
|
|
812
|
-
// Ensure git identity is set for existing repos too
|
|
813
|
-
await this.setGitIdentity(workspaceDir);
|
|
814
|
-
}
|
|
815
|
-
/**
|
|
816
|
-
* Git identity configuration for PR workspaces
|
|
817
|
-
* Can be overridden per-agent via setAgentGitIdentity
|
|
818
|
-
*/
|
|
819
|
-
gitIdentity = {
|
|
820
|
-
name: 'MAMA Bot',
|
|
821
|
-
email: 'mama-bot@mama-os.local',
|
|
822
|
-
};
|
|
823
|
-
/**
|
|
824
|
-
* Set git identity for a specific agent (called before agent starts work)
|
|
825
|
-
*/
|
|
826
|
-
setAgentGitIdentity(identity) {
|
|
827
|
-
this.gitIdentity = identity;
|
|
828
|
-
this.logger.info(`[PRPoller] Git identity set to: ${identity.name} <${identity.email}>`);
|
|
829
|
-
}
|
|
830
|
-
/**
|
|
831
|
-
* Set git identity in workspace
|
|
832
|
-
*/
|
|
833
|
-
async setGitIdentity(workspaceDir) {
|
|
834
|
-
try {
|
|
835
|
-
await execFileAsync('git', ['config', 'user.name', this.gitIdentity.name], {
|
|
836
|
-
cwd: workspaceDir,
|
|
837
|
-
timeout: 5000,
|
|
838
|
-
});
|
|
839
|
-
await execFileAsync('git', ['config', 'user.email', this.gitIdentity.email], {
|
|
840
|
-
cwd: workspaceDir,
|
|
841
|
-
timeout: 5000,
|
|
842
|
-
});
|
|
843
|
-
this.logger.debug(`[PRPoller] Set git identity: ${this.gitIdentity.name} <${this.gitIdentity.email}>`);
|
|
844
|
-
}
|
|
845
|
-
catch (err) {
|
|
846
|
-
this.logger.warn(`[PRPoller] Failed to set git identity:`, err);
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
async getRemoteUrl(workspaceDir) {
|
|
850
|
-
try {
|
|
851
|
-
const { stdout } = await execFileAsync('git', ['config', '--get', 'remote.origin.url'], {
|
|
852
|
-
timeout: 10000,
|
|
853
|
-
cwd: workspaceDir,
|
|
854
|
-
});
|
|
855
|
-
const url = stdout.trim();
|
|
856
|
-
return url || null;
|
|
857
|
-
}
|
|
858
|
-
catch {
|
|
859
|
-
return null;
|
|
860
|
-
}
|
|
861
|
-
}
|
|
862
|
-
matchesRepo(remoteUrl, owner, repo) {
|
|
863
|
-
const match = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/);
|
|
864
|
-
if (!match)
|
|
865
|
-
return false;
|
|
866
|
-
return match[1] === `${owner}/${repo}`;
|
|
867
|
-
}
|
|
868
|
-
async withWorkspaceLock(workspaceDir, task) {
|
|
869
|
-
const lockPath = (0, path_1.join)(workspaceDir, '.mama-pr-review.lock');
|
|
870
|
-
const startedAt = Date.now();
|
|
871
|
-
const timeoutMs = 30_000;
|
|
872
|
-
const maxLockAgeMs = 2 * 60 * 1000;
|
|
873
|
-
let acquired = false;
|
|
874
|
-
while (!acquired) {
|
|
875
|
-
try {
|
|
876
|
-
await (0, promises_1.writeFile)(lockPath, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }), { flag: 'wx' });
|
|
877
|
-
acquired = true;
|
|
878
|
-
}
|
|
879
|
-
catch (err) {
|
|
880
|
-
if (err.code !== 'EEXIST') {
|
|
881
|
-
throw err;
|
|
882
|
-
}
|
|
883
|
-
let isStale = true;
|
|
884
|
-
try {
|
|
885
|
-
const existing = await (0, promises_1.readFile)(lockPath, 'utf8');
|
|
886
|
-
const parsed = JSON.parse(existing);
|
|
887
|
-
const lockPid = typeof parsed.pid === 'number' ? parsed.pid : null;
|
|
888
|
-
const lockStarted = parsed.startedAt ? Date.parse(parsed.startedAt) : NaN;
|
|
889
|
-
const lockAge = Number.isFinite(lockStarted) ? Date.now() - lockStarted : Infinity;
|
|
890
|
-
if (lockPid) {
|
|
891
|
-
try {
|
|
892
|
-
process.kill(lockPid, 0);
|
|
893
|
-
isStale = lockAge > maxLockAgeMs;
|
|
894
|
-
}
|
|
895
|
-
catch (pidErr) {
|
|
896
|
-
isStale = pidErr.code === 'ESRCH';
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
catch {
|
|
901
|
-
isStale = true;
|
|
902
|
-
}
|
|
903
|
-
if (isStale) {
|
|
904
|
-
await (0, promises_1.unlink)(lockPath).catch(() => undefined);
|
|
905
|
-
continue;
|
|
906
|
-
}
|
|
907
|
-
if (Date.now() - startedAt > timeoutMs) {
|
|
908
|
-
const existing = await (0, promises_1.readFile)(lockPath, 'utf8').catch(() => '');
|
|
909
|
-
throw new Error(`Workspace lock timed out. Lock info: ${existing || 'unknown'}`);
|
|
910
|
-
}
|
|
911
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
912
|
-
}
|
|
913
|
-
}
|
|
914
|
-
try {
|
|
915
|
-
return await task();
|
|
916
|
-
}
|
|
917
|
-
finally {
|
|
918
|
-
await (0, promises_1.unlink)(lockPath).catch(() => undefined);
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
/**
|
|
922
|
-
* Parse GitHub PR URL into owner/repo/number
|
|
923
|
-
*/
|
|
924
|
-
parsePRUrl(url) {
|
|
925
|
-
// Match: https://github.com/owner/repo/pull/123 (with anchors to prevent partial matches)
|
|
926
|
-
const match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/);
|
|
927
|
-
if (!match)
|
|
928
|
-
return null;
|
|
929
|
-
return {
|
|
930
|
-
owner: match[1],
|
|
931
|
-
repo: match[2],
|
|
932
|
-
prNumber: parseInt(match[3], 10),
|
|
933
|
-
};
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Detect PR URLs in message text
|
|
937
|
-
*/
|
|
938
|
-
static extractPRUrls(text) {
|
|
939
|
-
const pattern = /https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+/g;
|
|
940
|
-
return text.match(pattern) || [];
|
|
941
|
-
}
|
|
942
|
-
/**
|
|
943
|
-
* Send message to Slack via callback
|
|
944
|
-
*/
|
|
945
|
-
async sendMessage(channelId, text, _batchItem) {
|
|
946
|
-
if (!this.messageSender) {
|
|
947
|
-
this.logger.error('[PRPoller] No message sender configured');
|
|
948
|
-
throw new Error('[PRPoller] No message sender configured');
|
|
949
|
-
}
|
|
950
|
-
// Discord has a 2000 char limit; split long messages
|
|
951
|
-
const chunks = (0, message_splitter_js_1.splitForDiscord)(text);
|
|
952
|
-
this.logger.info(`[PRPoller] sendMessage split into ${chunks.length} chunk(s)`);
|
|
953
|
-
for (const chunk of chunks) {
|
|
954
|
-
await this.messageSender(channelId, chunk);
|
|
955
|
-
}
|
|
956
|
-
}
|
|
957
|
-
/**
|
|
958
|
-
* Build a compact, single-line summary for orchestrator wake-up prompts.
|
|
959
|
-
* Keep the text intentionally short to avoid PR wake-up context bloat.
|
|
960
|
-
*/
|
|
961
|
-
extractBatchSummary(text) {
|
|
962
|
-
const stripped = text
|
|
963
|
-
.replace(/<@[^>]+>\s*/g, '')
|
|
964
|
-
.replace(/\*\*/g, '')
|
|
965
|
-
.trim()
|
|
966
|
-
.replace(/\s+/g, ' ');
|
|
967
|
-
const maxLength = 320;
|
|
968
|
-
return stripped.length > maxLength ? `${stripped.slice(0, maxLength)}…` : stripped;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
exports.PRReviewPoller = PRReviewPoller;
|
|
972
|
-
//# sourceMappingURL=pr-review-poller.js.map
|