@atolis-hq/wake 0.2.43 → 0.2.44

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.
@@ -194,6 +194,37 @@ export function createGitHubClient(token) {
194
194
  }),
195
195
  });
196
196
  },
197
+ async listPullRequestFiles(owner, repo, pullNumber) {
198
+ return fetchPaginatedWithEtag({
199
+ cache: etagCache,
200
+ cacheKey: `pr-files:${owner}/${repo}#${pullNumber}`,
201
+ pages: (headers) => octokit.paginate.iterator(octokit.rest.pulls.listFiles, {
202
+ owner,
203
+ repo,
204
+ pull_number: pullNumber,
205
+ per_page: 100,
206
+ ...(headers === undefined ? {} : { headers }),
207
+ }),
208
+ });
209
+ },
210
+ async createPullRequestApproval(owner, repo, pullNumber, body) {
211
+ await octokit.rest.pulls.createReview({
212
+ owner,
213
+ repo,
214
+ pull_number: pullNumber,
215
+ event: 'APPROVE',
216
+ body,
217
+ });
218
+ },
219
+ async enablePullRequestAutoMerge(pullRequestNodeId) {
220
+ await octokit.graphql(`mutation EnableWakeAutoMerge($pullRequestId: ID!) {
221
+ enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId }) {
222
+ pullRequest {
223
+ id
224
+ }
225
+ }
226
+ }`, { pullRequestId: pullRequestNodeId });
227
+ },
197
228
  async replyToReviewComment(owner, repo, pullNumber, commentId, body) {
198
229
  return octokit.rest.pulls.createReplyForReviewComment({
199
230
  owner,
@@ -0,0 +1,29 @@
1
+ function parseGithubPullRequestResourceUri(resourceUri) {
2
+ const match = /^github:pr:([^/]+)\/([^#]+)#(\d+)$/.exec(resourceUri);
3
+ if (match === null) {
4
+ throw new Error(`Unsupported GitHub pull request resource URI: ${resourceUri}`);
5
+ }
6
+ return {
7
+ owner: match[1],
8
+ repo: match[2],
9
+ pullNumber: Number(match[3]),
10
+ };
11
+ }
12
+ export function createGitHubPullRequestMergeActor(input) {
13
+ return {
14
+ async listChangedFiles(resourceUri) {
15
+ const ref = parseGithubPullRequestResourceUri(resourceUri);
16
+ const files = await input.client.listPullRequestFiles(ref.owner, ref.repo, ref.pullNumber);
17
+ return files.map((file) => file.filename);
18
+ },
19
+ async approve(resourceUri, body) {
20
+ const ref = parseGithubPullRequestResourceUri(resourceUri);
21
+ await input.client.createPullRequestApproval(ref.owner, ref.repo, ref.pullNumber, body);
22
+ },
23
+ async enableAutoMerge(resourceUri) {
24
+ const ref = parseGithubPullRequestResourceUri(resourceUri);
25
+ const pr = await input.client.getPullRequest(ref.owner, ref.repo, ref.pullNumber);
26
+ await input.client.enablePullRequestAutoMerge(pr.node_id);
27
+ },
28
+ };
29
+ }
@@ -188,7 +188,13 @@ export function createPolicyEngine() {
188
188
  if (latestComment?.isBotAuthored === true &&
189
189
  latestComment.resourceUri !== undefined &&
190
190
  latestComment.body.includes(prReviewApprovalMarker)) {
191
- return { approved: true, pendingAction };
191
+ return {
192
+ approved: true,
193
+ pendingAction,
194
+ targetResourceUri: latestComment.resourceUri,
195
+ triggeringCommentId: latestComment.id,
196
+ triggeringCommentBody: latestComment.body,
197
+ };
192
198
  }
193
199
  if (latestHumanComment === undefined) {
194
200
  return null;
@@ -110,6 +110,202 @@ export function createTickRunner(deps) {
110
110
  function hasLabel(projection, label) {
111
111
  return projection.issue.labels.includes(label);
112
112
  }
113
+ function approvedMergePolicyForStage(stage) {
114
+ return (stage?.watch?.find((watch) => watch.onApproved?.merge !== undefined)?.onApproved?.merge ??
115
+ null);
116
+ }
117
+ function escapeRegex(input) {
118
+ return input.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
119
+ }
120
+ function globPatternMatches(pattern, path) {
121
+ const regex = new RegExp(`^${pattern.split('*').map(escapeRegex).join('.*')}$`);
122
+ return regex.test(path);
123
+ }
124
+ function reviewerMessageFromApprovalComment(body) {
125
+ return body
126
+ .replaceAll(prReviewApprovalMarker, '')
127
+ .replaceAll('<!-- wake:pr-review-changes-requested -->', '')
128
+ .trim();
129
+ }
130
+ async function evaluatePrMergeRisk(input) {
131
+ const incumbent = await deps.resourceIndex.resolve(input.targetResourceUri);
132
+ if (incumbent !== undefined && incumbent !== input.projection.workItemKey) {
133
+ return {
134
+ passed: false,
135
+ reason: `Merge policy blocked this PR because ${input.targetResourceUri} is primary-correlated to ${incumbent}, not ${input.projection.workItemKey}.`,
136
+ };
137
+ }
138
+ const blockedLabel = input.policy.blockedLabels.find((label) => input.projection.issue.labels.includes(label));
139
+ if (blockedLabel !== undefined) {
140
+ return {
141
+ passed: false,
142
+ reason: `Merge policy blocked this PR because the work item has blocked label "${blockedLabel}".`,
143
+ };
144
+ }
145
+ if (deps.prMergeActor === undefined) {
146
+ return {
147
+ passed: false,
148
+ reason: 'Merge policy blocked this PR because no pull request merge actor is configured.',
149
+ };
150
+ }
151
+ const filesChanged = await deps.prMergeActor.listChangedFiles(input.targetResourceUri);
152
+ if (input.policy.maxFilesChanged !== undefined &&
153
+ filesChanged.length > input.policy.maxFilesChanged) {
154
+ return {
155
+ passed: false,
156
+ reason: `Merge policy blocked this PR because it changes ${filesChanged.length} files, exceeding maxFilesChanged ${input.policy.maxFilesChanged}.`,
157
+ };
158
+ }
159
+ for (const pattern of input.policy.blockedPaths) {
160
+ const blockedPath = filesChanged.find((path) => globPatternMatches(pattern, path));
161
+ if (blockedPath !== undefined) {
162
+ return {
163
+ passed: false,
164
+ reason: `Merge policy blocked this PR because changed path "${blockedPath}" matches blockedPaths pattern "${pattern}".`,
165
+ };
166
+ }
167
+ }
168
+ return { passed: true, filesChanged };
169
+ }
170
+ async function publishPrMergePolicyBlock(input) {
171
+ const commentId = input.approvalResolution.triggeringCommentId;
172
+ const targetResourceUri = input.approvalResolution.targetResourceUri;
173
+ if (commentId === undefined || targetResourceUri === undefined) {
174
+ return { status: 'idle' };
175
+ }
176
+ const runId = `merge-gate-blocked-${commentId.replace(/[^a-z0-9]+/gi, '-')}`;
177
+ const occurredAt = eventStampNow();
178
+ const blockedEvent = createEventEnvelope({
179
+ eventId: `${runId}-completed`,
180
+ workItemKey: input.projection.workItemKey,
181
+ streamScope: 'work-item',
182
+ direction: 'internal',
183
+ sourceSystem: 'wake',
184
+ sourceEventType: 'wake.run.completed',
185
+ sourceRefs: {
186
+ repo: input.projection.issue.repo,
187
+ issueNumber: input.projection.issue.number,
188
+ runId,
189
+ resourceUri: targetResourceUri,
190
+ },
191
+ occurredAt,
192
+ ingestedAt: occurredAt,
193
+ trigger: 'immediate',
194
+ payload: {
195
+ action: input.approvalResolution.pendingAction,
196
+ sentinel: 'BLOCKED',
197
+ runId,
198
+ reason: 'pr-review-merge-policy-blocked',
199
+ blockReason: 'pr-review-merge-policy-blocked',
200
+ handledCommentId: commentId,
201
+ body: input.reason,
202
+ },
203
+ });
204
+ const appended = await deps.stateStore.appendEventEnvelope(blockedEvent);
205
+ await projectionUpdater.rebuildFromEvents([appended]);
206
+ const reviewerMessage = reviewerMessageFromApprovalComment(input.approvalResolution.triggeringCommentBody ?? '');
207
+ const body = [reviewerMessage, input.reason].filter((part) => part.length > 0).join('\n\n');
208
+ await deliverOutboundEvent(createEventEnvelope({
209
+ eventId: `${runId}-publish-intent`,
210
+ workItemKey: input.projection.workItemKey,
211
+ streamScope: 'work-item',
212
+ direction: 'outbound',
213
+ sourceSystem: 'wake',
214
+ sourceEventType: 'wake.publish.intent.requested',
215
+ sourceRefs: {
216
+ repo: input.projection.issue.repo,
217
+ issueNumber: input.projection.issue.number,
218
+ runId,
219
+ resourceUri: targetResourceUri,
220
+ },
221
+ occurredAt,
222
+ ingestedAt: occurredAt,
223
+ trigger: 'context-only',
224
+ payload: {
225
+ kind: 'question',
226
+ origin: input.projection.origin ?? 'github',
227
+ body,
228
+ action: input.approvalResolution.pendingAction,
229
+ sentinel: 'BLOCKED',
230
+ runId,
231
+ idempotencyKey: `${commentId}:pr-review-merge-policy-blocked`,
232
+ deliveryState: 'PENDING',
233
+ },
234
+ derivedHints: { stage: input.projection.wake.stage },
235
+ }));
236
+ await deliverOutboundEvent(createLabelsEvent({
237
+ projection: input.projection,
238
+ runId,
239
+ statusLabel: 'wake:status.blocked',
240
+ stageLabel: stageLabelForStage(input.projection.wake.stage),
241
+ workflowLabel: workflowLabelForWorkflowName(input.workflowName),
242
+ occurredAt,
243
+ }));
244
+ return {
245
+ status: 'processed',
246
+ runId,
247
+ sentinel: 'BLOCKED',
248
+ nextStage: null,
249
+ };
250
+ }
251
+ async function performApprovedPrMergeActions(input) {
252
+ if (deps.prMergeActor === undefined ||
253
+ input.approvalResolution.targetResourceUri === undefined ||
254
+ input.approvalResolution.triggeringCommentId === undefined) {
255
+ return;
256
+ }
257
+ const targetResourceUri = input.approvalResolution.targetResourceUri;
258
+ const commentId = input.approvalResolution.triggeringCommentId.replace(/[^a-z0-9]+/gi, '-');
259
+ const reviewBody = reviewerMessageFromApprovalComment(input.approvalResolution.triggeringCommentBody ?? '');
260
+ const approvedEventId = `pr-merge-approved-${commentId}`;
261
+ if (input.mergePolicy.approve &&
262
+ (await deps.stateStore.readEventEnvelope(approvedEventId)) === null) {
263
+ await deps.prMergeActor.approve(targetResourceUri, reviewBody);
264
+ const occurredAt = eventStampNow();
265
+ await deps.stateStore.appendEventEnvelope(createEventEnvelope({
266
+ eventId: approvedEventId,
267
+ workItemKey: input.projection.workItemKey,
268
+ streamScope: 'work-item',
269
+ direction: 'internal',
270
+ sourceSystem: 'wake',
271
+ sourceEventType: 'wake.pr-review.approved',
272
+ sourceRefs: {
273
+ resourceUri: targetResourceUri,
274
+ commentId: input.approvalResolution.triggeringCommentId,
275
+ },
276
+ occurredAt,
277
+ ingestedAt: occurredAt,
278
+ trigger: 'context-only',
279
+ payload: {
280
+ idempotencyKey: `${input.approvalResolution.triggeringCommentId}:pr-review-approval`,
281
+ },
282
+ }));
283
+ }
284
+ const autoMergeEventId = `pr-auto-merge-enabled-${commentId}`;
285
+ if (input.mergePolicy.autoMerge &&
286
+ (await deps.stateStore.readEventEnvelope(autoMergeEventId)) === null) {
287
+ await deps.prMergeActor.enableAutoMerge(targetResourceUri);
288
+ const occurredAt = eventStampNow();
289
+ await deps.stateStore.appendEventEnvelope(createEventEnvelope({
290
+ eventId: autoMergeEventId,
291
+ workItemKey: input.projection.workItemKey,
292
+ streamScope: 'work-item',
293
+ direction: 'internal',
294
+ sourceSystem: 'wake',
295
+ sourceEventType: 'wake.pr-auto-merge.enabled',
296
+ sourceRefs: {
297
+ resourceUri: targetResourceUri,
298
+ commentId: input.approvalResolution.triggeringCommentId,
299
+ },
300
+ occurredAt,
301
+ ingestedAt: occurredAt,
302
+ trigger: 'context-only',
303
+ payload: {
304
+ idempotencyKey: `${input.approvalResolution.triggeringCommentId}:pr-auto-merge`,
305
+ },
306
+ }));
307
+ }
308
+ }
113
309
  // Closes the loop on #82's review feedback: rather than scrape a PR link
114
310
  // out of the agent's free text, the agent emits a `wake-artifacts` fence
115
311
  // (domain/schema.ts's parseRunnerArtifacts) and Wake verifies each claim
@@ -656,6 +852,27 @@ export function createTickRunner(deps) {
656
852
  promptContextOverrides = workflowAction?.promptContext;
657
853
  }
658
854
  else if (approvalResolution.approved) {
855
+ const mergePolicy = approvedMergePolicyForStage(workflow.stages[candidate.wake.stage]);
856
+ if (approvalResolution.targetResourceUri !== undefined && mergePolicy !== null) {
857
+ const risk = await evaluatePrMergeRisk({
858
+ projection: candidate,
859
+ targetResourceUri: approvalResolution.targetResourceUri,
860
+ policy: mergePolicy,
861
+ });
862
+ if (!risk.passed) {
863
+ return await publishPrMergePolicyBlock({
864
+ projection: candidate,
865
+ approvalResolution,
866
+ reason: risk.reason,
867
+ workflowName,
868
+ });
869
+ }
870
+ await performApprovedPrMergeActions({
871
+ projection: candidate,
872
+ approvalResolution,
873
+ mergePolicy,
874
+ });
875
+ }
659
876
  const approvalId = `approval-${candidate.issue.number}-${deps.clock.now().getTime()}`;
660
877
  const approvedAt = deps.clock.now().toISOString();
661
878
  const automaticApproval = approvalResolution.automatic === true;
@@ -684,7 +901,11 @@ export function createTickRunner(deps) {
684
901
  nextStage,
685
902
  runId: approvalId,
686
903
  reason: automaticApproval ? 'auto:approved' : 'human:approved',
687
- ...(automaticApproval ? {} : { handledCommentId: latestHumanCommentId(candidate) }),
904
+ ...(automaticApproval
905
+ ? {}
906
+ : {
907
+ handledCommentId: approvalResolution.triggeringCommentId ?? latestHumanCommentId(candidate),
908
+ }),
688
909
  ...(automaticApproval
689
910
  ? {
690
911
  autoResolution: {
@@ -377,6 +377,20 @@ const workflowWorkspaceSchema = z.enum(['none', 'read-only', 'branch']);
377
377
  const workflowTriggerScheduleSchema = z.object({
378
378
  cron: z.string().min(1),
379
379
  });
380
+ const approvedMergePolicySchema = z
381
+ .object({
382
+ approve: z.boolean().default(false),
383
+ autoMerge: z.boolean().default(false),
384
+ maxFilesChanged: z.number().int().positive().optional(),
385
+ blockedPaths: z.array(z.string().min(1)).default([]),
386
+ blockedLabels: z.array(z.string().min(1)).default([]),
387
+ })
388
+ .default({
389
+ approve: false,
390
+ autoMerge: false,
391
+ blockedPaths: [],
392
+ blockedLabels: [],
393
+ });
380
394
  const workflowStageSchema = stageRouteSchema.extend({
381
395
  workspace: workflowWorkspaceSchema,
382
396
  onDone: identifierSchema,
@@ -392,6 +406,11 @@ const workflowStageSchema = stageRouteSchema.extend({
392
406
  .optional(),
393
407
  schedule: workflowTriggerScheduleSchema.optional(),
394
408
  workflow: identifierSchema,
409
+ onApproved: z
410
+ .object({
411
+ merge: approvedMergePolicySchema.optional(),
412
+ })
413
+ .optional(),
395
414
  }))
396
415
  .optional(),
397
416
  });
package/dist/src/main.js CHANGED
@@ -19,6 +19,7 @@ import { createGitHubArtifactVerifier } from './adapters/github/github-artifact-
19
19
  import { createGitHubClient } from './adapters/github/github-client.js';
20
20
  import { createGitHubIssuesWorkSource } from './adapters/github/github-issues-work-source.js';
21
21
  import { createGitHubPullRequestActivitySource } from './adapters/github/github-pull-request-activity-source.js';
22
+ import { createGitHubPullRequestMergeActor } from './adapters/github/github-pull-request-merge-actor.js';
22
23
  import { runAuditCommand } from './cli/audit-command.js';
23
24
  import { runCorrelateCommand } from './cli/correlate-command.js';
24
25
  import { runDoctorCommand } from './cli/doctor-command.js';
@@ -497,6 +498,9 @@ export async function buildRuntime(args) {
497
498
  const artifactVerifier = prTrackingEnabled && githubClient !== undefined
498
499
  ? createGitHubArtifactVerifier({ client: githubClient })
499
500
  : undefined;
501
+ const prMergeActor = prTrackingEnabled && githubClient !== undefined
502
+ ? createGitHubPullRequestMergeActor({ client: githubClient })
503
+ : undefined;
500
504
  const ticketingSystem = githubClient !== undefined
501
505
  ? createGitHubIssuesWorkSource({
502
506
  client: githubClient,
@@ -577,6 +581,7 @@ export async function buildRuntime(args) {
577
581
  workspaceManager,
578
582
  resourceIndex,
579
583
  ...(artifactVerifier === undefined ? {} : { artifactVerifier }),
584
+ ...(prMergeActor === undefined ? {} : { prMergeActor }),
580
585
  });
581
586
  return {
582
587
  config,
@@ -124,4 +124,4 @@ export function resolveWakeVersion(options = {}) {
124
124
  }
125
125
  return '0.1.0-dev';
126
126
  }
127
- export const wakeVersion = "g45197ea";
127
+ export const wakeVersion = "gc00f4ed";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atolis-hq/wake",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
4
4
  "description": "Local autonomous agent control plane for software development",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {