@devinnn/docdrift 0.1.0 → 0.1.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/src/index.js CHANGED
@@ -7,6 +7,7 @@ exports.STATE_PATH = void 0;
7
7
  exports.runDetect = runDetect;
8
8
  exports.runDocDrift = runDocDrift;
9
9
  exports.runValidate = runValidate;
10
+ exports.runSlaCheck = runSlaCheck;
10
11
  exports.runStatus = runStatus;
11
12
  exports.resolveTrigger = resolveTrigger;
12
13
  exports.parseDurationHours = parseDurationHours;
@@ -21,6 +22,7 @@ const engine_1 = require("./policy/engine");
21
22
  const state_1 = require("./policy/state");
22
23
  const log_1 = require("./utils/log");
23
24
  const prompts_1 = require("./devin/prompts");
25
+ const glob_1 = require("./utils/glob");
24
26
  const schemas_1 = require("./devin/schemas");
25
27
  const v1_1 = require("./devin/v1");
26
28
  function parseStructured(session) {
@@ -45,30 +47,20 @@ function inferQuestions(structured) {
45
47
  }
46
48
  return [
47
49
  "Which conceptual docs should be updated for this behavior change?",
48
- "What are the exact user-visible semantics after this merge?"
50
+ "What are the exact user-visible semantics after this merge?",
49
51
  ];
50
52
  }
51
- async function executeSession(input) {
53
+ async function executeSessionSingle(input) {
52
54
  const attachmentUrls = [];
53
55
  for (const attachmentPath of input.attachmentPaths) {
54
56
  const url = await (0, v1_1.devinUploadAttachment)(input.apiKey, attachmentPath);
55
57
  attachmentUrls.push(url);
56
58
  }
57
- const prompt = input.item.mode === "autogen"
58
- ? (0, prompts_1.buildAutogenPrompt)({
59
- item: input.item,
60
- attachmentUrls,
61
- verificationCommands: input.config.policy.verification.commands,
62
- allowlist: input.config.policy.allowlist,
63
- confidenceThreshold: input.config.policy.confidence.autopatchThreshold
64
- })
65
- : (0, prompts_1.buildConceptualPrompt)({
66
- item: input.item,
67
- attachmentUrls,
68
- verificationCommands: input.config.policy.verification.commands,
69
- allowlist: input.config.policy.allowlist,
70
- confidenceThreshold: input.config.policy.confidence.autopatchThreshold
71
- });
59
+ const prompt = (0, prompts_1.buildWholeDocsitePrompt)({
60
+ aggregated: input.aggregated,
61
+ config: input.config,
62
+ attachmentUrls,
63
+ });
72
64
  const session = await (0, v1_1.devinCreateSession)(input.apiKey, {
73
65
  prompt,
74
66
  unlisted: input.config.devin.unlisted,
@@ -76,13 +68,13 @@ async function executeSession(input) {
76
68
  tags: [...new Set([...(input.config.devin.tags ?? []), "docdrift", input.item.docArea])],
77
69
  attachments: attachmentUrls,
78
70
  structured_output: {
79
- schema: schemas_1.PatchPlanSchema
71
+ schema: schemas_1.PatchPlanSchema,
80
72
  },
81
73
  metadata: {
82
74
  repository: input.repository,
83
75
  docArea: input.item.docArea,
84
- mode: input.item.mode
85
- }
76
+ mode: input.item.mode,
77
+ },
86
78
  });
87
79
  const finalSession = await (0, v1_1.pollUntilTerminal)(input.apiKey, session.session_id);
88
80
  const structured = parseStructured(finalSession);
@@ -96,7 +88,7 @@ async function executeSession(input) {
96
88
  : verificationCommands.map(() => "not reported");
97
89
  const verification = verificationCommands.map((command, idx) => ({
98
90
  command,
99
- result: verificationResults[idx] ?? "not reported"
91
+ result: verificationResults[idx] ?? "not reported",
100
92
  }));
101
93
  if (prUrl) {
102
94
  return {
@@ -104,7 +96,7 @@ async function executeSession(input) {
104
96
  summary: String(structured?.summary ?? "PR opened by Devin"),
105
97
  sessionUrl: session.url,
106
98
  prUrl,
107
- verification
99
+ verification,
108
100
  };
109
101
  }
110
102
  if (status === "blocked" || structured?.status === "BLOCKED") {
@@ -113,14 +105,14 @@ async function executeSession(input) {
113
105
  summary: String(structured?.blocked?.reason ?? structured?.summary ?? "Session blocked"),
114
106
  sessionUrl: session.url,
115
107
  questions: inferQuestions(structured),
116
- verification
108
+ verification,
117
109
  };
118
110
  }
119
111
  return {
120
112
  outcome: "NO_CHANGE",
121
113
  summary: String(structured?.summary ?? "Session completed without PR"),
122
114
  sessionUrl: session.url,
123
- verification
115
+ verification,
124
116
  };
125
117
  }
126
118
  async function runDetect(options) {
@@ -130,14 +122,15 @@ async function runDetect(options) {
130
122
  throw new Error(`Config validation failed:\n${runtimeValidation.errors.join("\n")}`);
131
123
  }
132
124
  const repo = process.env.GITHUB_REPOSITORY ?? "local/docdrift";
133
- const { report } = await (0, detect_1.buildDriftReport)({
134
- config,
125
+ const normalized = (0, load_1.loadNormalizedConfig)();
126
+ const { report, hasOpenApiDrift } = await (0, detect_1.buildDriftReport)({
127
+ config: normalized,
135
128
  repo,
136
129
  baseSha: options.baseSha,
137
130
  headSha: options.headSha,
138
- trigger: options.trigger ?? "manual"
131
+ trigger: options.trigger ?? "manual",
139
132
  });
140
- (0, log_1.logInfo)(`Drift items detected: ${report.items.length}`);
133
+ (0, log_1.logInfo)(`Drift items detected: ${report.items.length} (hasOpenApiDrift: ${hasOpenApiDrift})`);
141
134
  return { hasDrift: report.items.length > 0 };
142
135
  }
143
136
  async function runDocDrift(options) {
@@ -146,172 +139,234 @@ async function runDocDrift(options) {
146
139
  if (runtimeValidation.errors.length) {
147
140
  throw new Error(`Config validation failed:\n${runtimeValidation.errors.join("\n")}`);
148
141
  }
142
+ const normalized = (0, load_1.loadNormalizedConfig)();
149
143
  const repo = process.env.GITHUB_REPOSITORY ?? "local/docdrift";
150
144
  const commitSha = process.env.GITHUB_SHA ?? options.headSha;
151
145
  const githubToken = process.env.GITHUB_TOKEN;
152
146
  const devinApiKey = process.env.DEVIN_API_KEY;
153
- const { report, runInfo, evidenceRoot } = await (0, detect_1.buildDriftReport)({
154
- config,
147
+ const { report, aggregated, runInfo, evidenceRoot, hasOpenApiDrift } = await (0, detect_1.buildDriftReport)({
148
+ config: normalized,
155
149
  repo,
156
150
  baseSha: options.baseSha,
157
151
  headSha: options.headSha,
158
- trigger: options.trigger ?? "manual"
152
+ trigger: options.trigger ?? "manual",
159
153
  });
160
- const docAreaByName = new Map(config.docAreas.map((area) => [area.name, area]));
154
+ // Gate: no OpenAPI drift exit early, no session
155
+ if (!hasOpenApiDrift || report.items.length === 0) {
156
+ (0, log_1.logInfo)("No OpenAPI drift; skipping session");
157
+ return [];
158
+ }
159
+ const item = report.items[0];
160
+ const docAreaConfig = {
161
+ name: "docsite",
162
+ mode: "autogen",
163
+ owners: { reviewers: [] },
164
+ detect: { openapi: { exportCmd: normalized.openapi.export, generatedPath: normalized.openapi.generated, publishedPath: normalized.openapi.published }, paths: [] },
165
+ patch: { targets: [], requireHumanConfirmation: false },
166
+ };
161
167
  let state = (0, state_1.loadState)();
162
168
  const startedAt = Date.now();
163
169
  const results = [];
164
170
  const metrics = {
165
- driftItemsDetected: report.items.length,
171
+ driftItemsDetected: 1,
166
172
  prsOpened: 0,
167
173
  issuesOpened: 0,
168
174
  blockedCount: 0,
169
175
  timeToSessionTerminalMs: [],
170
- docAreaCounts: {},
171
- noiseRateProxy: 0
176
+ docAreaCounts: { docsite: 1 },
177
+ noiseRateProxy: 0,
172
178
  };
173
- for (const item of report.items) {
174
- metrics.docAreaCounts[item.docArea] = (metrics.docAreaCounts[item.docArea] ?? 0) + 1;
175
- const areaConfig = docAreaByName.get(item.docArea);
176
- if (!areaConfig) {
177
- continue;
178
- }
179
- const decision = (0, engine_1.decidePolicy)({
180
- item,
181
- docAreaConfig: areaConfig,
182
- config,
179
+ const decision = (0, engine_1.decidePolicy)({
180
+ item,
181
+ docAreaConfig,
182
+ config,
183
+ state,
184
+ repo,
185
+ baseSha: options.baseSha,
186
+ headSha: options.headSha,
187
+ });
188
+ if (decision.action === "NOOP") {
189
+ results.push({
190
+ docArea: item.docArea,
191
+ decision,
192
+ outcome: "NO_CHANGE",
193
+ summary: decision.reason,
194
+ });
195
+ (0, bundle_1.writeMetrics)(metrics);
196
+ return results;
197
+ }
198
+ if (decision.action === "UPDATE_EXISTING_PR") {
199
+ const existingPr = state.areaLatestPr["docsite"];
200
+ results.push({
201
+ docArea: item.docArea,
202
+ decision,
203
+ outcome: existingPr ? "NO_CHANGE" : "BLOCKED",
204
+ summary: existingPr ? `Bundled into existing PR: ${existingPr}` : "PR cap reached",
205
+ prUrl: existingPr,
206
+ });
207
+ state = (0, engine_1.applyDecisionToState)({
183
208
  state,
184
- repo,
185
- baseSha: options.baseSha,
186
- headSha: options.headSha
209
+ decision,
210
+ docArea: "docsite",
211
+ outcome: existingPr ? "NO_CHANGE" : "BLOCKED",
212
+ link: existingPr,
187
213
  });
188
- if (decision.action === "NOOP") {
189
- results.push({
190
- docArea: item.docArea,
191
- decision,
192
- outcome: "NO_CHANGE",
193
- summary: decision.reason
194
- });
195
- continue;
196
- }
197
- if (decision.action === "UPDATE_EXISTING_PR") {
198
- const existingPr = state.areaLatestPr[item.docArea];
199
- const summary = existingPr
200
- ? `Bundled into existing PR: ${existingPr}`
201
- : "PR cap reached and no existing area PR; escalated";
202
- const outcome = existingPr ? "NO_CHANGE" : "BLOCKED";
203
- results.push({
204
- docArea: item.docArea,
205
- decision,
206
- outcome,
207
- summary,
208
- prUrl: existingPr
209
- });
210
- state = (0, engine_1.applyDecisionToState)({
211
- state,
212
- decision,
213
- docArea: item.docArea,
214
- outcome,
215
- link: existingPr
216
- });
217
- continue;
218
- }
219
- const bundle = await (0, bundle_1.buildEvidenceBundle)({ runInfo, item, evidenceRoot });
220
- const attachmentPaths = [...new Set([bundle.archivePath, ...bundle.attachmentPaths])];
221
- let sessionOutcome = {
222
- outcome: "NO_CHANGE",
223
- summary: "Skipped Devin session",
224
- verification: config.policy.verification.commands.map((command) => ({ command, result: "not run" }))
214
+ (0, state_1.saveState)(state);
215
+ (0, bundle_1.writeMetrics)(metrics);
216
+ return results;
217
+ }
218
+ const bundle = await (0, bundle_1.buildEvidenceBundle)({ runInfo, item, evidenceRoot });
219
+ const attachmentPaths = [...new Set([bundle.archivePath, ...bundle.attachmentPaths])];
220
+ let sessionOutcome = {
221
+ outcome: "NO_CHANGE",
222
+ summary: "Skipped Devin session",
223
+ verification: normalized.policy.verification.commands.map((command) => ({
224
+ command,
225
+ result: "not run",
226
+ })),
227
+ };
228
+ if (devinApiKey) {
229
+ const sessionStart = Date.now();
230
+ sessionOutcome = await executeSessionSingle({
231
+ apiKey: devinApiKey,
232
+ repository: repo,
233
+ item,
234
+ aggregated: aggregated,
235
+ attachmentPaths,
236
+ config: normalized,
237
+ });
238
+ metrics.timeToSessionTerminalMs.push(Date.now() - sessionStart);
239
+ }
240
+ else {
241
+ (0, log_1.logWarn)("DEVIN_API_KEY not set; running fallback behavior", { docArea: item.docArea });
242
+ sessionOutcome = {
243
+ outcome: "BLOCKED",
244
+ summary: "DEVIN_API_KEY missing; cannot start Devin session",
245
+ questions: ["Set DEVIN_API_KEY in environment or GitHub Actions secrets"],
246
+ verification: normalized.policy.verification.commands.map((command) => ({
247
+ command,
248
+ result: "not run",
249
+ })),
225
250
  };
226
- if (devinApiKey) {
227
- const sessionStart = Date.now();
228
- sessionOutcome = await executeSession({
229
- apiKey: devinApiKey,
230
- repository: repo,
231
- item,
232
- attachmentPaths,
233
- config
234
- });
235
- metrics.timeToSessionTerminalMs.push(Date.now() - sessionStart);
236
- }
237
- else {
238
- (0, log_1.logWarn)("DEVIN_API_KEY not set; running fallback behavior", { docArea: item.docArea });
239
- sessionOutcome = {
240
- outcome: "BLOCKED",
241
- summary: "DEVIN_API_KEY missing; cannot start Devin session",
242
- questions: ["Set DEVIN_API_KEY in environment or GitHub Actions secrets"],
243
- verification: config.policy.verification.commands.map((command) => ({ command, result: "not run" }))
244
- };
245
- }
246
- let issueUrl;
247
- if (githubToken &&
248
- (decision.action === "OPEN_ISSUE" || sessionOutcome.outcome === "BLOCKED" || sessionOutcome.outcome === "NO_CHANGE")) {
251
+ }
252
+ let issueUrl;
253
+ if (sessionOutcome.outcome === "PR_OPENED" && sessionOutcome.prUrl) {
254
+ metrics.prsOpened += 1;
255
+ state.lastDocDriftPrUrl = sessionOutcome.prUrl;
256
+ state.lastDocDriftPrOpenedAt = new Date().toISOString();
257
+ const touchedRequireReview = (item.impactedDocs ?? []).filter((p) => normalized.requireHumanReview.some((glob) => (0, glob_1.matchesGlob)(glob, p)));
258
+ if (githubToken && touchedRequireReview.length > 0) {
249
259
  issueUrl = await (0, client_1.createIssue)({
250
260
  token: githubToken,
251
261
  repository: repo,
252
262
  issue: {
253
- title: `[docdrift] ${item.docArea}: docs drift requires input`,
254
- body: (0, client_1.renderBlockedIssueBody)({
255
- docArea: item.docArea,
256
- evidenceSummary: item.summary,
257
- questions: sessionOutcome.questions ?? ["Please confirm intended behavior and doc wording."],
258
- sessionUrl: sessionOutcome.sessionUrl
263
+ title: "[docdrift] Docs out of sync — review doc drift PR",
264
+ body: (0, client_1.renderRequireHumanReviewIssueBody)({
265
+ prUrl: sessionOutcome.prUrl,
266
+ touchedPaths: touchedRequireReview,
259
267
  }),
260
- labels: ["docdrift"]
261
- }
268
+ labels: ["docdrift"],
269
+ },
262
270
  });
263
271
  metrics.issuesOpened += 1;
264
- sessionOutcome.outcome = "ISSUE_OPENED";
265
- }
266
- if (sessionOutcome.outcome === "PR_OPENED") {
267
- metrics.prsOpened += 1;
268
272
  }
269
- if (sessionOutcome.outcome === "BLOCKED") {
270
- metrics.blockedCount += 1;
273
+ }
274
+ else if (githubToken &&
275
+ (decision.action === "OPEN_ISSUE" ||
276
+ sessionOutcome.outcome === "BLOCKED" ||
277
+ sessionOutcome.outcome === "NO_CHANGE")) {
278
+ issueUrl = await (0, client_1.createIssue)({
279
+ token: githubToken,
280
+ repository: repo,
281
+ issue: {
282
+ title: "[docdrift] docsite: docs drift requires input",
283
+ body: (0, client_1.renderBlockedIssueBody)({
284
+ docArea: item.docArea,
285
+ evidenceSummary: item.summary,
286
+ questions: sessionOutcome.questions ?? [
287
+ "Please confirm intended behavior and doc wording.",
288
+ ],
289
+ sessionUrl: sessionOutcome.sessionUrl,
290
+ }),
291
+ labels: ["docdrift"],
292
+ },
293
+ });
294
+ metrics.issuesOpened += 1;
295
+ if (sessionOutcome.outcome !== "PR_OPENED") {
296
+ sessionOutcome.outcome = "ISSUE_OPENED";
271
297
  }
272
- const result = {
298
+ }
299
+ if (sessionOutcome.outcome === "BLOCKED") {
300
+ metrics.blockedCount += 1;
301
+ }
302
+ const result = {
303
+ docArea: item.docArea,
304
+ decision,
305
+ outcome: sessionOutcome.outcome,
306
+ summary: sessionOutcome.summary,
307
+ sessionUrl: sessionOutcome.sessionUrl,
308
+ prUrl: sessionOutcome.prUrl,
309
+ issueUrl,
310
+ };
311
+ results.push(result);
312
+ state = (0, engine_1.applyDecisionToState)({
313
+ state,
314
+ decision,
315
+ docArea: "docsite",
316
+ outcome: sessionOutcome.outcome,
317
+ link: sessionOutcome.prUrl ?? issueUrl,
318
+ });
319
+ if (sessionOutcome.outcome === "PR_OPENED" && sessionOutcome.prUrl) {
320
+ state.lastDocDriftPrUrl = sessionOutcome.prUrl;
321
+ state.lastDocDriftPrOpenedAt = new Date().toISOString();
322
+ }
323
+ (0, state_1.saveState)(state);
324
+ if (githubToken) {
325
+ const body = (0, client_1.renderRunComment)({
273
326
  docArea: item.docArea,
274
- decision,
275
- outcome: sessionOutcome.outcome,
276
327
  summary: sessionOutcome.summary,
328
+ decision: decision.action,
329
+ outcome: sessionOutcome.outcome,
277
330
  sessionUrl: sessionOutcome.sessionUrl,
278
331
  prUrl: sessionOutcome.prUrl,
279
- issueUrl
280
- };
281
- results.push(result);
282
- if (githubToken) {
283
- const body = (0, client_1.renderRunComment)({
284
- docArea: item.docArea,
285
- summary: sessionOutcome.summary,
286
- decision: decision.action,
287
- outcome: sessionOutcome.outcome,
288
- sessionUrl: sessionOutcome.sessionUrl,
289
- prUrl: sessionOutcome.prUrl,
290
- issueUrl,
291
- validation: sessionOutcome.verification
292
- });
293
- await (0, client_1.postCommitComment)({
332
+ issueUrl,
333
+ validation: sessionOutcome.verification,
334
+ });
335
+ await (0, client_1.postCommitComment)({
336
+ token: githubToken,
337
+ repository: repo,
338
+ commitSha,
339
+ body,
340
+ });
341
+ }
342
+ const slaDays = normalized.policy.slaDays ?? 0;
343
+ if (githubToken && slaDays > 0 && state.lastDocDriftPrUrl && state.lastDocDriftPrOpenedAt) {
344
+ const openedAt = Date.parse(state.lastDocDriftPrOpenedAt);
345
+ const daysOld = (Date.now() - openedAt) / (24 * 60 * 60 * 1000);
346
+ const lastSla = state.lastSlaIssueOpenedAt ? Date.parse(state.lastSlaIssueOpenedAt) : 0;
347
+ const slaCooldown = 6 * 24 * 60 * 60 * 1000;
348
+ if (daysOld >= slaDays && Date.now() - lastSla > slaCooldown) {
349
+ const slaIssueUrl = await (0, client_1.createIssue)({
294
350
  token: githubToken,
295
351
  repository: repo,
296
- commitSha,
297
- body
352
+ issue: {
353
+ title: "[docdrift] Docs out of sync — merge doc drift PR(s)",
354
+ body: (0, client_1.renderSlaIssueBody)({
355
+ prUrls: [state.lastDocDriftPrUrl],
356
+ slaDays,
357
+ }),
358
+ labels: ["docdrift"],
359
+ },
298
360
  });
361
+ state.lastSlaIssueOpenedAt = new Date().toISOString();
362
+ (0, state_1.saveState)(state);
299
363
  }
300
- state = (0, engine_1.applyDecisionToState)({
301
- state,
302
- decision,
303
- docArea: item.docArea,
304
- outcome: sessionOutcome.outcome,
305
- link: sessionOutcome.prUrl ?? issueUrl
306
- });
307
364
  }
308
- (0, state_1.saveState)(state);
309
- metrics.noiseRateProxy =
310
- metrics.driftItemsDetected === 0 ? 0 : Number((metrics.prsOpened / metrics.driftItemsDetected).toFixed(4));
365
+ metrics.noiseRateProxy = metrics.prsOpened;
311
366
  (0, bundle_1.writeMetrics)(metrics);
312
367
  (0, log_1.logInfo)("Run complete", {
313
- items: report.items.length,
314
- elapsedMs: Date.now() - startedAt
368
+ items: 1,
369
+ elapsedMs: Date.now() - startedAt,
315
370
  });
316
371
  return results;
317
372
  }
@@ -324,6 +379,54 @@ async function runValidate() {
324
379
  runtimeValidation.warnings.forEach((warning) => (0, log_1.logWarn)(warning));
325
380
  (0, log_1.logInfo)("Config is valid");
326
381
  }
382
+ async function runSlaCheck() {
383
+ const githubToken = process.env.GITHUB_TOKEN;
384
+ if (!githubToken) {
385
+ throw new Error("GITHUB_TOKEN is required for sla-check command");
386
+ }
387
+ const repo = process.env.GITHUB_REPOSITORY;
388
+ if (!repo) {
389
+ throw new Error("GITHUB_REPOSITORY is required for sla-check command");
390
+ }
391
+ const normalized = (0, load_1.loadNormalizedConfig)();
392
+ const slaDays = normalized.policy.slaDays ?? 0;
393
+ const slaLabel = normalized.policy.slaLabel ?? "docdrift";
394
+ if (slaDays <= 0) {
395
+ (0, log_1.logInfo)("SLA check disabled (slaDays <= 0)");
396
+ return { issueOpened: false };
397
+ }
398
+ const cutoff = new Date(Date.now() - slaDays * 24 * 60 * 60 * 1000);
399
+ const openPrs = await (0, client_1.listOpenPrsWithLabel)(githubToken, repo, slaLabel);
400
+ const stalePrs = openPrs.filter((pr) => {
401
+ const created = pr.created_at ? Date.parse(pr.created_at) : Date.now();
402
+ return Number.isFinite(created) && created <= cutoff.getTime();
403
+ });
404
+ if (stalePrs.length === 0) {
405
+ (0, log_1.logInfo)("No doc-drift PRs open longer than slaDays; nothing to do");
406
+ return { issueOpened: false };
407
+ }
408
+ let state = (0, state_1.loadState)();
409
+ const lastSla = state.lastSlaIssueOpenedAt ? Date.parse(state.lastSlaIssueOpenedAt) : 0;
410
+ const slaCooldown = 6 * 24 * 60 * 60 * 1000;
411
+ if (Date.now() - lastSla < slaCooldown) {
412
+ (0, log_1.logInfo)("SLA issue cooldown; skipping");
413
+ return { issueOpened: false };
414
+ }
415
+ const prUrls = stalePrs.map((p) => p.url).filter(Boolean);
416
+ await (0, client_1.createIssue)({
417
+ token: githubToken,
418
+ repository: repo,
419
+ issue: {
420
+ title: "[docdrift] Docs out of sync — merge doc drift PR(s)",
421
+ body: (0, client_1.renderSlaIssueBody)({ prUrls, slaDays }),
422
+ labels: ["docdrift"],
423
+ },
424
+ });
425
+ state.lastSlaIssueOpenedAt = new Date().toISOString();
426
+ (0, state_1.saveState)(state);
427
+ (0, log_1.logInfo)(`Opened SLA issue for ${prUrls.length} stale PR(s)`);
428
+ return { issueOpened: true };
429
+ }
327
430
  async function runStatus(sinceHours = 24) {
328
431
  const apiKey = process.env.DEVIN_API_KEY;
329
432
  if (!apiKey) {
@@ -5,6 +5,6 @@ const emptyState = () => ({
5
5
  idempotency: {},
6
6
  dailyPrCount: {},
7
7
  areaDailyPrOpened: {},
8
- areaLatestPr: {}
8
+ areaLatestPr: {},
9
9
  });
10
10
  exports.emptyState = emptyState;
@@ -6,7 +6,7 @@ const tierWeight = {
6
6
  0: 1,
7
7
  1: 0.9,
8
8
  2: 0.6,
9
- 3: 0.35
9
+ 3: 0.35,
10
10
  };
11
11
  function clamp01(value) {
12
12
  return Math.max(0, Math.min(1, value));
@@ -21,7 +21,8 @@ function decidePolicy(input) {
21
21
  const capReached = prCountToday >= config.policy.prCaps.maxPrsPerDay;
22
22
  const areaDailyKey = `${today}:${item.docArea}`;
23
23
  const exceedsFileCap = item.impactedDocs.length > config.policy.prCaps.maxFilesTouched;
24
- const hasPathOutsideAllowlist = item.impactedDocs.some((filePath) => filePath && !(0, glob_1.isPathAllowed)(filePath, config.policy.allowlist));
24
+ const exclude = "exclude" in config && Array.isArray(config.exclude) ? config.exclude : [];
25
+ const hasPathOutsideAllowlist = item.impactedDocs.some((filePath) => filePath && !(0, glob_1.isPathAllowedAndNotExcluded)(filePath, config.policy.allowlist, exclude));
25
26
  let action = "NOOP";
26
27
  let reason = "No action needed";
27
28
  if (hasPathOutsideAllowlist) {
@@ -70,21 +71,21 @@ function decidePolicy(input) {
70
71
  docArea: item.docArea,
71
72
  baseSha: input.baseSha,
72
73
  headSha: input.headSha,
73
- action
74
+ action,
74
75
  });
75
76
  if (state.idempotency[idempotencyKey]) {
76
77
  return {
77
78
  action: "NOOP",
78
79
  confidence,
79
80
  reason: "Idempotency key already processed",
80
- idempotencyKey
81
+ idempotencyKey,
81
82
  };
82
83
  }
83
84
  return {
84
85
  action,
85
86
  confidence,
86
87
  reason,
87
- idempotencyKey
88
+ idempotencyKey,
88
89
  };
89
90
  }
90
91
  function applyDecisionToState(input) {
@@ -94,7 +95,7 @@ function applyDecisionToState(input) {
94
95
  createdAt: new Date().toISOString(),
95
96
  action: input.decision.action,
96
97
  outcome: input.outcome,
97
- link: input.link
98
+ link: input.link,
98
99
  };
99
100
  next.idempotency[input.decision.idempotencyKey] = record;
100
101
  if (input.outcome === "PR_OPENED") {
@@ -8,7 +8,7 @@ async function execCommand(command, cwd = process.cwd()) {
8
8
  try {
9
9
  const { stdout, stderr } = await exec(command, {
10
10
  cwd,
11
- maxBuffer: 10 * 1024 * 1024
11
+ maxBuffer: 10 * 1024 * 1024,
12
12
  });
13
13
  return { command, stdout, stderr, exitCode: 0 };
14
14
  }
@@ -18,7 +18,7 @@ async function execCommand(command, cwd = process.cwd()) {
18
18
  command,
19
19
  stdout: e.stdout ?? "",
20
20
  stderr: e.stderr ?? String(error),
21
- exitCode: typeof e.code === "number" ? e.code : 1
21
+ exitCode: typeof e.code === "number" ? e.code : 1,
22
22
  };
23
23
  }
24
24
  }
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.globToRegExp = globToRegExp;
4
4
  exports.matchesGlob = matchesGlob;
5
5
  exports.isPathAllowed = isPathAllowed;
6
+ exports.isPathExcluded = isPathExcluded;
7
+ exports.isPathAllowedAndNotExcluded = isPathAllowedAndNotExcluded;
6
8
  function escapeRegex(input) {
7
9
  return input.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
8
10
  }
@@ -19,3 +21,14 @@ function matchesGlob(glob, value) {
19
21
  function isPathAllowed(path, allowlist) {
20
22
  return allowlist.some((glob) => matchesGlob(glob, path));
21
23
  }
24
+ function isPathExcluded(path, exclude) {
25
+ if (!exclude?.length)
26
+ return false;
27
+ return exclude.some((glob) => matchesGlob(glob, path));
28
+ }
29
+ /** Path is allowed by allowlist AND not excluded */
30
+ function isPathAllowedAndNotExcluded(path, allowlist, exclude = []) {
31
+ if (isPathExcluded(path, exclude))
32
+ return false;
33
+ return isPathAllowed(path, allowlist);
34
+ }