@formthefog/stratus 2026.2.24 โ 2026.3.19
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/.github/sentinel/action.yml +100 -0
- package/.github/sentinel/dist/codebase.d.ts +3 -0
- package/.github/sentinel/dist/codebase.d.ts.map +1 -0
- package/.github/sentinel/dist/context.d.ts +6 -0
- package/.github/sentinel/dist/context.d.ts.map +1 -0
- package/.github/sentinel/dist/fixer.d.ts +6 -0
- package/.github/sentinel/dist/fixer.d.ts.map +1 -0
- package/.github/sentinel/dist/index.d.ts +1 -0
- package/.github/sentinel/dist/index.d.ts.map +1 -0
- package/.github/sentinel/dist/index.js +68808 -0
- package/.github/sentinel/dist/index.js.map +1 -0
- package/.github/sentinel/dist/licenses.txt +1152 -0
- package/.github/sentinel/dist/models/anthropic.d.ts +26 -0
- package/.github/sentinel/dist/models/anthropic.d.ts.map +1 -0
- package/.github/sentinel/dist/models/openai.d.ts +26 -0
- package/.github/sentinel/dist/models/openai.d.ts.map +1 -0
- package/.github/sentinel/dist/models/openrouter.d.ts +31 -0
- package/.github/sentinel/dist/models/openrouter.d.ts.map +1 -0
- package/.github/sentinel/dist/models/types.d.ts +37 -0
- package/.github/sentinel/dist/models/types.d.ts.map +1 -0
- package/.github/sentinel/dist/orchestrator.d.ts +3 -0
- package/.github/sentinel/dist/orchestrator.d.ts.map +1 -0
- package/.github/sentinel/dist/policy.d.ts +15 -0
- package/.github/sentinel/dist/policy.d.ts.map +1 -0
- package/.github/sentinel/dist/reporter.d.ts +8 -0
- package/.github/sentinel/dist/reporter.d.ts.map +1 -0
- package/.github/sentinel/dist/responder.d.ts +6 -0
- package/.github/sentinel/dist/responder.d.ts.map +1 -0
- package/.github/sentinel/dist/router.d.ts +2 -0
- package/.github/sentinel/dist/router.d.ts.map +1 -0
- package/.github/sentinel/dist/schemas/config.d.ts +195 -0
- package/.github/sentinel/dist/schemas/config.d.ts.map +1 -0
- package/.github/sentinel/dist/schemas/fix.d.ts +130 -0
- package/.github/sentinel/dist/schemas/fix.d.ts.map +1 -0
- package/.github/sentinel/dist/schemas/review.d.ts +275 -0
- package/.github/sentinel/dist/schemas/review.d.ts.map +1 -0
- package/.github/sentinel/dist/sourcemap-register.js +1 -0
- package/.github/sentinel/dist/subway.d.ts +31 -0
- package/.github/sentinel/dist/subway.d.ts.map +1 -0
- package/.github/sentinel/dist/types.d.ts +210 -0
- package/.github/sentinel/dist/types.d.ts.map +1 -0
- package/.github/sentinel/package-lock.json +2389 -0
- package/.github/sentinel/package.json +29 -0
- package/.github/sentinel/src/codebase.ts +265 -0
- package/.github/sentinel/src/context.ts +182 -0
- package/.github/sentinel/src/fixer.ts +353 -0
- package/.github/sentinel/src/index.ts +263 -0
- package/.github/sentinel/src/models/anthropic.ts +244 -0
- package/.github/sentinel/src/models/openai.ts +242 -0
- package/.github/sentinel/src/models/openrouter.ts +319 -0
- package/.github/sentinel/src/models/types.ts +35 -0
- package/.github/sentinel/src/orchestrator.ts +287 -0
- package/.github/sentinel/src/policy.ts +133 -0
- package/.github/sentinel/src/reporter.ts +666 -0
- package/.github/sentinel/src/responder.ts +156 -0
- package/.github/sentinel/src/router.ts +308 -0
- package/.github/sentinel/src/schemas/config.ts +84 -0
- package/.github/sentinel/src/schemas/fix.ts +44 -0
- package/.github/sentinel/src/schemas/review.ts +73 -0
- package/.github/sentinel/src/subway.ts +250 -0
- package/.github/sentinel/src/types.ts +234 -0
- package/.github/sentinel/tsconfig.json +19 -0
- package/.github/sentinel.yml +34 -0
- package/.github/workflows/sentinel.yml +55 -0
- package/README.md +88 -102
- package/SECURITY.md +21 -10
- package/TROUBLESHOOTING.md +2 -2
- package/index.ts +219 -109
- package/openclaw.plugin.json +50 -26
- package/package.json +1 -1
- package/skills/stratus-info/SKILL.md +70 -10
- package/src/client.ts +78 -18
- package/src/config.ts +29 -8
- package/src/setup.ts +53 -61
- package/src/types.ts +11 -0
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import * as core from "@actions/core"
|
|
2
|
+
import * as github from "@actions/github"
|
|
3
|
+
import type { FinalDecision, FinalAction, ReviewFinding, FindingSeverity, FindingType, FixResult, FixMode } from "./types"
|
|
4
|
+
|
|
5
|
+
type Octokit = ReturnType<typeof github.getOctokit>
|
|
6
|
+
|
|
7
|
+
const COMMENT_MARKER = "<!-- sentinel-review -->"
|
|
8
|
+
|
|
9
|
+
const SEVERITY_EMOJI: Record<FindingSeverity, string> = {
|
|
10
|
+
critical: "๐ด",
|
|
11
|
+
high: "๐ด",
|
|
12
|
+
medium: "๐ก",
|
|
13
|
+
low: "๐ต",
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const SEVERITY_LABEL: Record<FindingSeverity, string> = {
|
|
17
|
+
critical: "CRITICAL",
|
|
18
|
+
high: "HIGH",
|
|
19
|
+
medium: "MEDIUM",
|
|
20
|
+
low: "LOW",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TYPE_LABEL: Record<FindingType, string> = {
|
|
24
|
+
bug: "Bug",
|
|
25
|
+
security: "Security",
|
|
26
|
+
performance: "Performance",
|
|
27
|
+
maintainability: "Maintainability",
|
|
28
|
+
test_gap: "Test Gap",
|
|
29
|
+
architecture: "Architecture",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const SOURCE_LABEL: Record<string, string> = {
|
|
33
|
+
anthropic: "Anthropic",
|
|
34
|
+
openai: "OpenAI",
|
|
35
|
+
merged: "Both models",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const REVIEW_EVENT_MAP: Partial<Record<FinalAction, "COMMENT" | "REQUEST_CHANGES" | "APPROVE">> = {
|
|
39
|
+
comment_only: "COMMENT",
|
|
40
|
+
request_changes: "REQUEST_CHANGES",
|
|
41
|
+
needs_human_review: "REQUEST_CHANGES",
|
|
42
|
+
suggest_patch: "COMMENT",
|
|
43
|
+
decline: "COMMENT",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function reportReview(
|
|
47
|
+
octokit: Octokit,
|
|
48
|
+
decision: FinalDecision,
|
|
49
|
+
prNumber: number,
|
|
50
|
+
summaryOnClean = false
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const { owner, repo } = github.context.repo
|
|
53
|
+
const inlineFindings = decision.findings.filter((f) => f.lineStart && f.lineStart > 0)
|
|
54
|
+
const nonInlineFindings = decision.findings.filter((f) => !f.lineStart || f.lineStart <= 0)
|
|
55
|
+
|
|
56
|
+
await dismissPreviousReviews(octokit, prNumber)
|
|
57
|
+
|
|
58
|
+
if (inlineFindings.length > 0) {
|
|
59
|
+
await submitPRReview(octokit, decision, prNumber, inlineFindings)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (decision.findings.length === 0 && !summaryOnClean) {
|
|
63
|
+
// Still emit step summary + action outputs even when skipping the PR comment,
|
|
64
|
+
// so downstream jobs and RL training can consume quality_score / findings_count.
|
|
65
|
+
await postStepSummary(decision)
|
|
66
|
+
setOutputs(decision)
|
|
67
|
+
core.info("No findings โ skipping summary comment (summary_on_clean is false)")
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const summaryBody = formatSummaryComment(decision, nonInlineFindings, inlineFindings.length)
|
|
72
|
+
await upsertComment(octokit, prNumber, summaryBody)
|
|
73
|
+
|
|
74
|
+
await postStepSummary(decision)
|
|
75
|
+
setOutputs(decision)
|
|
76
|
+
|
|
77
|
+
core.info(`Review posted: ${decision.action}, ${decision.findings.length} findings (${inlineFindings.length} inline, ${nonInlineFindings.length} summary)`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function reportIssueTriage(
|
|
81
|
+
octokit: Octokit,
|
|
82
|
+
issueNumber: number,
|
|
83
|
+
classification: string,
|
|
84
|
+
summary: string
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const { owner, repo } = github.context.repo
|
|
87
|
+
const body = [
|
|
88
|
+
COMMENT_MARKER,
|
|
89
|
+
"## Sentinel โ Issue Triage",
|
|
90
|
+
"",
|
|
91
|
+
`**Classification:** ${classification}`,
|
|
92
|
+
"",
|
|
93
|
+
summary,
|
|
94
|
+
"",
|
|
95
|
+
"---",
|
|
96
|
+
"*Triaged by Sentinel*",
|
|
97
|
+
].join("\n")
|
|
98
|
+
|
|
99
|
+
await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function reportFixResult(
|
|
103
|
+
octokit: Octokit,
|
|
104
|
+
issueNumber: number,
|
|
105
|
+
result: FixResult,
|
|
106
|
+
mode: FixMode
|
|
107
|
+
): Promise<void> {
|
|
108
|
+
const { owner, repo } = github.context.repo
|
|
109
|
+
const lines: string[] = [COMMENT_MARKER]
|
|
110
|
+
|
|
111
|
+
if (result.success && result.fixPlan) {
|
|
112
|
+
const plan = result.fixPlan
|
|
113
|
+
const modeLabel =
|
|
114
|
+
mode === "propose_only" ? "Proposed Fix" :
|
|
115
|
+
mode === "propose_and_pr" ? "Fix PR Created" :
|
|
116
|
+
"Fix Applied (Auto-merged)"
|
|
117
|
+
|
|
118
|
+
lines.push(`## Sentinel โ ${modeLabel} โ
`)
|
|
119
|
+
lines.push("")
|
|
120
|
+
lines.push(`**Confidence:** ${(plan.confidence * 100).toFixed(0)}%`)
|
|
121
|
+
lines.push("")
|
|
122
|
+
lines.push("### Analysis")
|
|
123
|
+
lines.push(plan.analysis)
|
|
124
|
+
lines.push("")
|
|
125
|
+
lines.push("### Changes")
|
|
126
|
+
|
|
127
|
+
for (const file of plan.files) {
|
|
128
|
+
lines.push("")
|
|
129
|
+
lines.push(`#### \`${file.path}\` (${file.action})`)
|
|
130
|
+
lines.push(file.explanation)
|
|
131
|
+
|
|
132
|
+
if (file.changes && file.changes.length > 0) {
|
|
133
|
+
lines.push("")
|
|
134
|
+
lines.push("<details>")
|
|
135
|
+
lines.push("<summary>View changes</summary>")
|
|
136
|
+
lines.push("")
|
|
137
|
+
for (const change of file.changes) {
|
|
138
|
+
lines.push("```diff")
|
|
139
|
+
lines.push(`- ${change.search.split("\n").join("\n- ")}`)
|
|
140
|
+
lines.push(`+ ${change.replace.split("\n").join("\n+ ")}`)
|
|
141
|
+
lines.push("```")
|
|
142
|
+
}
|
|
143
|
+
lines.push("")
|
|
144
|
+
lines.push("</details>")
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (plan.testSuggestions.length > 0) {
|
|
149
|
+
lines.push("")
|
|
150
|
+
lines.push("### Suggested Tests")
|
|
151
|
+
for (const t of plan.testSuggestions) lines.push(`- [ ] ${t}`)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (plan.riskNotes.length > 0) {
|
|
155
|
+
lines.push("")
|
|
156
|
+
lines.push("### Risk Notes")
|
|
157
|
+
for (const r of plan.riskNotes) lines.push(`- โ ๏ธ ${r}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (result.prUrl) {
|
|
161
|
+
lines.push("")
|
|
162
|
+
lines.push(`### Pull Request`)
|
|
163
|
+
lines.push(`โ ${result.prUrl}`)
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
lines.push("## Sentinel โ Fix Analysis โน๏ธ")
|
|
167
|
+
lines.push("")
|
|
168
|
+
lines.push(`Could not generate a fix: ${result.error}`)
|
|
169
|
+
|
|
170
|
+
if (result.fixPlan) {
|
|
171
|
+
lines.push("")
|
|
172
|
+
lines.push("### Analysis")
|
|
173
|
+
lines.push(result.fixPlan.analysis)
|
|
174
|
+
|
|
175
|
+
if (result.fixPlan.confidence > 0) {
|
|
176
|
+
lines.push("")
|
|
177
|
+
lines.push(`Confidence was ${(result.fixPlan.confidence * 100).toFixed(0)}% (below threshold).`)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
lines.push("")
|
|
183
|
+
lines.push("---")
|
|
184
|
+
lines.push("*Did we get this right? ๐ / ๐ to inform future fixes*")
|
|
185
|
+
lines.push("")
|
|
186
|
+
lines.push("*Sentinel*")
|
|
187
|
+
|
|
188
|
+
const body = lines.join("\n")
|
|
189
|
+
await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function reportFailure(
|
|
193
|
+
octokit: Octokit,
|
|
194
|
+
prOrIssueNumber: number,
|
|
195
|
+
error: string
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const body = [
|
|
198
|
+
COMMENT_MARKER,
|
|
199
|
+
"## Sentinel โ Review Failed",
|
|
200
|
+
"",
|
|
201
|
+
`Review could not be completed: ${error}`,
|
|
202
|
+
"",
|
|
203
|
+
"This is non-destructive โ no code was modified.",
|
|
204
|
+
"",
|
|
205
|
+
"---",
|
|
206
|
+
"*Sentinel*",
|
|
207
|
+
].join("\n")
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
await upsertComment(octokit, prOrIssueNumber, body)
|
|
211
|
+
} catch {
|
|
212
|
+
core.warning("Failed to post failure comment")
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// โโ PR Review (inline comments with resolve/unresolve) โโ
|
|
217
|
+
|
|
218
|
+
async function submitPRReview(
|
|
219
|
+
octokit: Octokit,
|
|
220
|
+
decision: FinalDecision,
|
|
221
|
+
prNumber: number,
|
|
222
|
+
inlineFindings: ReviewFinding[]
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
const { owner, repo } = github.context.repo
|
|
225
|
+
|
|
226
|
+
try {
|
|
227
|
+
const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber })
|
|
228
|
+
const commitSha = pr.head.sha
|
|
229
|
+
const event = REVIEW_EVENT_MAP[decision.action] || "COMMENT"
|
|
230
|
+
|
|
231
|
+
const comments = inlineFindings.slice(0, 25).map((f) => {
|
|
232
|
+
const comment: {
|
|
233
|
+
path: string
|
|
234
|
+
body: string
|
|
235
|
+
line: number
|
|
236
|
+
side: "RIGHT"
|
|
237
|
+
start_line?: number
|
|
238
|
+
start_side?: "RIGHT"
|
|
239
|
+
} = {
|
|
240
|
+
path: f.file,
|
|
241
|
+
body: formatFindingComment(f),
|
|
242
|
+
line: f.lineEnd && f.lineEnd !== f.lineStart ? f.lineEnd : f.lineStart!,
|
|
243
|
+
side: "RIGHT",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (f.lineEnd && f.lineEnd !== f.lineStart && f.lineStart) {
|
|
247
|
+
comment.start_line = f.lineStart
|
|
248
|
+
comment.start_side = "RIGHT"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return comment
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
await octokit.rest.pulls.createReview({
|
|
255
|
+
owner,
|
|
256
|
+
repo,
|
|
257
|
+
pull_number: prNumber,
|
|
258
|
+
commit_id: commitSha,
|
|
259
|
+
event,
|
|
260
|
+
body: `Sentinel reviewed this PR with ${decision.findings.length} finding(s).`,
|
|
261
|
+
comments,
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
core.info(`Submitted PR review: ${event}, ${comments.length} inline comments`)
|
|
265
|
+
} catch (err) {
|
|
266
|
+
core.warning(`PR review submission failed, falling back to individual comments: ${err}`)
|
|
267
|
+
await postIndividualComments(octokit, inlineFindings, prNumber)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function postIndividualComments(
|
|
272
|
+
octokit: Octokit,
|
|
273
|
+
findings: ReviewFinding[],
|
|
274
|
+
prNumber: number
|
|
275
|
+
): Promise<void> {
|
|
276
|
+
const { owner, repo } = github.context.repo
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber })
|
|
280
|
+
const commitSha = pr.head.sha
|
|
281
|
+
|
|
282
|
+
for (const f of findings.slice(0, 15)) {
|
|
283
|
+
try {
|
|
284
|
+
await octokit.rest.pulls.createReviewComment({
|
|
285
|
+
owner,
|
|
286
|
+
repo,
|
|
287
|
+
pull_number: prNumber,
|
|
288
|
+
commit_id: commitSha,
|
|
289
|
+
path: f.file,
|
|
290
|
+
line: f.lineStart!,
|
|
291
|
+
body: formatFindingComment(f),
|
|
292
|
+
})
|
|
293
|
+
} catch {
|
|
294
|
+
core.debug(`Could not post inline comment on ${f.file}:${f.lineStart}`)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
} catch (err) {
|
|
298
|
+
core.debug(`Inline comments skipped entirely: ${err}`)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function dismissPreviousReviews(octokit: Octokit, prNumber: number): Promise<void> {
|
|
303
|
+
const { owner, repo } = github.context.repo
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const { data: reviews } = await octokit.rest.pulls.listReviews({
|
|
307
|
+
owner,
|
|
308
|
+
repo,
|
|
309
|
+
pull_number: prNumber,
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
for (const review of reviews) {
|
|
313
|
+
if (
|
|
314
|
+
review.body?.includes("Sentinel") &&
|
|
315
|
+
review.state === "CHANGES_REQUESTED"
|
|
316
|
+
) {
|
|
317
|
+
try {
|
|
318
|
+
await octokit.rest.pulls.dismissReview({
|
|
319
|
+
owner,
|
|
320
|
+
repo,
|
|
321
|
+
pull_number: prNumber,
|
|
322
|
+
review_id: review.id,
|
|
323
|
+
message: "Superseded by new Sentinel review",
|
|
324
|
+
})
|
|
325
|
+
} catch {
|
|
326
|
+
core.debug(`Could not dismiss review ${review.id}`)
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch {
|
|
331
|
+
core.debug("Could not list previous reviews for dismissal")
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// โโ Per-Finding Rich Comment (Sentry-style) โโ
|
|
336
|
+
|
|
337
|
+
function formatFindingComment(f: ReviewFinding): string {
|
|
338
|
+
const lines: string[] = []
|
|
339
|
+
|
|
340
|
+
const typeLabel = TYPE_LABEL[f.type] || f.type
|
|
341
|
+
lines.push(`**${typeLabel}:** ${f.explanation}`)
|
|
342
|
+
lines.push("")
|
|
343
|
+
|
|
344
|
+
const sevEmoji = SEVERITY_EMOJI[f.severity]
|
|
345
|
+
const sevLabel = SEVERITY_LABEL[f.severity]
|
|
346
|
+
const source = SOURCE_LABEL[f.source] || f.source
|
|
347
|
+
const confidence = Math.round(f.confidence * 100)
|
|
348
|
+
lines.push(`**Severity:** ${sevEmoji} ${sevLabel} ยท **Confidence:** ${confidence}% ยท **Source:** ${source}`)
|
|
349
|
+
|
|
350
|
+
if (f.suggestedFix) {
|
|
351
|
+
lines.push("")
|
|
352
|
+
lines.push("<details>")
|
|
353
|
+
lines.push("<summary>Suggested Fix</summary>")
|
|
354
|
+
lines.push("")
|
|
355
|
+
lines.push("```suggestion")
|
|
356
|
+
lines.push(f.suggestedFix)
|
|
357
|
+
lines.push("```")
|
|
358
|
+
lines.push("")
|
|
359
|
+
lines.push("</details>")
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
lines.push("")
|
|
363
|
+
lines.push("<details>")
|
|
364
|
+
lines.push("<summary>Prompt for AI Agent</summary>")
|
|
365
|
+
lines.push("")
|
|
366
|
+
lines.push("```")
|
|
367
|
+
lines.push(buildAgentPrompt(f))
|
|
368
|
+
lines.push("```")
|
|
369
|
+
lines.push("")
|
|
370
|
+
lines.push("</details>")
|
|
371
|
+
|
|
372
|
+
lines.push("")
|
|
373
|
+
lines.push("---")
|
|
374
|
+
lines.push("*Did we get this right? ๐ / ๐ to inform future reviews*")
|
|
375
|
+
|
|
376
|
+
return lines.join("\n")
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function buildAgentPrompt(f: ReviewFinding): string {
|
|
380
|
+
const typeLabel = TYPE_LABEL[f.type] || f.type
|
|
381
|
+
const location = f.lineStart
|
|
382
|
+
? `${f.file}#L${f.lineStart}${f.lineEnd && f.lineEnd !== f.lineStart ? `-L${f.lineEnd}` : ""}`
|
|
383
|
+
: f.file
|
|
384
|
+
|
|
385
|
+
const parts: string[] = []
|
|
386
|
+
parts.push("Review the code at the location below. A potential issue has been")
|
|
387
|
+
parts.push("identified by an AI agent.")
|
|
388
|
+
parts.push("Verify if this is a real issue. If it is, propose a fix; if not,")
|
|
389
|
+
parts.push("explain why it's not valid.")
|
|
390
|
+
parts.push("")
|
|
391
|
+
parts.push(`Location: ${location}`)
|
|
392
|
+
parts.push(`Type: ${typeLabel}`)
|
|
393
|
+
parts.push("")
|
|
394
|
+
parts.push(`Potential issue: ${f.explanation}`)
|
|
395
|
+
|
|
396
|
+
if (f.suggestedFix) {
|
|
397
|
+
parts.push("")
|
|
398
|
+
parts.push(`Suggested fix: ${f.suggestedFix}`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
return parts.join("\n")
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// โโ Summary Comment (upserted) โโ
|
|
405
|
+
|
|
406
|
+
function formatSummaryComment(
|
|
407
|
+
decision: FinalDecision,
|
|
408
|
+
nonInlineFindings: ReviewFinding[],
|
|
409
|
+
inlineCount: number
|
|
410
|
+
): string {
|
|
411
|
+
const lines: string[] = [COMMENT_MARKER]
|
|
412
|
+
|
|
413
|
+
const statusEmoji =
|
|
414
|
+
decision.action === "comment_only" ? "โ
" :
|
|
415
|
+
decision.action === "request_changes" ? "โ ๏ธ" :
|
|
416
|
+
decision.action === "needs_human_review" ? "๐" : "โน๏ธ"
|
|
417
|
+
|
|
418
|
+
lines.push(`## Sentinel Review ${statusEmoji}`)
|
|
419
|
+
lines.push("")
|
|
420
|
+
|
|
421
|
+
if (decision.findings.length === 0) {
|
|
422
|
+
lines.push("No issues found. Code looks clean. โจ")
|
|
423
|
+
} else {
|
|
424
|
+
const severityCounts = countBySeverity(decision.findings)
|
|
425
|
+
const countParts: string[] = []
|
|
426
|
+
if (severityCounts.critical) countParts.push(`๐ด ${severityCounts.critical} critical`)
|
|
427
|
+
if (severityCounts.high) countParts.push(`๐ด ${severityCounts.high} high`)
|
|
428
|
+
if (severityCounts.medium) countParts.push(`๐ก ${severityCounts.medium} medium`)
|
|
429
|
+
if (severityCounts.low) countParts.push(`๐ต ${severityCounts.low} low`)
|
|
430
|
+
|
|
431
|
+
lines.push(`**${decision.findings.length} finding(s)** โ ${countParts.join(" ยท ")}`)
|
|
432
|
+
|
|
433
|
+
if (inlineCount > 0) {
|
|
434
|
+
lines.push("")
|
|
435
|
+
lines.push(`> ${inlineCount} finding(s) posted as inline review comments on the diff.`)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (nonInlineFindings.length > 0) {
|
|
440
|
+
lines.push("")
|
|
441
|
+
lines.push("### Findings without line context")
|
|
442
|
+
lines.push("")
|
|
443
|
+
|
|
444
|
+
for (const f of nonInlineFindings) {
|
|
445
|
+
const sevEmoji = SEVERITY_EMOJI[f.severity]
|
|
446
|
+
const typeLabel = TYPE_LABEL[f.type] || f.type
|
|
447
|
+
lines.push(`#### ${sevEmoji} ${f.title}`)
|
|
448
|
+
lines.push("")
|
|
449
|
+
lines.push(`**${typeLabel}** in \`${f.file}\` ยท **Severity:** ${SEVERITY_LABEL[f.severity]} ยท **Confidence:** ${Math.round(f.confidence * 100)}%`)
|
|
450
|
+
lines.push("")
|
|
451
|
+
lines.push(f.explanation)
|
|
452
|
+
|
|
453
|
+
if (f.suggestedFix) {
|
|
454
|
+
lines.push("")
|
|
455
|
+
lines.push("<details>")
|
|
456
|
+
lines.push("<summary>Suggested Fix</summary>")
|
|
457
|
+
lines.push("")
|
|
458
|
+
lines.push("```suggestion")
|
|
459
|
+
lines.push(f.suggestedFix)
|
|
460
|
+
lines.push("```")
|
|
461
|
+
lines.push("")
|
|
462
|
+
lines.push("</details>")
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
lines.push("")
|
|
466
|
+
lines.push("<details>")
|
|
467
|
+
lines.push("<summary>Prompt for AI Agent</summary>")
|
|
468
|
+
lines.push("")
|
|
469
|
+
lines.push("```")
|
|
470
|
+
lines.push(buildAgentPrompt(f))
|
|
471
|
+
lines.push("```")
|
|
472
|
+
lines.push("")
|
|
473
|
+
lines.push("</details>")
|
|
474
|
+
lines.push("")
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (decision.anthropicReview && decision.openaiReview) {
|
|
479
|
+
lines.push("")
|
|
480
|
+
lines.push("<details>")
|
|
481
|
+
lines.push("<summary>Model Summaries</summary>")
|
|
482
|
+
lines.push("")
|
|
483
|
+
lines.push(`**Anthropic (architecture):** ${decision.anthropicReview.summary}`)
|
|
484
|
+
lines.push("")
|
|
485
|
+
lines.push(`**OpenAI (implementation):** ${decision.openaiReview.summary}`)
|
|
486
|
+
lines.push("")
|
|
487
|
+
lines.push("</details>")
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (decision.critique) {
|
|
491
|
+
const { agreedFindings, disputedFindings } = decision.critique
|
|
492
|
+
if (agreedFindings.length || disputedFindings.length) {
|
|
493
|
+
lines.push("")
|
|
494
|
+
lines.push("<details>")
|
|
495
|
+
lines.push("<summary>Adversarial Debate</summary>")
|
|
496
|
+
lines.push("")
|
|
497
|
+
if (agreedFindings.length) {
|
|
498
|
+
lines.push("**Agreed:**")
|
|
499
|
+
for (const a of agreedFindings) lines.push(`- โ ${a}`)
|
|
500
|
+
lines.push("")
|
|
501
|
+
}
|
|
502
|
+
if (disputedFindings.length) {
|
|
503
|
+
lines.push("**Disputed:**")
|
|
504
|
+
for (const d of disputedFindings) lines.push(`- โ ${d.finding}: *${d.reason}*`)
|
|
505
|
+
lines.push("")
|
|
506
|
+
}
|
|
507
|
+
lines.push("</details>")
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
lines.push("")
|
|
512
|
+
lines.push("---")
|
|
513
|
+
lines.push("")
|
|
514
|
+
|
|
515
|
+
const verdict =
|
|
516
|
+
decision.action === "comment_only" ? "No blockers โ ready to merge" :
|
|
517
|
+
decision.action === "request_changes" ? "Changes requested โ address findings before merge" :
|
|
518
|
+
decision.action === "needs_human_review" ? "Human review required โ sensitive changes detected" :
|
|
519
|
+
decision.action
|
|
520
|
+
|
|
521
|
+
lines.push(`**Verdict:** ${verdict}`)
|
|
522
|
+
|
|
523
|
+
const totalTokens =
|
|
524
|
+
decision.tokenUsage.anthropic.input + decision.tokenUsage.anthropic.output +
|
|
525
|
+
decision.tokenUsage.openai.input + decision.tokenUsage.openai.output
|
|
526
|
+
const seconds = (decision.durationMs / 1000).toFixed(1)
|
|
527
|
+
|
|
528
|
+
lines.push("")
|
|
529
|
+
lines.push(`*Sentinel โ dual-model review in ${seconds}s ยท ${totalTokens.toLocaleString()} tokens*`)
|
|
530
|
+
|
|
531
|
+
return lines.join("\n")
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// โโ Helpers โโ
|
|
535
|
+
|
|
536
|
+
function countBySeverity(findings: ReviewFinding[]): Record<FindingSeverity, number> {
|
|
537
|
+
const counts: Record<FindingSeverity, number> = { critical: 0, high: 0, medium: 0, low: 0 }
|
|
538
|
+
for (const f of findings) counts[f.severity]++
|
|
539
|
+
return counts
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function upsertComment(octokit: Octokit, issueNumber: number, body: string): Promise<void> {
|
|
543
|
+
const { owner, repo } = github.context.repo
|
|
544
|
+
|
|
545
|
+
try {
|
|
546
|
+
const { data: comments } = await octokit.rest.issues.listComments({
|
|
547
|
+
owner,
|
|
548
|
+
repo,
|
|
549
|
+
issue_number: issueNumber,
|
|
550
|
+
})
|
|
551
|
+
|
|
552
|
+
const existing = comments.find((c) => c.body?.includes(COMMENT_MARKER))
|
|
553
|
+
if (existing) {
|
|
554
|
+
await octokit.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body })
|
|
555
|
+
} else {
|
|
556
|
+
await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
|
|
557
|
+
}
|
|
558
|
+
} catch (err) {
|
|
559
|
+
core.warning(`Failed to upsert comment: ${err}`)
|
|
560
|
+
try {
|
|
561
|
+
const { owner, repo } = github.context.repo
|
|
562
|
+
await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
|
|
563
|
+
} catch {
|
|
564
|
+
core.warning("Failed to create comment as fallback")
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function postStepSummary(decision: FinalDecision): Promise<void> {
|
|
570
|
+
const lines: string[] = []
|
|
571
|
+
lines.push("### Sentinel Review")
|
|
572
|
+
lines.push("")
|
|
573
|
+
lines.push("| Metric | Value |")
|
|
574
|
+
lines.push("|--------|-------|")
|
|
575
|
+
lines.push(`| Findings | ${decision.findings.length} |`)
|
|
576
|
+
lines.push(`| Action | \`${decision.action}\` |`)
|
|
577
|
+
lines.push(`| Duration | ${(decision.durationMs / 1000).toFixed(1)}s |`)
|
|
578
|
+
lines.push(`| Anthropic tokens | ${(decision.tokenUsage.anthropic.input + decision.tokenUsage.anthropic.output).toLocaleString()} |`)
|
|
579
|
+
lines.push(`| OpenAI tokens | ${(decision.tokenUsage.openai.input + decision.tokenUsage.openai.output).toLocaleString()} |`)
|
|
580
|
+
|
|
581
|
+
if (decision.findings.length > 0) {
|
|
582
|
+
lines.push("")
|
|
583
|
+
lines.push("| Severity | Count |")
|
|
584
|
+
lines.push("|----------|-------|")
|
|
585
|
+
const counts = countBySeverity(decision.findings)
|
|
586
|
+
for (const [sev, count] of Object.entries(counts)) {
|
|
587
|
+
if (count > 0) lines.push(`| ${SEVERITY_EMOJI[sev as FindingSeverity]} ${SEVERITY_LABEL[sev as FindingSeverity]} | ${count} |`)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
core.summary.addRaw(lines.join("\n"))
|
|
592
|
+
await core.summary.write()
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function setOutputs(decision: FinalDecision): void {
|
|
596
|
+
const quality = computeQualitySignal(decision)
|
|
597
|
+
|
|
598
|
+
const artifact = {
|
|
599
|
+
version: 2,
|
|
600
|
+
timestamp: new Date().toISOString(),
|
|
601
|
+
action: decision.action,
|
|
602
|
+
rationale: decision.rationale,
|
|
603
|
+
findings: decision.findings,
|
|
604
|
+
token_usage: decision.tokenUsage,
|
|
605
|
+
duration_ms: decision.durationMs,
|
|
606
|
+
quality_score: quality.quality_score,
|
|
607
|
+
model_agreement: quality.model_agreement,
|
|
608
|
+
dimensions: quality.dimensions,
|
|
609
|
+
severity_counts: quality.severity_counts,
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
core.setOutput("review_json", JSON.stringify(artifact))
|
|
613
|
+
core.setOutput("findings_count", decision.findings.length.toString())
|
|
614
|
+
core.setOutput("action", decision.action)
|
|
615
|
+
core.setOutput("quality_score", quality.quality_score.toFixed(4))
|
|
616
|
+
core.setOutput("model_agreement", quality.model_agreement.toFixed(4))
|
|
617
|
+
core.setOutput("findings_critical", quality.severity_counts.critical.toString())
|
|
618
|
+
core.setOutput("findings_high", quality.severity_counts.high.toString())
|
|
619
|
+
core.setOutput("findings_medium", quality.severity_counts.medium.toString())
|
|
620
|
+
core.setOutput("findings_low", quality.severity_counts.low.toString())
|
|
621
|
+
core.setOutput("dim_correctness", quality.dimensions.correctness.toFixed(4))
|
|
622
|
+
core.setOutput("dim_coverage", quality.dimensions.coverage.toFixed(4))
|
|
623
|
+
core.setOutput("dim_architecture", quality.dimensions.architecture.toFixed(4))
|
|
624
|
+
core.setOutput("dim_value", quality.dimensions.value.toFixed(4))
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
interface QualitySignal {
|
|
628
|
+
quality_score: number
|
|
629
|
+
model_agreement: number
|
|
630
|
+
dimensions: { correctness: number; coverage: number; architecture: number; value: number }
|
|
631
|
+
severity_counts: Record<FindingSeverity, number>
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function computeQualitySignal(decision: FinalDecision): QualitySignal {
|
|
635
|
+
const counts = countBySeverity(decision.findings)
|
|
636
|
+
|
|
637
|
+
const penalties = counts.critical * 0.25 + counts.high * 0.15 + counts.medium * 0.05 + counts.low * 0.01
|
|
638
|
+
const quality_score = Math.max(0, Math.min(1, 1.0 - penalties))
|
|
639
|
+
|
|
640
|
+
let model_agreement = 1.0
|
|
641
|
+
if (decision.anthropicReview && decision.openaiReview) {
|
|
642
|
+
const agreedCount = decision.critique?.agreedFindings?.length ?? 0
|
|
643
|
+
const disputedCount = decision.critique?.disputedFindings?.length ?? 0
|
|
644
|
+
const totalDebated = agreedCount + disputedCount
|
|
645
|
+
model_agreement = totalDebated > 0 ? agreedCount / totalDebated : 1.0
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const dimPenalties: Record<FindingType, keyof QualitySignal["dimensions"]> = {
|
|
649
|
+
bug: "correctness",
|
|
650
|
+
security: "correctness",
|
|
651
|
+
performance: "value",
|
|
652
|
+
maintainability: "value",
|
|
653
|
+
test_gap: "coverage",
|
|
654
|
+
architecture: "architecture",
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const dims = { correctness: 1.0, coverage: 1.0, architecture: 1.0, value: 1.0 }
|
|
658
|
+
for (const f of decision.findings) {
|
|
659
|
+
const dim = dimPenalties[f.type]
|
|
660
|
+
if (!dim) continue
|
|
661
|
+
const penalty = f.severity === "critical" ? 0.3 : f.severity === "high" ? 0.2 : f.severity === "medium" ? 0.1 : 0.03
|
|
662
|
+
dims[dim] = Math.max(0, dims[dim] - penalty)
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
return { quality_score, model_agreement, dimensions: dims, severity_counts: counts }
|
|
666
|
+
}
|