@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,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sentinel",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Dual-model AI review bot for GitHub pull requests and issues",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "ncc build src/index.ts -o dist --source-map --license licenses.txt",
|
|
8
|
+
"typecheck": "tsc --noEmit",
|
|
9
|
+
"test": "vitest run",
|
|
10
|
+
"test:watch": "vitest",
|
|
11
|
+
"lint": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["github-action", "code-review", "ai", "anthropic", "openai"],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@actions/core": "^1.11.1",
|
|
17
|
+
"@actions/github": "^6.0.0",
|
|
18
|
+
"@anthropic-ai/sdk": "^0.39.0",
|
|
19
|
+
"openai": "^4.85.0",
|
|
20
|
+
"yaml": "^2.7.0",
|
|
21
|
+
"zod": "^3.24.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"@vercel/ncc": "^0.38.3",
|
|
26
|
+
"typescript": "^5.7.0",
|
|
27
|
+
"vitest": "^3.0.0"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import * as fs from "fs"
|
|
2
|
+
import { execSync } from "child_process"
|
|
3
|
+
import * as core from "@actions/core"
|
|
4
|
+
import type { CodeContext } from "./types"
|
|
5
|
+
|
|
6
|
+
const CODE_EXTENSIONS = [
|
|
7
|
+
"ts", "tsx", "js", "jsx", "py", "go", "rs", "rb",
|
|
8
|
+
"java", "kt", "swift", "c", "cpp", "h", "cs",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_SIZE = 50_000
|
|
12
|
+
const MAX_CONTEXT_FILES = 12
|
|
13
|
+
|
|
14
|
+
export async function analyzeCodebase(
|
|
15
|
+
keywords: string[],
|
|
16
|
+
maxFiles: number = MAX_CONTEXT_FILES
|
|
17
|
+
): Promise<CodeContext> {
|
|
18
|
+
const allFiles = getFileTree()
|
|
19
|
+
const searchHits = searchForKeywords(keywords, allFiles)
|
|
20
|
+
const rankedFiles = rankFiles(searchHits, allFiles, keywords)
|
|
21
|
+
const relevantFiles = readTopFiles(rankedFiles, maxFiles)
|
|
22
|
+
const structure = getProjectStructure(allFiles)
|
|
23
|
+
const dependencies = getDependencies()
|
|
24
|
+
|
|
25
|
+
core.info(`Codebase analysis: ${allFiles.length} files, ${keywords.length} keywords, ${relevantFiles.length} relevant files`)
|
|
26
|
+
|
|
27
|
+
return { files: relevantFiles, structure, dependencies }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function extractKeywords(title: string, body: string): string[] {
|
|
31
|
+
const text = `${title} ${body}`
|
|
32
|
+
const codeRefs = text.match(/`([^`]+)`/g)?.map((m) => m.replace(/`/g, "")) || []
|
|
33
|
+
const fileRefs = text.match(/[\w/.-]+\.\w{1,5}/g) || []
|
|
34
|
+
const fnRefs = text.match(/\b[a-z][a-zA-Z0-9_]+(?=\()/g) || []
|
|
35
|
+
const classRefs = text.match(/\b[A-Z][a-zA-Z0-9]+\b/g)?.filter((w) => w.length > 3) || []
|
|
36
|
+
|
|
37
|
+
const stopwords = new Set([
|
|
38
|
+
"the", "and", "for", "that", "this", "with", "from", "have", "been",
|
|
39
|
+
"should", "would", "could", "when", "where", "what", "which", "there",
|
|
40
|
+
"about", "into", "more", "some", "than", "them", "then", "these",
|
|
41
|
+
"Error", "Warning", "Issue", "Problem", "Bug", "Feature", "Request",
|
|
42
|
+
"TODO", "FIXME", "NOTE", "String", "Number", "Boolean", "Object", "Array",
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
const all = [...codeRefs, ...fileRefs, ...fnRefs, ...classRefs]
|
|
46
|
+
const unique = [...new Set(all)].filter((k) => k.length > 2 && !stopwords.has(k))
|
|
47
|
+
|
|
48
|
+
return unique.slice(0, 20)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getFileTree(): string[] {
|
|
52
|
+
try {
|
|
53
|
+
return execSync("git ls-files", { encoding: "utf-8", timeout: 5000 })
|
|
54
|
+
.split("\n")
|
|
55
|
+
.filter((f) => f && !f.startsWith(".git"))
|
|
56
|
+
} catch {
|
|
57
|
+
try {
|
|
58
|
+
return execSync("find . -type f -not -path './.git/*' -not -path './node_modules/*' | head -2000", {
|
|
59
|
+
encoding: "utf-8",
|
|
60
|
+
timeout: 5000,
|
|
61
|
+
})
|
|
62
|
+
.split("\n")
|
|
63
|
+
.filter(Boolean)
|
|
64
|
+
.map((f) => f.replace(/^\.\//, ""))
|
|
65
|
+
} catch {
|
|
66
|
+
return []
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SearchHit {
|
|
72
|
+
file: string
|
|
73
|
+
line: number
|
|
74
|
+
text: string
|
|
75
|
+
keyword: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function searchForKeywords(keywords: string[], _files: string[]): SearchHit[] {
|
|
79
|
+
const hits: SearchHit[] = []
|
|
80
|
+
const extGlob = CODE_EXTENSIONS.map((e) => `--include='*.${e}'`).join(" ")
|
|
81
|
+
|
|
82
|
+
for (const keyword of keywords) {
|
|
83
|
+
const shellSafe = keyword.replace(/'/g, "'\\''")
|
|
84
|
+
try {
|
|
85
|
+
const output = execSync(
|
|
86
|
+
`grep -rn ${extGlob} -F -i '${shellSafe}' . 2>/dev/null | head -30`,
|
|
87
|
+
{ encoding: "utf-8", timeout: 5000 }
|
|
88
|
+
)
|
|
89
|
+
for (const line of output.split("\n").filter(Boolean)) {
|
|
90
|
+
const match = line.match(/^\.\/(.+):(\d+):(.+)$/)
|
|
91
|
+
if (match) {
|
|
92
|
+
hits.push({
|
|
93
|
+
file: match[1],
|
|
94
|
+
line: parseInt(match[2]),
|
|
95
|
+
text: match[3].trim(),
|
|
96
|
+
keyword,
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// no matches
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return hits
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function rankFiles(
|
|
109
|
+
hits: SearchHit[],
|
|
110
|
+
allFiles: string[],
|
|
111
|
+
keywords: string[]
|
|
112
|
+
): Array<{ path: string; score: number; relevance: string }> {
|
|
113
|
+
const scores = new Map<string, { score: number; reasons: string[] }>()
|
|
114
|
+
|
|
115
|
+
for (const hit of hits) {
|
|
116
|
+
const existing = scores.get(hit.file) || { score: 0, reasons: [] }
|
|
117
|
+
existing.score += 1
|
|
118
|
+
if (!existing.reasons.includes(hit.keyword)) {
|
|
119
|
+
existing.reasons.push(hit.keyword)
|
|
120
|
+
}
|
|
121
|
+
scores.set(hit.file, existing)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const keyword of keywords) {
|
|
125
|
+
for (const file of allFiles) {
|
|
126
|
+
const basename = file.split("/").pop() || ""
|
|
127
|
+
if (basename.toLowerCase().includes(keyword.toLowerCase())) {
|
|
128
|
+
const existing = scores.get(file) || { score: 0, reasons: [] }
|
|
129
|
+
existing.score += 3
|
|
130
|
+
existing.reasons.push(`filename match: ${keyword}`)
|
|
131
|
+
scores.set(file, existing)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Array.from(scores.entries())
|
|
137
|
+
.map(([path, { score, reasons }]) => ({
|
|
138
|
+
path,
|
|
139
|
+
score,
|
|
140
|
+
relevance: reasons.slice(0, 3).join(", "),
|
|
141
|
+
}))
|
|
142
|
+
.sort((a, b) => b.score - a.score)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readTopFiles(
|
|
146
|
+
ranked: Array<{ path: string; score: number; relevance: string }>,
|
|
147
|
+
maxFiles: number
|
|
148
|
+
): Array<{ path: string; content: string; relevance: string }> {
|
|
149
|
+
const result: Array<{ path: string; content: string; relevance: string }> = []
|
|
150
|
+
|
|
151
|
+
for (const file of ranked.slice(0, maxFiles)) {
|
|
152
|
+
try {
|
|
153
|
+
const stat = fs.statSync(file.path)
|
|
154
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
155
|
+
result.push({
|
|
156
|
+
path: file.path,
|
|
157
|
+
content: `[File too large: ${stat.size} bytes — showing first ${MAX_FILE_SIZE} chars]\n` +
|
|
158
|
+
fs.readFileSync(file.path, "utf-8").substring(0, MAX_FILE_SIZE),
|
|
159
|
+
relevance: file.relevance,
|
|
160
|
+
})
|
|
161
|
+
} else {
|
|
162
|
+
result.push({
|
|
163
|
+
path: file.path,
|
|
164
|
+
content: fs.readFileSync(file.path, "utf-8"),
|
|
165
|
+
relevance: file.relevance,
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
} catch {
|
|
169
|
+
core.debug(`Could not read ${file.path}`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function getProjectStructure(files: string[]): string {
|
|
177
|
+
const dirs = new Set<string>()
|
|
178
|
+
for (const f of files) {
|
|
179
|
+
const parts = f.split("/")
|
|
180
|
+
for (let i = 1; i <= Math.min(parts.length - 1, 3); i++) {
|
|
181
|
+
dirs.add(parts.slice(0, i).join("/") + "/")
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const sorted = Array.from(dirs).sort()
|
|
186
|
+
const codeFiles = files.filter((f) => CODE_EXTENSIONS.some((ext) => f.endsWith(`.${ext}`)))
|
|
187
|
+
|
|
188
|
+
const lines: string[] = []
|
|
189
|
+
lines.push(`Total files: ${files.length} (${codeFiles.length} code files)`)
|
|
190
|
+
lines.push("")
|
|
191
|
+
lines.push("Directories:")
|
|
192
|
+
for (const dir of sorted.slice(0, 40)) {
|
|
193
|
+
const count = files.filter((f) => f.startsWith(dir)).length
|
|
194
|
+
lines.push(` ${dir} (${count} files)`)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return lines.join("\n")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getDependencies(): string {
|
|
201
|
+
const parts: string[] = []
|
|
202
|
+
|
|
203
|
+
if (fs.existsSync("package.json")) {
|
|
204
|
+
try {
|
|
205
|
+
const pkg = JSON.parse(fs.readFileSync("package.json", "utf-8"))
|
|
206
|
+
const deps = Object.keys(pkg.dependencies || {})
|
|
207
|
+
const devDeps = Object.keys(pkg.devDependencies || {})
|
|
208
|
+
parts.push(`Node.js project: ${pkg.name || "unnamed"}`)
|
|
209
|
+
if (deps.length) parts.push(`Dependencies: ${deps.join(", ")}`)
|
|
210
|
+
if (devDeps.length) parts.push(`Dev dependencies: ${devDeps.join(", ")}`)
|
|
211
|
+
} catch { /* */ }
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (fs.existsSync("requirements.txt")) {
|
|
215
|
+
try {
|
|
216
|
+
const reqs = fs.readFileSync("requirements.txt", "utf-8").split("\n").filter(Boolean).slice(0, 20)
|
|
217
|
+
parts.push(`Python dependencies: ${reqs.join(", ")}`)
|
|
218
|
+
} catch { /* */ }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (fs.existsSync("go.mod")) {
|
|
222
|
+
parts.push("Go module project")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (fs.existsSync("Cargo.toml")) {
|
|
226
|
+
try {
|
|
227
|
+
const cargoContent = fs.readFileSync("Cargo.toml", "utf-8")
|
|
228
|
+
const nameMatch = cargoContent.match(/^name\s*=\s*"([^"]+)"/m)
|
|
229
|
+
const membersMatch = cargoContent.match(/members\s*=\s*\[([^\]]+)\]/s)
|
|
230
|
+
const cargoName = nameMatch ? nameMatch[1] : "unnamed"
|
|
231
|
+
|
|
232
|
+
if (membersMatch) {
|
|
233
|
+
const members = membersMatch[1].match(/"([^"]+)"/g)?.map((m) => m.replace(/"/g, "")) || []
|
|
234
|
+
parts.push(`Rust workspace: ${cargoName} (members: ${members.join(", ")})`)
|
|
235
|
+
|
|
236
|
+
for (const member of members.slice(0, 8)) {
|
|
237
|
+
const memberToml = `${member}/Cargo.toml`
|
|
238
|
+
if (fs.existsSync(memberToml)) {
|
|
239
|
+
try {
|
|
240
|
+
const memberContent = fs.readFileSync(memberToml, "utf-8")
|
|
241
|
+
const depsSection = memberContent.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/)?.[1] || ""
|
|
242
|
+
const depNames = depsSection.match(/^(\w[\w-]*)\s*=/gm)?.map((d) => d.replace(/\s*=.*/, "")) || []
|
|
243
|
+
if (depNames.length) {
|
|
244
|
+
parts.push(` ${member}: ${depNames.join(", ")}`)
|
|
245
|
+
}
|
|
246
|
+
} catch { /* skip unreadable member */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
const depsSection = cargoContent.match(/\[dependencies\]([\s\S]*?)(?=\n\[|$)/)?.[1] || ""
|
|
251
|
+
const depNames = depsSection.match(/^(\w[\w-]*)\s*=/gm)?.map((d) => d.replace(/\s*=.*/, "")) || []
|
|
252
|
+
parts.push(`Rust project: ${cargoName}`)
|
|
253
|
+
if (depNames.length) parts.push(`Dependencies: ${depNames.join(", ")}`)
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
parts.push("Rust/Cargo project")
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (fs.existsSync("tsconfig.json")) {
|
|
261
|
+
parts.push("TypeScript enabled")
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return parts.join("\n") || "No dependency info found"
|
|
265
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import * as core from "@actions/core"
|
|
2
|
+
import * as github from "@actions/github"
|
|
3
|
+
import type { ReviewContext, ChangedFile, RepoPolicies } from "./types"
|
|
4
|
+
|
|
5
|
+
type Octokit = ReturnType<typeof github.getOctokit>
|
|
6
|
+
|
|
7
|
+
export async function buildPRContext(
|
|
8
|
+
ctx: ReviewContext,
|
|
9
|
+
octokit: Octokit
|
|
10
|
+
): Promise<ReviewContext> {
|
|
11
|
+
if (!ctx.pullRequest) return ctx
|
|
12
|
+
|
|
13
|
+
const { owner, name } = ctx.repository
|
|
14
|
+
const prNumber = ctx.pullRequest.number
|
|
15
|
+
|
|
16
|
+
const [files, prDetail, ciStatus] = await Promise.all([
|
|
17
|
+
fetchChangedFiles(octokit, owner, name, prNumber, ctx.repoPolicies),
|
|
18
|
+
fetchPRDetail(octokit, owner, name, prNumber),
|
|
19
|
+
fetchCIStatus(octokit, owner, name, ctx.pullRequest.headRef),
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
ctx.pullRequest.changedFiles = files
|
|
23
|
+
ctx.pullRequest.body = prDetail.body || ctx.pullRequest.body
|
|
24
|
+
ctx.pullRequest.baseRef = prDetail.baseRef || ctx.pullRequest.baseRef
|
|
25
|
+
ctx.pullRequest.headRef = prDetail.headRef || ctx.pullRequest.headRef
|
|
26
|
+
ctx.pullRequest.ciStatus = ciStatus
|
|
27
|
+
ctx.event.trustedActor = !prDetail.isFork
|
|
28
|
+
|
|
29
|
+
const [reviewRules, architectureNotes] = await Promise.all([
|
|
30
|
+
fetchFileContent(octokit, owner, name, ".github/review-rules.md"),
|
|
31
|
+
fetchFileContent(octokit, owner, name, ".github/architecture-notes.md"),
|
|
32
|
+
])
|
|
33
|
+
|
|
34
|
+
if (reviewRules) ctx.repoPolicies.reviewRulesMarkdown = reviewRules
|
|
35
|
+
if (architectureNotes) ctx.repoPolicies.architectureNotes = architectureNotes
|
|
36
|
+
|
|
37
|
+
core.info(`Context built: ${files.length} files, fork=${prDetail.isFork}, ci=${ciStatus || "unknown"}`)
|
|
38
|
+
return ctx
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function buildIssueContext(
|
|
42
|
+
ctx: ReviewContext,
|
|
43
|
+
octokit: Octokit
|
|
44
|
+
): Promise<ReviewContext> {
|
|
45
|
+
if (!ctx.issue) return ctx
|
|
46
|
+
|
|
47
|
+
const { owner, name } = ctx.repository
|
|
48
|
+
const comments = await fetchIssueComments(octokit, owner, name, ctx.issue.number)
|
|
49
|
+
ctx.issue.comments = comments
|
|
50
|
+
|
|
51
|
+
return ctx
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function fetchChangedFiles(
|
|
55
|
+
octokit: Octokit,
|
|
56
|
+
owner: string,
|
|
57
|
+
repo: string,
|
|
58
|
+
prNumber: number,
|
|
59
|
+
policies: RepoPolicies
|
|
60
|
+
): Promise<ChangedFile[]> {
|
|
61
|
+
const files: ChangedFile[] = []
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const response = await octokit.rest.pulls.listFiles({
|
|
65
|
+
owner,
|
|
66
|
+
repo,
|
|
67
|
+
pull_number: prNumber,
|
|
68
|
+
per_page: 100,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
let totalPatchChars = 0
|
|
72
|
+
|
|
73
|
+
for (const file of response.data) {
|
|
74
|
+
if (files.length >= policies.maxFiles) {
|
|
75
|
+
core.warning(`Truncated at ${policies.maxFiles} files (policy limit)`)
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const patchLen = file.patch?.length || 0
|
|
80
|
+
if (totalPatchChars + patchLen > policies.maxPatchChars) {
|
|
81
|
+
core.warning(`Truncated patch content at ${policies.maxPatchChars} chars`)
|
|
82
|
+
files.push({
|
|
83
|
+
path: file.filename,
|
|
84
|
+
status: mapFileStatus(file.status),
|
|
85
|
+
additions: file.additions,
|
|
86
|
+
deletions: file.deletions,
|
|
87
|
+
})
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
totalPatchChars += patchLen
|
|
92
|
+
files.push({
|
|
93
|
+
path: file.filename,
|
|
94
|
+
patch: file.patch,
|
|
95
|
+
status: mapFileStatus(file.status),
|
|
96
|
+
additions: file.additions,
|
|
97
|
+
deletions: file.deletions,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
} catch (err) {
|
|
101
|
+
core.warning(`Failed to fetch changed files: ${err}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return files
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function fetchPRDetail(
|
|
108
|
+
octokit: Octokit,
|
|
109
|
+
owner: string,
|
|
110
|
+
repo: string,
|
|
111
|
+
prNumber: number
|
|
112
|
+
): Promise<{ body: string; baseRef: string; headRef: string; isFork: boolean }> {
|
|
113
|
+
try {
|
|
114
|
+
const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: prNumber })
|
|
115
|
+
return {
|
|
116
|
+
body: data.body || "",
|
|
117
|
+
baseRef: data.base.ref,
|
|
118
|
+
headRef: data.head.ref,
|
|
119
|
+
isFork: data.head.repo?.fork ?? false,
|
|
120
|
+
}
|
|
121
|
+
} catch {
|
|
122
|
+
return { body: "", baseRef: "main", headRef: "", isFork: false }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function fetchCIStatus(
|
|
127
|
+
octokit: Octokit,
|
|
128
|
+
owner: string,
|
|
129
|
+
repo: string,
|
|
130
|
+
ref: string
|
|
131
|
+
): Promise<string | undefined> {
|
|
132
|
+
if (!ref) return undefined
|
|
133
|
+
try {
|
|
134
|
+
const { data } = await octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref })
|
|
135
|
+
return data.state
|
|
136
|
+
} catch {
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function fetchIssueComments(
|
|
142
|
+
octokit: Octokit,
|
|
143
|
+
owner: string,
|
|
144
|
+
repo: string,
|
|
145
|
+
issueNumber: number
|
|
146
|
+
): Promise<string[]> {
|
|
147
|
+
try {
|
|
148
|
+
const { data } = await octokit.rest.issues.listComments({
|
|
149
|
+
owner,
|
|
150
|
+
repo,
|
|
151
|
+
issue_number: issueNumber,
|
|
152
|
+
per_page: 20,
|
|
153
|
+
})
|
|
154
|
+
return data.map((c) => c.body || "").filter(Boolean)
|
|
155
|
+
} catch {
|
|
156
|
+
return []
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function fetchFileContent(
|
|
161
|
+
octokit: Octokit,
|
|
162
|
+
owner: string,
|
|
163
|
+
repo: string,
|
|
164
|
+
path: string
|
|
165
|
+
): Promise<string | undefined> {
|
|
166
|
+
try {
|
|
167
|
+
const { data } = await octokit.rest.repos.getContent({ owner, repo, path })
|
|
168
|
+
if ("content" in data && data.content) {
|
|
169
|
+
return Buffer.from(data.content, "base64").toString("utf-8")
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// file doesn't exist
|
|
173
|
+
}
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function mapFileStatus(status: string): ChangedFile["status"] {
|
|
178
|
+
if (status === "added") return "added"
|
|
179
|
+
if (status === "removed") return "removed"
|
|
180
|
+
if (status === "renamed") return "renamed"
|
|
181
|
+
return "modified"
|
|
182
|
+
}
|