@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/README.md +74 -22
- package/dist/src/cli.js +13 -1
- package/dist/src/config/load.js +21 -1
- package/dist/src/config/normalize.js +68 -0
- package/dist/src/config/schema.js +55 -25
- package/dist/src/config/validate.js +3 -4
- package/dist/src/detect/docsCheck.js +4 -4
- package/dist/src/detect/heuristics.js +2 -2
- package/dist/src/detect/index.js +56 -47
- package/dist/src/detect/openapi.js +92 -9
- package/dist/src/devin/prompts.js +56 -5
- package/dist/src/devin/schemas.js +19 -19
- package/dist/src/devin/v1.js +8 -8
- package/dist/src/evidence/bundle.js +3 -3
- package/dist/src/github/client.js +77 -2
- package/dist/src/index.js +257 -154
- package/dist/src/model/state.js +1 -1
- package/dist/src/policy/confidence.js +1 -1
- package/dist/src/policy/engine.js +6 -5
- package/dist/src/utils/exec.js +2 -2
- package/dist/src/utils/glob.js +13 -0
- package/package.json +18 -5
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
|
|
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 =
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
134
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
209
|
+
decision,
|
|
210
|
+
docArea: "docsite",
|
|
211
|
+
outcome: existingPr ? "NO_CHANGE" : "BLOCKED",
|
|
212
|
+
link: existingPr,
|
|
187
213
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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:
|
|
254
|
-
body: (0, client_1.
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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:
|
|
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) {
|
package/dist/src/model/state.js
CHANGED
|
@@ -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
|
|
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") {
|
package/dist/src/utils/exec.js
CHANGED
|
@@ -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
|
}
|
package/dist/src/utils/glob.js
CHANGED
|
@@ -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
|
+
}
|