@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,353 @@
|
|
|
1
|
+
import * as core from "@actions/core"
|
|
2
|
+
import * as github from "@actions/github"
|
|
3
|
+
import { execSync } from "child_process"
|
|
4
|
+
import * as fs from "fs"
|
|
5
|
+
import type { ModelClient } from "./models/types"
|
|
6
|
+
import type { ReviewContext, FixMode, FixPlan, FixResult, CodeContext } from "./types"
|
|
7
|
+
import { analyzeCodebase, extractKeywords } from "./codebase"
|
|
8
|
+
import { parseFixPlan, parseFixReview } from "./schemas/fix"
|
|
9
|
+
|
|
10
|
+
type Octokit = ReturnType<typeof github.getOctokit>
|
|
11
|
+
|
|
12
|
+
const PLAN_SYSTEM = `You are a senior engineer analyzing a bug report for a software project.
|
|
13
|
+
|
|
14
|
+
Given the issue description and relevant source code, produce a fix plan.
|
|
15
|
+
|
|
16
|
+
Rules:
|
|
17
|
+
1. Be conservative — only propose changes you are confident will fix the issue
|
|
18
|
+
2. Use search/replace pairs for modifications so changes can be applied precisely
|
|
19
|
+
3. The "search" string must be an EXACT substring of the current file contents
|
|
20
|
+
4. Keep changes minimal — fix the bug, don't refactor
|
|
21
|
+
5. If you cannot determine a fix from the available context, set fixable=false
|
|
22
|
+
|
|
23
|
+
Output a JSON object (no markdown fences):
|
|
24
|
+
{
|
|
25
|
+
"analysis": "your understanding of the issue and root cause",
|
|
26
|
+
"fixable": true/false,
|
|
27
|
+
"confidence": 0.0-1.0,
|
|
28
|
+
"files": [
|
|
29
|
+
{
|
|
30
|
+
"path": "path/to/file",
|
|
31
|
+
"action": "modify|create|delete",
|
|
32
|
+
"changes": [{"search": "exact text to find", "replace": "replacement text"}],
|
|
33
|
+
"content": "full content for new files only",
|
|
34
|
+
"explanation": "why this change fixes the issue"
|
|
35
|
+
}
|
|
36
|
+
],
|
|
37
|
+
"commit_message": "fix: description (fixes #N)",
|
|
38
|
+
"test_suggestions": ["test cases that should verify the fix"],
|
|
39
|
+
"risk_notes": ["potential risks or side effects"]
|
|
40
|
+
}`
|
|
41
|
+
|
|
42
|
+
const REVIEW_SYSTEM = `You are a senior engineer reviewing a proposed bug fix.
|
|
43
|
+
|
|
44
|
+
Given:
|
|
45
|
+
- The original issue
|
|
46
|
+
- The proposed code changes
|
|
47
|
+
- The relevant source code
|
|
48
|
+
|
|
49
|
+
Evaluate whether the fix:
|
|
50
|
+
1. Actually addresses the root cause
|
|
51
|
+
2. Handles edge cases
|
|
52
|
+
3. Could break anything else
|
|
53
|
+
4. Is the minimal correct change
|
|
54
|
+
|
|
55
|
+
Output a JSON object (no markdown fences):
|
|
56
|
+
{
|
|
57
|
+
"approved": true/false,
|
|
58
|
+
"confidence": 0.0-1.0,
|
|
59
|
+
"concerns": ["any concerns about the fix"],
|
|
60
|
+
"verdict": "brief assessment"
|
|
61
|
+
}`
|
|
62
|
+
|
|
63
|
+
export async function fixIssue(
|
|
64
|
+
ctx: ReviewContext,
|
|
65
|
+
anthropic: ModelClient | null,
|
|
66
|
+
openai: ModelClient | null,
|
|
67
|
+
octokit: Octokit,
|
|
68
|
+
mode: FixMode,
|
|
69
|
+
confidenceThreshold: number
|
|
70
|
+
): Promise<FixResult> {
|
|
71
|
+
if (!ctx.issue) return { success: false, error: "No issue context" }
|
|
72
|
+
|
|
73
|
+
const planner = anthropic || openai
|
|
74
|
+
const reviewer = anthropic && openai ? openai : planner
|
|
75
|
+
if (!planner) return { success: false, error: "No model available" }
|
|
76
|
+
|
|
77
|
+
core.info(`Analyzing issue #${ctx.issue.number}: ${ctx.issue.title}`)
|
|
78
|
+
|
|
79
|
+
const keywords = extractKeywords(ctx.issue.title, ctx.issue.body)
|
|
80
|
+
core.info(`Extracted ${keywords.length} keywords: ${keywords.slice(0, 5).join(", ")}`)
|
|
81
|
+
|
|
82
|
+
const codeContext = await analyzeCodebase(keywords)
|
|
83
|
+
|
|
84
|
+
if (codeContext.files.length === 0) {
|
|
85
|
+
return { success: false, error: "Could not find relevant code for this issue" }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const fixPlan = await generateFixPlan(planner, ctx, codeContext)
|
|
89
|
+
|
|
90
|
+
if (!fixPlan.fixable) {
|
|
91
|
+
return { success: false, fixPlan, error: `Issue not fixable: ${fixPlan.analysis}` }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (fixPlan.confidence < confidenceThreshold) {
|
|
95
|
+
return {
|
|
96
|
+
success: false,
|
|
97
|
+
fixPlan,
|
|
98
|
+
error: `Confidence too low: ${(fixPlan.confidence * 100).toFixed(0)}% (threshold: ${(confidenceThreshold * 100).toFixed(0)}%)`,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (reviewer && reviewer !== planner) {
|
|
103
|
+
const reviewResult = await reviewFixPlan(reviewer, ctx, fixPlan, codeContext)
|
|
104
|
+
if (!reviewResult.approved) {
|
|
105
|
+
core.warning(`Fix review rejected: ${reviewResult.verdict}`)
|
|
106
|
+
fixPlan.riskNotes.push(`Review concerns: ${reviewResult.concerns.join("; ")}`)
|
|
107
|
+
|
|
108
|
+
if (reviewResult.confidence < 0.5) {
|
|
109
|
+
return {
|
|
110
|
+
success: false,
|
|
111
|
+
fixPlan,
|
|
112
|
+
error: `Fix rejected by reviewer: ${reviewResult.verdict}`,
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (mode === "propose_only") {
|
|
119
|
+
core.info("Propose-only mode — returning fix plan without applying")
|
|
120
|
+
return { success: true, fixPlan }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const branch = await createFixBranch(ctx.issue.number)
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await applyChanges(fixPlan)
|
|
127
|
+
await commitAndPush(branch, fixPlan.commitMessage)
|
|
128
|
+
|
|
129
|
+
const pr = await createFixPR(octokit, ctx, fixPlan, branch)
|
|
130
|
+
core.info(`Fix PR created: ${pr.url}`)
|
|
131
|
+
|
|
132
|
+
if (mode === "yolo") {
|
|
133
|
+
core.info("Yolo mode — auto-merging fix PR")
|
|
134
|
+
await mergeFixPR(octokit, pr.number)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
success: true,
|
|
139
|
+
fixPlan,
|
|
140
|
+
branch,
|
|
141
|
+
prNumber: pr.number,
|
|
142
|
+
prUrl: pr.url,
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
core.warning(`Failed to apply fix: ${err}`)
|
|
146
|
+
try {
|
|
147
|
+
execSync(`git checkout ${ctx.repository.defaultBranch} 2>/dev/null || git checkout main`, { encoding: "utf-8" })
|
|
148
|
+
execSync(`git branch -D ${branch} 2>/dev/null`, { encoding: "utf-8" })
|
|
149
|
+
} catch { /* cleanup best effort */ }
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
fixPlan,
|
|
154
|
+
error: `Failed to apply changes: ${err instanceof Error ? err.message : String(err)}`,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function generateFixPlan(
|
|
160
|
+
model: ModelClient,
|
|
161
|
+
ctx: ReviewContext,
|
|
162
|
+
codeContext: CodeContext
|
|
163
|
+
): Promise<FixPlan> {
|
|
164
|
+
const issue = ctx.issue!
|
|
165
|
+
const filesContext = codeContext.files
|
|
166
|
+
.map((f) => `### ${f.path}\nRelevance: ${f.relevance}\n\`\`\`\n${f.content}\n\`\`\``)
|
|
167
|
+
.join("\n\n")
|
|
168
|
+
|
|
169
|
+
const prompt = [
|
|
170
|
+
`# Issue #${issue.number}: ${issue.title}`,
|
|
171
|
+
"",
|
|
172
|
+
issue.body,
|
|
173
|
+
"",
|
|
174
|
+
"## Project Structure",
|
|
175
|
+
codeContext.structure,
|
|
176
|
+
"",
|
|
177
|
+
"## Dependencies",
|
|
178
|
+
codeContext.dependencies,
|
|
179
|
+
"",
|
|
180
|
+
"## Relevant Source Files",
|
|
181
|
+
filesContext,
|
|
182
|
+
].join("\n")
|
|
183
|
+
|
|
184
|
+
const result = await model.chat(PLAN_SYSTEM, prompt)
|
|
185
|
+
const parsed = parseFixPlan(result.text)
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
analysis: parsed.analysis,
|
|
189
|
+
fixable: parsed.fixable,
|
|
190
|
+
confidence: parsed.confidence,
|
|
191
|
+
files: parsed.files.map((f) => ({
|
|
192
|
+
path: f.path,
|
|
193
|
+
action: f.action,
|
|
194
|
+
changes: f.changes,
|
|
195
|
+
content: f.content,
|
|
196
|
+
explanation: f.explanation,
|
|
197
|
+
})),
|
|
198
|
+
commitMessage: parsed.commit_message,
|
|
199
|
+
testSuggestions: parsed.test_suggestions,
|
|
200
|
+
riskNotes: parsed.risk_notes,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function reviewFixPlan(
|
|
205
|
+
model: ModelClient,
|
|
206
|
+
ctx: ReviewContext,
|
|
207
|
+
plan: FixPlan,
|
|
208
|
+
codeContext: CodeContext
|
|
209
|
+
): Promise<{ approved: boolean; confidence: number; concerns: string[]; verdict: string }> {
|
|
210
|
+
const issue = ctx.issue!
|
|
211
|
+
const changesDescription = plan.files
|
|
212
|
+
.map((f) => `${f.path} (${f.action}): ${f.explanation}`)
|
|
213
|
+
.join("\n")
|
|
214
|
+
|
|
215
|
+
const prompt = [
|
|
216
|
+
`# Original Issue #${issue.number}: ${issue.title}`,
|
|
217
|
+
issue.body,
|
|
218
|
+
"",
|
|
219
|
+
"# Proposed Fix",
|
|
220
|
+
`Analysis: ${plan.analysis}`,
|
|
221
|
+
`Confidence: ${plan.confidence}`,
|
|
222
|
+
"",
|
|
223
|
+
"## Changes",
|
|
224
|
+
changesDescription,
|
|
225
|
+
"",
|
|
226
|
+
"## Detailed Changes",
|
|
227
|
+
JSON.stringify(plan.files, null, 2),
|
|
228
|
+
"",
|
|
229
|
+
"## Relevant Code Context",
|
|
230
|
+
codeContext.files.slice(0, 5).map((f) => `### ${f.path}\n\`\`\`\n${f.content.substring(0, 3000)}\n\`\`\``).join("\n\n"),
|
|
231
|
+
].join("\n")
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const result = await model.chat(REVIEW_SYSTEM, prompt)
|
|
235
|
+
const parsed = parseFixReview(result.text)
|
|
236
|
+
return parsed
|
|
237
|
+
} catch {
|
|
238
|
+
return { approved: true, confidence: 0.5, concerns: ["Review parsing failed"], verdict: "Proceeding with caution" }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function createFixBranch(issueNumber: number): Promise<string> {
|
|
243
|
+
const branch = `fix/issue-${issueNumber}`
|
|
244
|
+
execSync(`git checkout -b ${branch}`, { encoding: "utf-8" })
|
|
245
|
+
return branch
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function applyChanges(plan: FixPlan): Promise<void> {
|
|
249
|
+
for (const file of plan.files) {
|
|
250
|
+
if (file.action === "delete") {
|
|
251
|
+
if (fs.existsSync(file.path)) {
|
|
252
|
+
fs.unlinkSync(file.path)
|
|
253
|
+
core.info(`Deleted: ${file.path}`)
|
|
254
|
+
}
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (file.action === "create") {
|
|
259
|
+
const dir = file.path.split("/").slice(0, -1).join("/")
|
|
260
|
+
if (dir) fs.mkdirSync(dir, { recursive: true })
|
|
261
|
+
fs.writeFileSync(file.path, file.content || "")
|
|
262
|
+
core.info(`Created: ${file.path}`)
|
|
263
|
+
continue
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (file.action === "modify" && file.changes) {
|
|
267
|
+
if (!fs.existsSync(file.path)) {
|
|
268
|
+
throw new Error(`File not found: ${file.path}`)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
let content = fs.readFileSync(file.path, "utf-8")
|
|
272
|
+
|
|
273
|
+
for (const change of file.changes) {
|
|
274
|
+
if (!content.includes(change.search)) {
|
|
275
|
+
throw new Error(`Search text not found in ${file.path}: "${change.search.substring(0, 60)}..."`)
|
|
276
|
+
}
|
|
277
|
+
content = content.replace(change.search, change.replace)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
fs.writeFileSync(file.path, content)
|
|
281
|
+
core.info(`Modified: ${file.path} (${file.changes.length} change(s))`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function commitAndPush(branch: string, message: string): Promise<void> {
|
|
287
|
+
execSync('git config user.name "sentinel[bot]"', { encoding: "utf-8" })
|
|
288
|
+
execSync('git config user.email "sentinel[bot]@users.noreply.github.com"', { encoding: "utf-8" })
|
|
289
|
+
execSync("git add -A", { encoding: "utf-8" })
|
|
290
|
+
execSync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { encoding: "utf-8" })
|
|
291
|
+
execSync(`git push origin ${branch}`, { encoding: "utf-8" })
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async function createFixPR(
|
|
295
|
+
octokit: Octokit,
|
|
296
|
+
ctx: ReviewContext,
|
|
297
|
+
plan: FixPlan,
|
|
298
|
+
branch: string
|
|
299
|
+
): Promise<{ number: number; url: string }> {
|
|
300
|
+
const { owner, name: repo } = ctx.repository
|
|
301
|
+
const issue = ctx.issue!
|
|
302
|
+
|
|
303
|
+
const body = [
|
|
304
|
+
`Fixes #${issue.number}`,
|
|
305
|
+
"",
|
|
306
|
+
"## Analysis",
|
|
307
|
+
plan.analysis,
|
|
308
|
+
"",
|
|
309
|
+
"## Changes",
|
|
310
|
+
...plan.files.map((f) => `- **${f.path}** (${f.action}): ${f.explanation}`),
|
|
311
|
+
"",
|
|
312
|
+
plan.riskNotes.length > 0
|
|
313
|
+
? `## Risk Notes\n${plan.riskNotes.map((n) => `- ⚠️ ${n}`).join("\n")}`
|
|
314
|
+
: "",
|
|
315
|
+
"",
|
|
316
|
+
plan.testSuggestions.length > 0
|
|
317
|
+
? `## Suggested Tests\n${plan.testSuggestions.map((t) => `- [ ] ${t}`).join("\n")}`
|
|
318
|
+
: "",
|
|
319
|
+
"",
|
|
320
|
+
`---`,
|
|
321
|
+
`*Generated by Sentinel · Confidence: ${(plan.confidence * 100).toFixed(0)}%*`,
|
|
322
|
+
]
|
|
323
|
+
.filter(Boolean)
|
|
324
|
+
.join("\n")
|
|
325
|
+
|
|
326
|
+
const { data: pr } = await octokit.rest.pulls.create({
|
|
327
|
+
owner,
|
|
328
|
+
repo,
|
|
329
|
+
title: plan.commitMessage,
|
|
330
|
+
body,
|
|
331
|
+
head: branch,
|
|
332
|
+
base: ctx.repository.defaultBranch,
|
|
333
|
+
draft: ctx.repoPolicies.fix.createDraftPr,
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
return { number: pr.number, url: pr.html_url }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function mergeFixPR(octokit: Octokit, prNumber: number): Promise<void> {
|
|
340
|
+
const { owner, repo } = github.context.repo
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
await octokit.rest.pulls.merge({
|
|
344
|
+
owner,
|
|
345
|
+
repo,
|
|
346
|
+
pull_number: prNumber,
|
|
347
|
+
merge_method: "squash",
|
|
348
|
+
})
|
|
349
|
+
core.info(`Auto-merged PR #${prNumber} (yolo mode)`)
|
|
350
|
+
} catch (err) {
|
|
351
|
+
core.warning(`Auto-merge failed for PR #${prNumber}: ${err}`)
|
|
352
|
+
}
|
|
353
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import * as core from "@actions/core"
|
|
2
|
+
import * as github from "@actions/github"
|
|
3
|
+
import { routeEvent } from "./router"
|
|
4
|
+
import { buildPRContext, buildIssueContext } from "./context"
|
|
5
|
+
import { loadPolicies, evaluateTrust } from "./policy"
|
|
6
|
+
import { orchestrateReview } from "./orchestrator"
|
|
7
|
+
import { reportReview, reportIssueTriage, reportFailure, reportFixResult } from "./reporter"
|
|
8
|
+
import { fixIssue } from "./fixer"
|
|
9
|
+
import { handleResponse } from "./responder"
|
|
10
|
+
import { AnthropicClient } from "./models/anthropic"
|
|
11
|
+
import { OpenAIClient } from "./models/openai"
|
|
12
|
+
import { OpenRouterClient } from "./models/openrouter"
|
|
13
|
+
import { readPrContact, readCommitContact, notifySubwayAgent } from "./subway"
|
|
14
|
+
import type { ModelClient } from "./models/types"
|
|
15
|
+
|
|
16
|
+
async function run(): Promise<void> {
|
|
17
|
+
const start = Date.now()
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN || ""
|
|
21
|
+
const anthropicKey = core.getInput("anthropic_api_key")
|
|
22
|
+
const openaiKey = core.getInput("openai_api_key")
|
|
23
|
+
const openrouterKey = core.getInput("openrouter_api_key")
|
|
24
|
+
const configPath = core.getInput("config_path") || ".github/sentinel.yml"
|
|
25
|
+
const modeOverride = core.getInput("mode") || undefined
|
|
26
|
+
const debug = core.getInput("debug") === "true"
|
|
27
|
+
|
|
28
|
+
if (debug) core.info(`Event: ${github.context.eventName} / ${github.context.payload.action}`)
|
|
29
|
+
|
|
30
|
+
if (!githubToken) {
|
|
31
|
+
core.setFailed("github_token is required")
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const octokit = github.getOctokit(githubToken)
|
|
36
|
+
const policies = await loadPolicies(octokit, configPath, modeOverride)
|
|
37
|
+
const routed = routeEvent(policies)
|
|
38
|
+
|
|
39
|
+
if (debug) core.info(`Routed to: ${routed.actionType}`)
|
|
40
|
+
|
|
41
|
+
if (routed.actionType === "noop") {
|
|
42
|
+
core.info("No action required for this event")
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let anthropic: ModelClient | null = null
|
|
47
|
+
let openai: ModelClient | null = null
|
|
48
|
+
|
|
49
|
+
if (anthropicKey && policies.models.anthropic.enabled) {
|
|
50
|
+
anthropic = new AnthropicClient(anthropicKey, policies.models.anthropic.model)
|
|
51
|
+
} else if (openrouterKey && policies.models.anthropic.enabled) {
|
|
52
|
+
const model = core.getInput("openrouter_anthropic_model") || "anthropic/claude-sonnet-4-20250514"
|
|
53
|
+
anthropic = new OpenRouterClient(openrouterKey, model, "anthropic")
|
|
54
|
+
core.info(`Anthropic slot: using OpenRouter fallback (${model})`)
|
|
55
|
+
} else {
|
|
56
|
+
core.warning("Anthropic disabled or no API key (no OpenRouter fallback)")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (openaiKey && policies.models.openai.enabled) {
|
|
60
|
+
openai = new OpenAIClient(openaiKey, policies.models.openai.model)
|
|
61
|
+
} else if (openrouterKey && policies.models.openai.enabled) {
|
|
62
|
+
const model = core.getInput("openrouter_openai_model") || "openai/gpt-4o"
|
|
63
|
+
openai = new OpenRouterClient(openrouterKey, model, "openai")
|
|
64
|
+
core.info(`OpenAI slot: using OpenRouter fallback (${model})`)
|
|
65
|
+
} else {
|
|
66
|
+
core.warning("OpenAI disabled or no API key (no OpenRouter fallback)")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!anthropic && !openai) {
|
|
70
|
+
core.setFailed("At least one model must be configured (anthropic_api_key, openai_api_key, or openrouter_api_key)")
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
switch (routed.actionType) {
|
|
75
|
+
case "pr_review": {
|
|
76
|
+
await handlePRReview(octokit, routed.context, anthropic, openai, debug, policies.review.summaryOnClean)
|
|
77
|
+
break
|
|
78
|
+
}
|
|
79
|
+
case "issue_fix": {
|
|
80
|
+
await handleIssueFix(octokit, routed.context, anthropic, openai, debug)
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
case "issue_triage": {
|
|
84
|
+
await handleIssueTriage(octokit, routed.context)
|
|
85
|
+
break
|
|
86
|
+
}
|
|
87
|
+
case "respond": {
|
|
88
|
+
if (routed.responseContext) {
|
|
89
|
+
const model = anthropic || openai!
|
|
90
|
+
await handleResponse(routed.context, routed.responseContext, model, octokit)
|
|
91
|
+
}
|
|
92
|
+
break
|
|
93
|
+
}
|
|
94
|
+
case "pr_fix": {
|
|
95
|
+
core.info("PR fix mode not yet implemented (Phase 2)")
|
|
96
|
+
break
|
|
97
|
+
}
|
|
98
|
+
case "slash_command": {
|
|
99
|
+
const cmd = routed.slashCommand
|
|
100
|
+
if (cmd?.command === "fix") {
|
|
101
|
+
if (cmd.isPR) {
|
|
102
|
+
core.info("PR fix via slash command not yet implemented")
|
|
103
|
+
} else {
|
|
104
|
+
await handleIssueFix(octokit, routed.context, anthropic, openai, debug)
|
|
105
|
+
}
|
|
106
|
+
} else if (cmd?.command === "review" && cmd.isPR) {
|
|
107
|
+
await handlePRReview(octokit, routed.context, anthropic, openai, debug)
|
|
108
|
+
} else {
|
|
109
|
+
core.info(`Slash command /bot ${cmd?.command} — not yet implemented`)
|
|
110
|
+
}
|
|
111
|
+
break
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
core.info(`Sentinel completed in ${((Date.now() - start) / 1000).toFixed(1)}s`)
|
|
116
|
+
} catch (err) {
|
|
117
|
+
core.setFailed(`Sentinel failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
const githubToken = core.getInput("github_token") || process.env.GITHUB_TOKEN || ""
|
|
121
|
+
if (githubToken) {
|
|
122
|
+
const octokit = github.getOctokit(githubToken)
|
|
123
|
+
const prNumber = github.context.payload.pull_request?.number
|
|
124
|
+
|| github.context.payload.issue?.number
|
|
125
|
+
if (prNumber) {
|
|
126
|
+
await reportFailure(octokit, prNumber, err instanceof Error ? err.message : String(err))
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} catch {
|
|
130
|
+
core.debug("Could not post failure comment")
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function handlePRReview(
|
|
136
|
+
octokit: ReturnType<typeof github.getOctokit>,
|
|
137
|
+
ctx: import("./types").ReviewContext,
|
|
138
|
+
anthropic: ModelClient | null,
|
|
139
|
+
openai: ModelClient | null,
|
|
140
|
+
debug: boolean,
|
|
141
|
+
summaryOnClean = false
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
if (!ctx.pullRequest) {
|
|
144
|
+
core.warning("No PR context available")
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
ctx = await buildPRContext(ctx, octokit)
|
|
149
|
+
|
|
150
|
+
if (ctx.pullRequest!.changedFiles.length === 0) {
|
|
151
|
+
core.info("No changed files to review")
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (debug) {
|
|
156
|
+
core.info(`Reviewing PR #${ctx.pullRequest!.number}: ${ctx.pullRequest!.changedFiles.length} files`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const decision = await orchestrateReview(ctx, anthropic, openai)
|
|
160
|
+
|
|
161
|
+
if (debug) {
|
|
162
|
+
core.info(`Decision: ${decision.action}, ${decision.findings.length} findings, ${decision.durationMs}ms`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
await reportReview(octokit, decision, ctx.pullRequest!.number, summaryOnClean)
|
|
166
|
+
|
|
167
|
+
if (core.getInput("subway_notify") !== "false") {
|
|
168
|
+
try {
|
|
169
|
+
// Commit trailer takes priority — always fresh, no file needed.
|
|
170
|
+
// Falls back to .subway/pr-contact if no trailer found.
|
|
171
|
+
const headSha = github.context.payload.pull_request?.head?.sha as string | undefined
|
|
172
|
+
const contact = readCommitContact(headSha) ?? readPrContact()
|
|
173
|
+
const bridgeUrl = core.getInput("subway_bridge_url") || "https://relay.subway.dev"
|
|
174
|
+
const { owner, name } = ctx.repository
|
|
175
|
+
const prNumber = ctx.pullRequest!.number
|
|
176
|
+
|
|
177
|
+
// Use event payload for PR state — avoids an extra API call.
|
|
178
|
+
// github.context.payload.pull_request is populated for pull_request events;
|
|
179
|
+
// fall back to API only for non-PR triggers (e.g. issue_comment on a PR).
|
|
180
|
+
const prPayload = github.context.payload.pull_request
|
|
181
|
+
let prState: "open" | "closed" | "merged"
|
|
182
|
+
if (prPayload) {
|
|
183
|
+
prState = prPayload.merged ? "merged" : prPayload.state === "open" ? "open" : "closed"
|
|
184
|
+
} else {
|
|
185
|
+
const { data: prData } = await octokit.rest.pulls.get({ owner, repo: name, pull_number: prNumber })
|
|
186
|
+
prState = prData.merged ? "merged" : prData.state === "open" ? "open" : "closed"
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (prState !== "open") {
|
|
190
|
+
core.info(`Subway: PR #${prNumber} is ${prState} — skipping notification`)
|
|
191
|
+
} else {
|
|
192
|
+
await notifySubwayAgent(contact, decision, {
|
|
193
|
+
prNumber,
|
|
194
|
+
prUrl: `https://github.com/${owner}/${name}/pull/${prNumber}`,
|
|
195
|
+
repo: `${owner}/${name}`,
|
|
196
|
+
headSha: github.context.payload.pull_request?.head?.sha ?? process.env.GITHUB_SHA ?? "unknown",
|
|
197
|
+
prState,
|
|
198
|
+
runUrl: `${process.env.GITHUB_SERVER_URL ?? "https://github.com"}/${process.env.GITHUB_REPOSITORY ?? `${owner}/${name}`}/actions/runs/${process.env.GITHUB_RUN_ID ?? ""}`,
|
|
199
|
+
}, bridgeUrl)
|
|
200
|
+
}
|
|
201
|
+
} catch (err) {
|
|
202
|
+
core.info(`Subway notification failed non-fatally: ${(err as Error).message}`)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function handleIssueFix(
|
|
208
|
+
octokit: ReturnType<typeof github.getOctokit>,
|
|
209
|
+
ctx: import("./types").ReviewContext,
|
|
210
|
+
anthropic: ModelClient | null,
|
|
211
|
+
openai: ModelClient | null,
|
|
212
|
+
debug: boolean
|
|
213
|
+
): Promise<void> {
|
|
214
|
+
if (!ctx.issue) {
|
|
215
|
+
core.warning("No issue context available")
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
ctx = await buildIssueContext(ctx, octokit)
|
|
220
|
+
|
|
221
|
+
if (debug) {
|
|
222
|
+
core.info(`Fixing issue #${ctx.issue!.number}: ${ctx.issue!.title}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const { mode, confidenceThreshold } = ctx.repoPolicies.fix
|
|
226
|
+
|
|
227
|
+
const trust = evaluateTrust({
|
|
228
|
+
actor: ctx.event.actor,
|
|
229
|
+
isFork: ctx.event.isFork,
|
|
230
|
+
policies: ctx.repoPolicies,
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
const effectiveMode = trust.canMutate ? mode : "propose_only" as const
|
|
234
|
+
if (effectiveMode !== mode) {
|
|
235
|
+
core.info(`Mutation blocked: ${trust.reason}. Mode downgraded to propose_only.`)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const result = await fixIssue(ctx, anthropic, openai, octokit, effectiveMode, confidenceThreshold)
|
|
239
|
+
|
|
240
|
+
await reportFixResult(octokit, ctx.issue!.number, result, mode)
|
|
241
|
+
|
|
242
|
+
if (result.success) {
|
|
243
|
+
core.info(`Fix ${mode === "propose_only" ? "proposed" : "applied"} for issue #${ctx.issue!.number}`)
|
|
244
|
+
} else {
|
|
245
|
+
core.warning(`Fix failed for issue #${ctx.issue!.number}: ${result.error}`)
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function handleIssueTriage(
|
|
250
|
+
octokit: ReturnType<typeof github.getOctokit>,
|
|
251
|
+
ctx: import("./types").ReviewContext
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
if (!ctx.issue) {
|
|
254
|
+
core.warning("No issue context available")
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
ctx = await buildIssueContext(ctx, octokit)
|
|
259
|
+
|
|
260
|
+
core.info(`Issue #${ctx.issue!.number} triage: routing to fix flow`)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
run()
|