@formthefog/stratus 2026.2.20 → 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/publish.yml +28 -0
- package/.github/workflows/sentinel.yml +55 -0
- package/README.md +90 -41
- package/SECURITY.md +85 -0
- 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,156 @@
|
|
|
1
|
+
import * as core from "@actions/core"
|
|
2
|
+
import * as github from "@actions/github"
|
|
3
|
+
import type { ModelClient } from "./models/types"
|
|
4
|
+
import type { ReviewContext, ResponseContext } from "./types"
|
|
5
|
+
|
|
6
|
+
type Octokit = ReturnType<typeof github.getOctokit>
|
|
7
|
+
|
|
8
|
+
const RESPOND_SYSTEM = `You are Sentinel, an AI code review bot responding to a developer's question or comment about a code review finding.
|
|
9
|
+
|
|
10
|
+
Guidelines:
|
|
11
|
+
1. If they point out your finding was wrong, acknowledge it clearly
|
|
12
|
+
2. If they ask for clarification, provide specific code-level detail
|
|
13
|
+
3. If they ask you to fix something, describe the fix precisely
|
|
14
|
+
4. If they disagree, engage constructively with their reasoning
|
|
15
|
+
5. Be concise and direct — developers don't want fluff
|
|
16
|
+
6. Reference specific files and line numbers when relevant
|
|
17
|
+
|
|
18
|
+
Do NOT output JSON. Respond in plain markdown as a conversation reply.`
|
|
19
|
+
|
|
20
|
+
export async function handleResponse(
|
|
21
|
+
ctx: ReviewContext,
|
|
22
|
+
responseContext: ResponseContext,
|
|
23
|
+
model: ModelClient,
|
|
24
|
+
octokit: Octokit
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const { owner, repo } = github.context.repo
|
|
27
|
+
const parentBody = await resolveParentComment(octokit, responseContext)
|
|
28
|
+
|
|
29
|
+
const contextParts: string[] = []
|
|
30
|
+
|
|
31
|
+
if (parentBody) {
|
|
32
|
+
contextParts.push(`## Original Sentinel Comment\n${parentBody}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (ctx.pullRequest) {
|
|
36
|
+
contextParts.push(`## PR Context\nPR #${ctx.pullRequest.number}: ${ctx.pullRequest.title}\n${ctx.pullRequest.body || ""}`)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (ctx.issue) {
|
|
40
|
+
contextParts.push(`## Issue Context\nIssue #${ctx.issue.number}: ${ctx.issue.title}\n${ctx.issue.body || ""}`)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const prompt = [
|
|
44
|
+
...contextParts,
|
|
45
|
+
"",
|
|
46
|
+
"## Developer's Comment",
|
|
47
|
+
responseContext.replyBody,
|
|
48
|
+
].join("\n\n")
|
|
49
|
+
|
|
50
|
+
core.info("Generating response to developer comment")
|
|
51
|
+
|
|
52
|
+
const result = await model.chat(RESPOND_SYSTEM, prompt)
|
|
53
|
+
const responseBody = formatResponse(result.text)
|
|
54
|
+
|
|
55
|
+
if (responseContext.isPRReviewComment) {
|
|
56
|
+
await replyToReviewComment(octokit, responseContext.commentId, responseBody)
|
|
57
|
+
} else {
|
|
58
|
+
const issueNumber = ctx.pullRequest?.number || ctx.issue?.number
|
|
59
|
+
if (issueNumber) {
|
|
60
|
+
await octokit.rest.issues.createComment({
|
|
61
|
+
owner,
|
|
62
|
+
repo,
|
|
63
|
+
issue_number: issueNumber,
|
|
64
|
+
body: responseBody,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
core.info("Response posted")
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resolveParentComment(
|
|
73
|
+
octokit: Octokit,
|
|
74
|
+
responseContext: ResponseContext
|
|
75
|
+
): Promise<string> {
|
|
76
|
+
if (responseContext.parentCommentBody) {
|
|
77
|
+
return responseContext.parentCommentBody
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { owner, repo } = github.context.repo
|
|
81
|
+
|
|
82
|
+
if (responseContext.isPRReviewComment) {
|
|
83
|
+
const inReplyToId = github.context.payload.comment?.in_reply_to_id
|
|
84
|
+
if (inReplyToId) {
|
|
85
|
+
try {
|
|
86
|
+
const { data: parent } = await octokit.rest.pulls.getReviewComment({
|
|
87
|
+
owner,
|
|
88
|
+
repo,
|
|
89
|
+
comment_id: inReplyToId,
|
|
90
|
+
})
|
|
91
|
+
return parent.body || ""
|
|
92
|
+
} catch {
|
|
93
|
+
core.debug(`Could not fetch parent review comment ${inReplyToId}`)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
const issueNumber = github.context.payload.issue?.number
|
|
98
|
+
if (issueNumber) {
|
|
99
|
+
try {
|
|
100
|
+
const { data: comments } = await octokit.rest.issues.listComments({
|
|
101
|
+
owner,
|
|
102
|
+
repo,
|
|
103
|
+
issue_number: issueNumber,
|
|
104
|
+
per_page: 10,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
108
|
+
if (comments[i].body?.includes("Sentinel") && comments[i].id !== responseContext.commentId) {
|
|
109
|
+
return comments[i].body || ""
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
core.debug("Could not fetch previous comments")
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return ""
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function replyToReviewComment(
|
|
122
|
+
octokit: Octokit,
|
|
123
|
+
commentId: number,
|
|
124
|
+
body: string
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
const { owner, repo } = github.context.repo
|
|
127
|
+
const prNumber = github.context.payload.pull_request?.number
|
|
128
|
+
|
|
129
|
+
if (!prNumber) {
|
|
130
|
+
core.warning("No PR number for review comment reply")
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
await octokit.rest.pulls.createReplyForReviewComment({
|
|
136
|
+
owner,
|
|
137
|
+
repo,
|
|
138
|
+
pull_number: prNumber,
|
|
139
|
+
comment_id: commentId,
|
|
140
|
+
body,
|
|
141
|
+
})
|
|
142
|
+
} catch (err) {
|
|
143
|
+
core.warning(`Could not reply to review comment, posting as issue comment: ${err}`)
|
|
144
|
+
await octokit.rest.issues.createComment({
|
|
145
|
+
owner,
|
|
146
|
+
repo,
|
|
147
|
+
issue_number: prNumber,
|
|
148
|
+
body,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function formatResponse(text: string): string {
|
|
154
|
+
const cleaned = text.trim()
|
|
155
|
+
return `${cleaned}\n\n---\n*Sentinel*`
|
|
156
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import * as core from "@actions/core"
|
|
2
|
+
import * as github from "@actions/github"
|
|
3
|
+
import type { ActionType, EventType, SlashCommand, RoutedEvent, ReviewContext, RepoPolicies, ResponseContext } from "./types"
|
|
4
|
+
|
|
5
|
+
const SLASH_COMMANDS = ["review", "fix", "triage", "plan", "explain", "retry", "ignore", "security-review", "tests"]
|
|
6
|
+
const BOT_MARKER = "Sentinel"
|
|
7
|
+
const TRUSTED_ASSOCIATIONS = ["MEMBER", "OWNER", "COLLABORATOR"]
|
|
8
|
+
|
|
9
|
+
export function routeEvent(policies: RepoPolicies): RoutedEvent {
|
|
10
|
+
const { context } = github
|
|
11
|
+
const eventName = context.eventName
|
|
12
|
+
const action = context.payload.action || ""
|
|
13
|
+
const actor = context.actor
|
|
14
|
+
|
|
15
|
+
const pr = context.payload.pull_request
|
|
16
|
+
const isFork = pr ? pr.head?.repo?.full_name !== context.payload.repository?.full_name : false
|
|
17
|
+
const authorAssociation = (context.payload.comment?.author_association as string) || undefined
|
|
18
|
+
|
|
19
|
+
core.info(`Routing event: ${eventName} / ${action} from ${actor} (assoc=${authorAssociation || "N/A"}, fork=${isFork})`)
|
|
20
|
+
|
|
21
|
+
const baseContext: ReviewContext = {
|
|
22
|
+
repository: {
|
|
23
|
+
owner: context.repo.owner,
|
|
24
|
+
name: context.repo.repo,
|
|
25
|
+
defaultBranch: context.payload.repository?.default_branch || "main",
|
|
26
|
+
},
|
|
27
|
+
event: {
|
|
28
|
+
type: mapEventType(eventName),
|
|
29
|
+
action,
|
|
30
|
+
actor,
|
|
31
|
+
trustedActor: TRUSTED_ASSOCIATIONS.includes(authorAssociation || ""),
|
|
32
|
+
isFork,
|
|
33
|
+
authorAssociation,
|
|
34
|
+
},
|
|
35
|
+
repoPolicies: policies,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (eventName === "pull_request") {
|
|
39
|
+
return routePullRequest(baseContext, action, policies)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (eventName === "issues") {
|
|
43
|
+
return routeIssue(baseContext, action, policies)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (eventName === "issue_comment") {
|
|
47
|
+
return routeComment(baseContext, policies)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (eventName === "pull_request_review_comment") {
|
|
51
|
+
return routeReviewComment(baseContext, policies)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (eventName === "workflow_dispatch") {
|
|
55
|
+
return { actionType: "pr_review", context: baseContext }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
core.info(`Unhandled event: ${eventName}`)
|
|
59
|
+
return { actionType: "noop", context: baseContext }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Pull Request Events ──
|
|
63
|
+
|
|
64
|
+
function routePullRequest(ctx: ReviewContext, action: string, policies: RepoPolicies): RoutedEvent {
|
|
65
|
+
const pr = github.context.payload.pull_request
|
|
66
|
+
if (!pr) return { actionType: "noop", context: ctx }
|
|
67
|
+
|
|
68
|
+
const labels: string[] = (pr.labels || []).map((l: { name: string }) => l.name)
|
|
69
|
+
|
|
70
|
+
const labelGateEnabled = policies.trigger.requireLabel !== ""
|
|
71
|
+
|
|
72
|
+
if (action === "labeled") {
|
|
73
|
+
if (labelGateEnabled) {
|
|
74
|
+
const addedLabel = github.context.payload.label?.name || ""
|
|
75
|
+
if (addedLabel.toLowerCase() !== policies.trigger.requireLabel.toLowerCase()) {
|
|
76
|
+
return { actionType: "noop", context: ctx }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
} else if (["opened", "synchronize", "reopened"].includes(action)) {
|
|
80
|
+
if (labelGateEnabled && !hasLabel(labels, policies.trigger.requireLabel)) {
|
|
81
|
+
core.info(`PR #${pr.number} missing "${policies.trigger.requireLabel}" label — skipping`)
|
|
82
|
+
return { actionType: "noop", context: ctx }
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
return { actionType: "noop", context: ctx }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ctx.pullRequest = {
|
|
89
|
+
number: pr.number,
|
|
90
|
+
title: pr.title || "",
|
|
91
|
+
body: pr.body || "",
|
|
92
|
+
baseRef: pr.base?.ref || "main",
|
|
93
|
+
headRef: pr.head?.ref || "",
|
|
94
|
+
labels,
|
|
95
|
+
changedFiles: [],
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return { actionType: "pr_review", context: ctx }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Issue Events ──
|
|
102
|
+
|
|
103
|
+
function routeIssue(ctx: ReviewContext, action: string, policies: RepoPolicies): RoutedEvent {
|
|
104
|
+
const issue = github.context.payload.issue
|
|
105
|
+
if (!issue) return { actionType: "noop", context: ctx }
|
|
106
|
+
|
|
107
|
+
const labels: string[] = (issue.labels || []).map((l: { name: string }) => l.name)
|
|
108
|
+
|
|
109
|
+
const labelGateEnabled = policies.trigger.requireLabel !== ""
|
|
110
|
+
|
|
111
|
+
if (action === "labeled") {
|
|
112
|
+
if (labelGateEnabled) {
|
|
113
|
+
const addedLabel = github.context.payload.label?.name || ""
|
|
114
|
+
if (addedLabel.toLowerCase() !== policies.trigger.requireLabel.toLowerCase()) {
|
|
115
|
+
return { actionType: "noop", context: ctx }
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
} else if (action === "opened") {
|
|
119
|
+
if (labelGateEnabled && !hasLabel(labels, policies.trigger.requireLabel)) {
|
|
120
|
+
core.info(`Issue #${issue.number} missing "${policies.trigger.requireLabel}" label — skipping`)
|
|
121
|
+
return { actionType: "noop", context: ctx }
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
return { actionType: "noop", context: ctx }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
ctx.issue = {
|
|
128
|
+
number: issue.number,
|
|
129
|
+
title: issue.title || "",
|
|
130
|
+
body: issue.body || "",
|
|
131
|
+
labels,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return { actionType: "issue_fix", context: ctx }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── Issue / PR Comment Events ──
|
|
138
|
+
|
|
139
|
+
function routeComment(ctx: ReviewContext, policies: RepoPolicies): RoutedEvent {
|
|
140
|
+
const comment = github.context.payload.comment
|
|
141
|
+
const issue = github.context.payload.issue
|
|
142
|
+
if (!comment || !issue) return { actionType: "noop", context: ctx }
|
|
143
|
+
|
|
144
|
+
if (!ctx.event.trustedActor) {
|
|
145
|
+
core.info(`Ignoring comment from untrusted actor ${ctx.event.actor} (association: ${ctx.event.authorAssociation || "NONE"})`)
|
|
146
|
+
return { actionType: "noop", context: ctx }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const body = (comment.body || "").trim()
|
|
150
|
+
const isPR = !!issue.pull_request
|
|
151
|
+
const labels: string[] = (issue.labels || []).map((l: { name: string }) => l.name)
|
|
152
|
+
const hasAgentLabel = hasLabel(labels, policies.trigger.requireLabel)
|
|
153
|
+
const botName = policies.trigger.botName
|
|
154
|
+
|
|
155
|
+
const slashCommand = parseSlashCommand(body, ctx.event.actor, issue.number, isPR)
|
|
156
|
+
if (slashCommand) {
|
|
157
|
+
const actionType = resolveCommandAction(slashCommand, isPR)
|
|
158
|
+
attachIssueOrPR(ctx, issue, isPR)
|
|
159
|
+
return { actionType, context: ctx, slashCommand }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const mentioned = isMentioned(body, botName)
|
|
163
|
+
if (mentioned) {
|
|
164
|
+
attachIssueOrPR(ctx, issue, isPR)
|
|
165
|
+
const actionType = isPR ? "pr_review" : "issue_fix"
|
|
166
|
+
core.info(`@${botName} mentioned in comment on ${isPR ? "PR" : "issue"} #${issue.number}`)
|
|
167
|
+
return { actionType, context: ctx }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (policies.trigger.respondToReplies && hasAgentLabel) {
|
|
171
|
+
const isReplyToBot = isBotComment(comment.body, body)
|
|
172
|
+
if (isReplyToBot) {
|
|
173
|
+
attachIssueOrPR(ctx, issue, isPR)
|
|
174
|
+
const responseContext: ResponseContext = {
|
|
175
|
+
parentCommentBody: "",
|
|
176
|
+
replyBody: body,
|
|
177
|
+
commentId: comment.id,
|
|
178
|
+
isPRReviewComment: false,
|
|
179
|
+
}
|
|
180
|
+
return { actionType: "respond", context: ctx, responseContext }
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return { actionType: "noop", context: ctx }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── PR Review Comment Events (reply detection) ──
|
|
188
|
+
|
|
189
|
+
function routeReviewComment(ctx: ReviewContext, policies: RepoPolicies): RoutedEvent {
|
|
190
|
+
const comment = github.context.payload.comment
|
|
191
|
+
const pr = github.context.payload.pull_request
|
|
192
|
+
if (!comment || !pr) return { actionType: "noop", context: ctx }
|
|
193
|
+
|
|
194
|
+
if (!ctx.event.trustedActor) {
|
|
195
|
+
core.info(`Ignoring review comment from untrusted actor ${ctx.event.actor} (association: ${ctx.event.authorAssociation || "NONE"})`)
|
|
196
|
+
return { actionType: "noop", context: ctx }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const body = (comment.body || "").trim()
|
|
200
|
+
const botName = policies.trigger.botName
|
|
201
|
+
const inReplyToId = comment.in_reply_to_id
|
|
202
|
+
|
|
203
|
+
ctx.pullRequest = {
|
|
204
|
+
number: pr.number,
|
|
205
|
+
title: pr.title || "",
|
|
206
|
+
body: pr.body || "",
|
|
207
|
+
baseRef: pr.base?.ref || "main",
|
|
208
|
+
headRef: pr.head?.ref || "",
|
|
209
|
+
labels: (pr.labels || []).map((l: { name: string }) => l.name),
|
|
210
|
+
changedFiles: [],
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (isMentioned(body, botName)) {
|
|
214
|
+
core.info(`@${botName} mentioned in review comment on PR #${pr.number}`)
|
|
215
|
+
return { actionType: "pr_review", context: ctx }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (policies.trigger.respondToReplies && inReplyToId) {
|
|
219
|
+
const responseContext: ResponseContext = {
|
|
220
|
+
parentCommentBody: "",
|
|
221
|
+
replyBody: body,
|
|
222
|
+
commentId: comment.id,
|
|
223
|
+
isPRReviewComment: true,
|
|
224
|
+
}
|
|
225
|
+
return { actionType: "respond", context: ctx, responseContext }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return { actionType: "noop", context: ctx }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ── Helpers ──
|
|
232
|
+
|
|
233
|
+
function hasLabel(labels: string[], target: string): boolean {
|
|
234
|
+
return labels.some((l) => l.toLowerCase() === target.toLowerCase())
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isMentioned(body: string, botName: string): boolean {
|
|
238
|
+
return body.toLowerCase().includes(`@${botName.toLowerCase()}`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function isBotComment(_commentBody: string, _body: string): boolean {
|
|
242
|
+
return false
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function attachIssueOrPR(
|
|
246
|
+
ctx: ReviewContext,
|
|
247
|
+
issue: { number: number; title?: string; body?: string; labels?: Array<{ name: string }>; pull_request?: unknown },
|
|
248
|
+
isPR: boolean
|
|
249
|
+
): void {
|
|
250
|
+
if (isPR) {
|
|
251
|
+
ctx.pullRequest = {
|
|
252
|
+
number: issue.number,
|
|
253
|
+
title: issue.title || "",
|
|
254
|
+
body: issue.body || "",
|
|
255
|
+
baseRef: "",
|
|
256
|
+
headRef: "",
|
|
257
|
+
labels: (issue.labels || []).map((l: { name: string }) => l.name),
|
|
258
|
+
changedFiles: [],
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
ctx.issue = {
|
|
262
|
+
number: issue.number,
|
|
263
|
+
title: issue.title || "",
|
|
264
|
+
body: issue.body || "",
|
|
265
|
+
labels: (issue.labels || []).map((l: { name: string }) => l.name),
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function parseSlashCommand(body: string, actor: string, issueNumber: number, isPR: boolean): SlashCommand | undefined {
|
|
271
|
+
const match = body.match(/^\/bot\s+(\S+)(.*)$/m)
|
|
272
|
+
if (!match) return undefined
|
|
273
|
+
|
|
274
|
+
const command = match[1].toLowerCase()
|
|
275
|
+
if (!SLASH_COMMANDS.includes(command)) return undefined
|
|
276
|
+
|
|
277
|
+
const args = match[2].trim().split(/\s+/).filter(Boolean)
|
|
278
|
+
return { command, args, actor, issueNumber, isPR }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveCommandAction(cmd: SlashCommand, isPR: boolean): ActionType {
|
|
282
|
+
switch (cmd.command) {
|
|
283
|
+
case "review":
|
|
284
|
+
return isPR ? "pr_review" : "noop"
|
|
285
|
+
case "fix":
|
|
286
|
+
return isPR ? "pr_fix" : "issue_fix"
|
|
287
|
+
case "triage":
|
|
288
|
+
return "issue_triage"
|
|
289
|
+
case "plan":
|
|
290
|
+
case "explain":
|
|
291
|
+
return isPR ? "pr_review" : "issue_triage"
|
|
292
|
+
case "security-review":
|
|
293
|
+
case "tests":
|
|
294
|
+
return isPR ? "pr_review" : "noop"
|
|
295
|
+
case "ignore":
|
|
296
|
+
case "retry":
|
|
297
|
+
default:
|
|
298
|
+
return "noop"
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function mapEventType(eventName: string): EventType {
|
|
303
|
+
if (eventName === "pull_request") return "pull_request"
|
|
304
|
+
if (eventName === "issues") return "issue"
|
|
305
|
+
if (eventName === "issue_comment") return "issue_comment"
|
|
306
|
+
if (eventName === "pull_request_review_comment") return "pull_request_review_comment"
|
|
307
|
+
return "issue_comment"
|
|
308
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export const SentinelConfigSchema = z.object({
|
|
4
|
+
mode: z
|
|
5
|
+
.enum([
|
|
6
|
+
"review",
|
|
7
|
+
"review_and_suggest",
|
|
8
|
+
"review_and_patch",
|
|
9
|
+
"issue_triage",
|
|
10
|
+
"issue_fix",
|
|
11
|
+
"manual_only",
|
|
12
|
+
])
|
|
13
|
+
.default("review"),
|
|
14
|
+
|
|
15
|
+
models: z
|
|
16
|
+
.object({
|
|
17
|
+
anthropic: z
|
|
18
|
+
.object({
|
|
19
|
+
enabled: z.boolean().default(true),
|
|
20
|
+
model: z.string().default("claude-sonnet-4-20250514"),
|
|
21
|
+
})
|
|
22
|
+
.default({}),
|
|
23
|
+
openai: z
|
|
24
|
+
.object({
|
|
25
|
+
enabled: z.boolean().default(true),
|
|
26
|
+
model: z.string().default("gpt-4o"),
|
|
27
|
+
})
|
|
28
|
+
.default({}),
|
|
29
|
+
})
|
|
30
|
+
.default({}),
|
|
31
|
+
|
|
32
|
+
trigger: z
|
|
33
|
+
.object({
|
|
34
|
+
require_label: z.string().default("agent"), // set to "" to run on every PR/issue
|
|
35
|
+
respond_to_mentions: z.boolean().default(true),
|
|
36
|
+
respond_to_replies: z.boolean().default(true),
|
|
37
|
+
bot_name: z.string().default("sentinel"),
|
|
38
|
+
})
|
|
39
|
+
.default({}),
|
|
40
|
+
|
|
41
|
+
review: z
|
|
42
|
+
.object({
|
|
43
|
+
max_files: z.number().default(50),
|
|
44
|
+
max_patch_chars: z.number().default(200_000),
|
|
45
|
+
comment_style: z.enum(["concise", "comprehensive"]).default("comprehensive"),
|
|
46
|
+
inline_comments: z.boolean().default(true),
|
|
47
|
+
severity_threshold: z
|
|
48
|
+
.enum(["low", "medium", "high", "critical"])
|
|
49
|
+
.default("medium"),
|
|
50
|
+
summary_on_clean: z.boolean().default(false),
|
|
51
|
+
})
|
|
52
|
+
.default({}),
|
|
53
|
+
|
|
54
|
+
fix: z
|
|
55
|
+
.object({
|
|
56
|
+
mode: z.enum(["propose_only", "propose_and_pr", "yolo"]).default("propose_and_pr"),
|
|
57
|
+
confidence_threshold: z.number().min(0).max(1).default(0.7),
|
|
58
|
+
max_retry_count: z.number().default(2),
|
|
59
|
+
create_draft_pr: z.boolean().default(true),
|
|
60
|
+
})
|
|
61
|
+
.default({}),
|
|
62
|
+
|
|
63
|
+
security: z
|
|
64
|
+
.object({
|
|
65
|
+
restricted_paths: z
|
|
66
|
+
.array(z.string())
|
|
67
|
+
.default([
|
|
68
|
+
".github/workflows/**",
|
|
69
|
+
"infra/**",
|
|
70
|
+
"auth/**",
|
|
71
|
+
"payments/**",
|
|
72
|
+
]),
|
|
73
|
+
block_fork_mutation: z.boolean().default(true),
|
|
74
|
+
})
|
|
75
|
+
.default({}),
|
|
76
|
+
|
|
77
|
+
validation: z
|
|
78
|
+
.object({
|
|
79
|
+
commands: z.array(z.string()).default([]),
|
|
80
|
+
})
|
|
81
|
+
.default({}),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
export type SentinelConfig = z.infer<typeof SentinelConfigSchema>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export const FileChangeSchema = z.object({
|
|
4
|
+
path: z.string(),
|
|
5
|
+
action: z.enum(["modify", "create", "delete"]),
|
|
6
|
+
changes: z
|
|
7
|
+
.array(z.object({ search: z.string(), replace: z.string() }))
|
|
8
|
+
.optional(),
|
|
9
|
+
content: z.string().optional(),
|
|
10
|
+
explanation: z.string(),
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export const FixPlanSchema = z.object({
|
|
14
|
+
analysis: z.string(),
|
|
15
|
+
fixable: z.boolean(),
|
|
16
|
+
confidence: z.number().min(0).max(1),
|
|
17
|
+
files: z.array(FileChangeSchema),
|
|
18
|
+
commit_message: z.string(),
|
|
19
|
+
test_suggestions: z.array(z.string()),
|
|
20
|
+
risk_notes: z.array(z.string()),
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
export const FixReviewSchema = z.object({
|
|
24
|
+
approved: z.boolean(),
|
|
25
|
+
confidence: z.number().min(0).max(1),
|
|
26
|
+
concerns: z.array(z.string()),
|
|
27
|
+
verdict: z.string(),
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
export function parseFixPlan(raw: string): z.infer<typeof FixPlanSchema> {
|
|
31
|
+
const json = extractJson(raw)
|
|
32
|
+
return FixPlanSchema.parse(json)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseFixReview(raw: string): z.infer<typeof FixReviewSchema> {
|
|
36
|
+
const json = extractJson(raw)
|
|
37
|
+
return FixReviewSchema.parse(json)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractJson(text: string): unknown {
|
|
41
|
+
const match = text.match(/\{[\s\S]*\}/)
|
|
42
|
+
if (!match) throw new Error("No JSON object found in model response")
|
|
43
|
+
return JSON.parse(match[0])
|
|
44
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export const FindingSeveritySchema = z.enum(["low", "medium", "high", "critical"])
|
|
4
|
+
|
|
5
|
+
export const FindingTypeSchema = z.enum([
|
|
6
|
+
"bug",
|
|
7
|
+
"security",
|
|
8
|
+
"performance",
|
|
9
|
+
"maintainability",
|
|
10
|
+
"test_gap",
|
|
11
|
+
"architecture",
|
|
12
|
+
])
|
|
13
|
+
|
|
14
|
+
export const ReviewFindingSchema = z.object({
|
|
15
|
+
type: FindingTypeSchema,
|
|
16
|
+
severity: FindingSeveritySchema,
|
|
17
|
+
title: z.string(),
|
|
18
|
+
file: z.string(),
|
|
19
|
+
line_start: z.number().optional(),
|
|
20
|
+
line_end: z.number().optional(),
|
|
21
|
+
explanation: z.string(),
|
|
22
|
+
suggested_fix: z.string().optional(),
|
|
23
|
+
confidence: z.number().min(0).max(1),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const ModelReviewSchema = z.object({
|
|
27
|
+
summary: z.string(),
|
|
28
|
+
severity: FindingSeveritySchema,
|
|
29
|
+
confidence: z.number().min(0).max(1),
|
|
30
|
+
findings: z.array(ReviewFindingSchema),
|
|
31
|
+
merge_blocking: z.boolean(),
|
|
32
|
+
needs_human_attention: z.boolean(),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export const CritiqueOutputSchema = z.object({
|
|
36
|
+
agreed_findings: z.array(z.string()),
|
|
37
|
+
disputed_findings: z.array(
|
|
38
|
+
z.object({ finding: z.string(), reason: z.string() })
|
|
39
|
+
),
|
|
40
|
+
missed_issues: z.array(ReviewFindingSchema),
|
|
41
|
+
overall_assessment: z.string(),
|
|
42
|
+
revised_severity: FindingSeveritySchema,
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
export const CritiqueResponseSchema = z.object({
|
|
46
|
+
accepted: z.array(z.string()),
|
|
47
|
+
disputed: z.array(
|
|
48
|
+
z.object({ critique: z.string(), rebuttal: z.string() })
|
|
49
|
+
),
|
|
50
|
+
revised_findings: z.array(ReviewFindingSchema),
|
|
51
|
+
final_summary: z.string(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export function parseModelReview(raw: string): z.infer<typeof ModelReviewSchema> {
|
|
55
|
+
const json = extractJson(raw)
|
|
56
|
+
return ModelReviewSchema.parse(json)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseCritiqueOutput(raw: string): z.infer<typeof CritiqueOutputSchema> {
|
|
60
|
+
const json = extractJson(raw)
|
|
61
|
+
return CritiqueOutputSchema.parse(json)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseCritiqueResponse(raw: string): z.infer<typeof CritiqueResponseSchema> {
|
|
65
|
+
const json = extractJson(raw)
|
|
66
|
+
return CritiqueResponseSchema.parse(json)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function extractJson(text: string): unknown {
|
|
70
|
+
const match = text.match(/\{[\s\S]*\}/)
|
|
71
|
+
if (!match) throw new Error("No JSON object found in model response")
|
|
72
|
+
return JSON.parse(match[0])
|
|
73
|
+
}
|