caucus 0.1.0__tar.gz
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.
- caucus-0.1.0/.github/codex/prompts/review.md +48 -0
- caucus-0.1.0/.github/codex/review-config.json +21 -0
- caucus-0.1.0/.github/codex/review-rules.md +12 -0
- caucus-0.1.0/.github/codex/scripts/build-review-prompt.mjs +217 -0
- caucus-0.1.0/.github/codex/scripts/normalize-review-output.mjs +120 -0
- caucus-0.1.0/.github/codex/scripts/post-review.cjs +246 -0
- caucus-0.1.0/.github/pull_request_template.md +11 -0
- caucus-0.1.0/.github/workflows/ci.yml +39 -0
- caucus-0.1.0/.github/workflows/codex-review.yml +206 -0
- caucus-0.1.0/.github/workflows/keep-codex-auth-fresh.yml +78 -0
- caucus-0.1.0/.github/workflows/release.yml +69 -0
- caucus-0.1.0/.gitignore +10 -0
- caucus-0.1.0/AGENT_SETUP.md +44 -0
- caucus-0.1.0/DISCLAIMER.md +17 -0
- caucus-0.1.0/LICENSE +21 -0
- caucus-0.1.0/PKG-INFO +117 -0
- caucus-0.1.0/README.md +101 -0
- caucus-0.1.0/SPEC.md +193 -0
- caucus-0.1.0/config.example.yaml +30 -0
- caucus-0.1.0/examples/trading-robinhood/README.md +57 -0
- caucus-0.1.0/examples/trading-robinhood/config.yaml +31 -0
- caucus-0.1.0/examples/trading-robinhood/evidence.sample.json +22 -0
- caucus-0.1.0/examples/trading-robinhood/mcp.example.json +8 -0
- caucus-0.1.0/pyproject.toml +42 -0
- caucus-0.1.0/src/caucus/__init__.py +3 -0
- caucus-0.1.0/src/caucus/backends.py +113 -0
- caucus-0.1.0/src/caucus/cli.py +108 -0
- caucus-0.1.0/src/caucus/config.py +91 -0
- caucus-0.1.0/src/caucus/engine.py +203 -0
- caucus-0.1.0/src/caucus/record.py +396 -0
- caucus-0.1.0/tests/test_cli.py +87 -0
- caucus-0.1.0/tests/test_config.py +118 -0
- caucus-0.1.0/tests/test_engine.py +176 -0
- caucus-0.1.0/tests/test_record.py +428 -0
- caucus-0.1.0/uv.lock +577 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
You are reviewing a pull request for Caucus, an open-source Python framework for MCP-grounded multi-agent consensus with an auditable, hash-chained decision record.
|
|
2
|
+
|
|
3
|
+
Prioritize concrete defects over style preferences:
|
|
4
|
+
|
|
5
|
+
- broken installs, runtime errors, and CLI regressions
|
|
6
|
+
- defects in decision-record integrity: hash chaining, append-only semantics, schema versioning
|
|
7
|
+
- security issues, secret exposure, prompt-injection surfaces, unsafe dependency or workflow changes
|
|
8
|
+
- logic bugs that would make deliberation, consensus, or the recorded dissent behave incorrectly
|
|
9
|
+
- integration bugs across changed files, including CI sequencing and environment assumptions
|
|
10
|
+
- missing tests or verification when the changed behavior is risky
|
|
11
|
+
|
|
12
|
+
Use the repository diff, commit history, changed file context, and important repository context as the source of truth. Review the PR as a whole product change, not only as isolated syntax edits.
|
|
13
|
+
Keep feedback concise, concrete, and actionable. Prefer human-style code review comments that explain the user-visible or operational impact.
|
|
14
|
+
If there are no material issues, approve the PR, say that clearly, and mention any residual test or deployment risk.
|
|
15
|
+
|
|
16
|
+
Additional guidance to reduce false positives:
|
|
17
|
+
|
|
18
|
+
- Use the "Commit History (newest first)" section to understand the PR's evolution. If an issue introduced in an early commit is already resolved by a later commit in the same PR, do NOT flag it as an open finding.
|
|
19
|
+
- Use the "Quality Gate Output" section as ground truth for formatting, lint, and test failures. Do not speculate about failures that are absent from that output. Only flag a lint or test issue if it appears explicitly there.
|
|
20
|
+
|
|
21
|
+
Decision guidance:
|
|
22
|
+
|
|
23
|
+
- Use "request_changes" for blocker/high findings or any issue that should block merge.
|
|
24
|
+
- Use "comment" for medium non-blocking findings or when you have useful risk notes without a blocking defect.
|
|
25
|
+
- Use "approve" when no material findings remain after applying the configured threshold.
|
|
26
|
+
|
|
27
|
+
Return JSON only. Do not wrap it in Markdown.
|
|
28
|
+
|
|
29
|
+
Use this shape:
|
|
30
|
+
{
|
|
31
|
+
"summary": "One or two sentences describing the review result.",
|
|
32
|
+
"decision": "approve" | "comment" | "request_changes",
|
|
33
|
+
"findings": [
|
|
34
|
+
{
|
|
35
|
+
"severity": "blocker" | "high" | "medium" | "low" | "nit",
|
|
36
|
+
"category": "logic" | "security" | "runtime" | "deploy" | "test" | "maintainability" | "style" | "info",
|
|
37
|
+
"path": "relative/path/from/repo/root",
|
|
38
|
+
"line": 123,
|
|
39
|
+
"title": "Short issue title",
|
|
40
|
+
"body": "Concrete explanation of the issue and what should change.",
|
|
41
|
+
"confidence": 0.0,
|
|
42
|
+
"suggested_fix": "Optional short fix guidance.",
|
|
43
|
+
"should_block_merge": false
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Only put a line number on a changed RIGHT-side line from the diff. If a finding is important but cannot be anchored to a changed line, still include it with the closest changed line and explain the anchor in the body.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"minimumSeverity": "medium",
|
|
3
|
+
"blockingSeverities": ["blocker", "high"],
|
|
4
|
+
"enabledCategories": [
|
|
5
|
+
"logic",
|
|
6
|
+
"security",
|
|
7
|
+
"runtime",
|
|
8
|
+
"deploy",
|
|
9
|
+
"test",
|
|
10
|
+
"maintainability"
|
|
11
|
+
],
|
|
12
|
+
"ignoredPaths": [
|
|
13
|
+
"**/*.png",
|
|
14
|
+
"**/*.jpg",
|
|
15
|
+
"**/*.gif",
|
|
16
|
+
"**/*.lock",
|
|
17
|
+
"uv.lock"
|
|
18
|
+
],
|
|
19
|
+
"maxInlineComments": 20,
|
|
20
|
+
"summaryCommentMarker": "<!-- caucus-codex-review-summary -->"
|
|
21
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Caucus Review Rules
|
|
2
|
+
|
|
3
|
+
These rules tune automated Codex reviews. Keep them explicit and reviewable instead of relying on hidden memory.
|
|
4
|
+
|
|
5
|
+
- Caucus's core promise is an auditable, tamper-evident decision record. Treat any defect in record integrity (hash chaining, append-only semantics, schema versioning) as blocking, even if the code otherwise works.
|
|
6
|
+
- Prioritize prompt-injection surfaces: anywhere fetched or tool-provided text could be interpreted as instructions must be flagged.
|
|
7
|
+
- Secrets never belong in tracked files; configuration goes in `config.yaml` (gitignored) or `.env`. Flag any hardcoded path, account identifier, or credential.
|
|
8
|
+
- The project deliberately keeps dependencies minimal and storage boring (JSONL, SQLite, Markdown). Flag new dependencies or storage engines that are not clearly justified by the PR.
|
|
9
|
+
- Treat style-only feedback as non-blocking unless it hides a concrete bug or maintainability risk.
|
|
10
|
+
- For documentation-only diffs, avoid inline comments unless there is a real correctness, security, or workflow issue.
|
|
11
|
+
- When a PR changes GitHub Actions, verify permissions, secret handling, trigger scope, and whether the workflow can run safely on same-repo PRs.
|
|
12
|
+
- Do not block on speculative missing environment variables when CI passes. Mention residual configuration risk in the summary unless the diff proves a runtime failure or exposes a real secret.
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
const baseRef = process.env.BASE_REF
|
|
6
|
+
const headRef = process.env.HEAD_REF
|
|
7
|
+
const prNumber = process.env.PR_NUMBER
|
|
8
|
+
const reviewWorktree = process.env.REVIEW_WORKTREE || '.'
|
|
9
|
+
const qualityOutputPath = process.env.QUALITY_OUTPUT || ''
|
|
10
|
+
const output = process.argv[2] || 'codex-review-prompt.md'
|
|
11
|
+
|
|
12
|
+
if (!baseRef || !headRef || !prNumber) {
|
|
13
|
+
throw new Error('BASE_REF, HEAD_REF, and PR_NUMBER are required.')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function git(args, options = {}) {
|
|
17
|
+
return execFileSync('git', ['-C', reviewWorktree, ...args], {
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
20
|
+
...options,
|
|
21
|
+
}).trimEnd()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function readIfExists(filePath) {
|
|
25
|
+
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : ''
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readTargetIfExists(filePath) {
|
|
29
|
+
const targetPath = path.join(reviewWorktree, filePath)
|
|
30
|
+
return existsSync(targetPath) ? readFileSync(targetPath, 'utf8') : ''
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function truncate(value, maxChars) {
|
|
34
|
+
if (value.length <= maxChars) return value
|
|
35
|
+
return `${value.slice(0, maxChars)}\n...[truncated ${value.length - maxChars} chars]`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseChangedLines(diff) {
|
|
39
|
+
const result = new Map()
|
|
40
|
+
let currentFile = null
|
|
41
|
+
let newLine = 0
|
|
42
|
+
|
|
43
|
+
for (const line of diff.split('\n')) {
|
|
44
|
+
if (line.startsWith('+++ b/')) {
|
|
45
|
+
currentFile = line.slice('+++ b/'.length)
|
|
46
|
+
if (!result.has(currentFile)) result.set(currentFile, new Set())
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!currentFile) continue
|
|
51
|
+
|
|
52
|
+
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
|
53
|
+
if (hunk) {
|
|
54
|
+
newLine = Number(hunk[1])
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
59
|
+
result.get(currentFile).add(newLine)
|
|
60
|
+
newLine += 1
|
|
61
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
62
|
+
continue
|
|
63
|
+
} else if (!line.startsWith('\\')) {
|
|
64
|
+
newLine += 1
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return [...result.entries()]
|
|
69
|
+
.map(
|
|
70
|
+
([file, lines]) =>
|
|
71
|
+
`${file}: ${[...lines].sort((a, b) => a - b).join(', ') || '(no added lines)'}`,
|
|
72
|
+
)
|
|
73
|
+
.join('\n')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const base = `origin/${baseRef}`
|
|
77
|
+
const prompt = readIfExists('.github/codex/prompts/review.md')
|
|
78
|
+
const config = readIfExists('.github/codex/review-config.json')
|
|
79
|
+
const rules = readIfExists('.github/codex/review-rules.md')
|
|
80
|
+
const diffStat = git(['diff', '--stat', `${base}...HEAD`])
|
|
81
|
+
const diffNameOnly = git(['diff', '--name-only', `${base}...HEAD`])
|
|
82
|
+
const diff = git(['diff', `${base}...HEAD`])
|
|
83
|
+
const commitLog = git(['log', '--reverse', '--format=%h %s', `${base}..HEAD`])
|
|
84
|
+
const commitDetails = git([
|
|
85
|
+
'log',
|
|
86
|
+
'--reverse',
|
|
87
|
+
'--stat',
|
|
88
|
+
'--format=commit %H%nAuthor: %an <%ae>%nSubject: %s%nBody:%n%b',
|
|
89
|
+
`${base}..HEAD`,
|
|
90
|
+
])
|
|
91
|
+
const repoFiles = git(['ls-files'])
|
|
92
|
+
|
|
93
|
+
// Commit history newest-first with +/-line stats for multi-commit false-positive reduction.
|
|
94
|
+
const commitHistoryNewestFirst = git([
|
|
95
|
+
'log',
|
|
96
|
+
'--format=## %h %s',
|
|
97
|
+
'--stat',
|
|
98
|
+
`${base}..HEAD`,
|
|
99
|
+
])
|
|
100
|
+
|
|
101
|
+
// Output of the real quality gates (ruff format/lint + pytest) run against the PR's
|
|
102
|
+
// merged code — gives the model ground truth instead of guesses.
|
|
103
|
+
const qualityOutput = qualityOutputPath
|
|
104
|
+
? readIfExists(qualityOutputPath) || '(quality output file was empty)'
|
|
105
|
+
: '(quality checks were not run)'
|
|
106
|
+
|
|
107
|
+
const contextFiles = [
|
|
108
|
+
'README.md',
|
|
109
|
+
'AGENT_SETUP.md',
|
|
110
|
+
'pyproject.toml',
|
|
111
|
+
'.github/workflows/ci.yml',
|
|
112
|
+
'.github/workflows/codex-review.yml',
|
|
113
|
+
'.github/codex/review-config.json',
|
|
114
|
+
'.github/codex/review-rules.md',
|
|
115
|
+
].filter((file) => existsSync(path.join(reviewWorktree, file)))
|
|
116
|
+
|
|
117
|
+
const changedFiles = diffNameOnly.split('\n').filter(Boolean)
|
|
118
|
+
const changedFileContext = changedFiles
|
|
119
|
+
.filter((file) => existsSync(path.join(reviewWorktree, file)))
|
|
120
|
+
.slice(0, 30)
|
|
121
|
+
.map((file) => {
|
|
122
|
+
const body = truncate(readTargetIfExists(file), 12000)
|
|
123
|
+
return `### ${file}\n\n\`\`\`\n${body}\n\`\`\``
|
|
124
|
+
})
|
|
125
|
+
.join('\n\n')
|
|
126
|
+
|
|
127
|
+
const staticContext = contextFiles
|
|
128
|
+
.map((file) => {
|
|
129
|
+
const body = truncate(readTargetIfExists(file), 8000)
|
|
130
|
+
return `### ${file}\n\n\`\`\`\n${body}\n\`\`\``
|
|
131
|
+
})
|
|
132
|
+
.join('\n\n')
|
|
133
|
+
|
|
134
|
+
const finalPrompt = `# Review Instructions
|
|
135
|
+
|
|
136
|
+
${prompt}
|
|
137
|
+
|
|
138
|
+
# Review Configuration
|
|
139
|
+
|
|
140
|
+
\`\`\`json
|
|
141
|
+
${config}
|
|
142
|
+
\`\`\`
|
|
143
|
+
|
|
144
|
+
# Repository Review Rules
|
|
145
|
+
|
|
146
|
+
${rules}
|
|
147
|
+
|
|
148
|
+
# Pull Request Metadata
|
|
149
|
+
|
|
150
|
+
- Base branch: ${baseRef}
|
|
151
|
+
- Head branch: ${headRef}
|
|
152
|
+
- PR number: ${prNumber}
|
|
153
|
+
- Working directory: ${path.resolve('.')}
|
|
154
|
+
- Review worktree: ${path.resolve(reviewWorktree)}
|
|
155
|
+
|
|
156
|
+
# Commit History (newest first)
|
|
157
|
+
|
|
158
|
+
Use this to understand the PR's evolution. If an early commit introduced an issue that a later commit already fixed, do NOT flag it as an open finding.
|
|
159
|
+
|
|
160
|
+
\`\`\`
|
|
161
|
+
${truncate(commitHistoryNewestFirst, 12000)}
|
|
162
|
+
\`\`\`
|
|
163
|
+
|
|
164
|
+
# Pull Request Commit History
|
|
165
|
+
|
|
166
|
+
\`\`\`
|
|
167
|
+
${truncate(commitLog, 12000)}
|
|
168
|
+
\`\`\`
|
|
169
|
+
|
|
170
|
+
# Pull Request Commit Details
|
|
171
|
+
|
|
172
|
+
\`\`\`
|
|
173
|
+
${truncate(commitDetails, 40000)}
|
|
174
|
+
\`\`\`
|
|
175
|
+
|
|
176
|
+
# Quality Gate Output
|
|
177
|
+
|
|
178
|
+
This is the output of \`ruff format --check\`, \`ruff check\`, and \`pytest\` run against the PR's merged code. Use this as ground truth for formatting, lint, and test failures — do not speculate about issues that are absent from this output.
|
|
179
|
+
|
|
180
|
+
\`\`\`
|
|
181
|
+
${truncate(qualityOutput, 16000)}
|
|
182
|
+
\`\`\`
|
|
183
|
+
|
|
184
|
+
# Repository File List
|
|
185
|
+
|
|
186
|
+
\`\`\`
|
|
187
|
+
${truncate(repoFiles, 12000)}
|
|
188
|
+
\`\`\`
|
|
189
|
+
|
|
190
|
+
# Changed RIGHT-side Lines Eligible for Inline Comments
|
|
191
|
+
|
|
192
|
+
\`\`\`
|
|
193
|
+
${parseChangedLines(diff)}
|
|
194
|
+
\`\`\`
|
|
195
|
+
|
|
196
|
+
# Important Repository Context
|
|
197
|
+
|
|
198
|
+
${staticContext || '(none)'}
|
|
199
|
+
|
|
200
|
+
# Changed File Context
|
|
201
|
+
|
|
202
|
+
${changedFileContext || '(none)'}
|
|
203
|
+
|
|
204
|
+
# Diff Stat
|
|
205
|
+
|
|
206
|
+
\`\`\`
|
|
207
|
+
${diffStat}
|
|
208
|
+
\`\`\`
|
|
209
|
+
|
|
210
|
+
# Full Diff
|
|
211
|
+
|
|
212
|
+
\`\`\`diff
|
|
213
|
+
${truncate(diff, 120000)}
|
|
214
|
+
\`\`\`
|
|
215
|
+
`
|
|
216
|
+
|
|
217
|
+
writeFileSync(output, finalPrompt)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
const input = process.argv[2] || 'codex-review.raw.md'
|
|
4
|
+
const output = process.argv[3] || 'codex-review.json'
|
|
5
|
+
const configPath = process.argv[4] || '.github/codex/review-config.json'
|
|
6
|
+
|
|
7
|
+
const severityRank = {
|
|
8
|
+
nit: 0,
|
|
9
|
+
low: 1,
|
|
10
|
+
medium: 2,
|
|
11
|
+
high: 3,
|
|
12
|
+
blocker: 4,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function extractJson(text) {
|
|
16
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i)
|
|
17
|
+
if (fenced) {
|
|
18
|
+
return JSON.parse(fenced[1])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const start = text.indexOf('{')
|
|
22
|
+
if (start === -1)
|
|
23
|
+
throw new Error('Codex review output did not contain a JSON object.')
|
|
24
|
+
|
|
25
|
+
let depth = 0
|
|
26
|
+
let inString = false
|
|
27
|
+
let escaped = false
|
|
28
|
+
|
|
29
|
+
for (let index = start; index < text.length; index += 1) {
|
|
30
|
+
const char = text[index]
|
|
31
|
+
|
|
32
|
+
if (inString) {
|
|
33
|
+
if (escaped) {
|
|
34
|
+
escaped = false
|
|
35
|
+
} else if (char === '\\') {
|
|
36
|
+
escaped = true
|
|
37
|
+
} else if (char === '"') {
|
|
38
|
+
inString = false
|
|
39
|
+
}
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (char === '"') inString = true
|
|
44
|
+
if (char === '{') depth += 1
|
|
45
|
+
if (char === '}') depth -= 1
|
|
46
|
+
|
|
47
|
+
if (depth === 0) {
|
|
48
|
+
return JSON.parse(text.slice(start, index + 1))
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new Error('Codex review output had an unterminated JSON object.')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function matchesPattern(filePath, pattern) {
|
|
56
|
+
if (pattern.startsWith('**/*.')) {
|
|
57
|
+
return filePath.endsWith(pattern.slice(4))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (pattern.startsWith('*.')) {
|
|
61
|
+
return filePath.endsWith(pattern.slice(1))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return filePath === pattern
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const raw = readFileSync(input, 'utf8')
|
|
68
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'))
|
|
69
|
+
const parsed = extractJson(raw)
|
|
70
|
+
const minimumRank = severityRank[config.minimumSeverity] ?? severityRank.medium
|
|
71
|
+
const enabledCategories = new Set(config.enabledCategories || [])
|
|
72
|
+
const ignoredPaths = config.ignoredPaths || []
|
|
73
|
+
|
|
74
|
+
const findings = Array.isArray(parsed.findings) ? parsed.findings : []
|
|
75
|
+
const normalizedFindings = findings
|
|
76
|
+
.map((finding) => ({
|
|
77
|
+
severity: String(finding.severity || 'medium').toLowerCase(),
|
|
78
|
+
category: String(finding.category || 'maintainability').toLowerCase(),
|
|
79
|
+
path: String(finding.path || ''),
|
|
80
|
+
line: Number(finding.line),
|
|
81
|
+
title: String(finding.title || 'Review finding').trim(),
|
|
82
|
+
body: String(finding.body || '').trim(),
|
|
83
|
+
confidence: Number.isFinite(Number(finding.confidence))
|
|
84
|
+
? Number(finding.confidence)
|
|
85
|
+
: 0.5,
|
|
86
|
+
suggested_fix: finding.suggested_fix
|
|
87
|
+
? String(finding.suggested_fix).trim()
|
|
88
|
+
: '',
|
|
89
|
+
should_block_merge: Boolean(finding.should_block_merge),
|
|
90
|
+
}))
|
|
91
|
+
.filter((finding) => severityRank[finding.severity] !== undefined)
|
|
92
|
+
.filter((finding) => severityRank[finding.severity] >= minimumRank)
|
|
93
|
+
.filter(
|
|
94
|
+
(finding) =>
|
|
95
|
+
!enabledCategories.size || enabledCategories.has(finding.category),
|
|
96
|
+
)
|
|
97
|
+
.filter(
|
|
98
|
+
(finding) =>
|
|
99
|
+
finding.path &&
|
|
100
|
+
!ignoredPaths.some((pattern) => matchesPattern(finding.path, pattern)),
|
|
101
|
+
)
|
|
102
|
+
.filter((finding) => finding.body)
|
|
103
|
+
|
|
104
|
+
const hasBlocking = normalizedFindings.some(
|
|
105
|
+
(finding) =>
|
|
106
|
+
(config.blockingSeverities || []).includes(finding.severity) ||
|
|
107
|
+
finding.should_block_merge,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const report = {
|
|
111
|
+
summary: String(parsed.summary || '').trim() || 'Codex completed the review.',
|
|
112
|
+
decision: hasBlocking
|
|
113
|
+
? 'request_changes'
|
|
114
|
+
: normalizedFindings.length
|
|
115
|
+
? 'comment'
|
|
116
|
+
: 'approve',
|
|
117
|
+
findings: normalizedFindings,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
2
|
+
|
|
3
|
+
const severityLabel = {
|
|
4
|
+
blocker: 'Blocker',
|
|
5
|
+
high: 'High',
|
|
6
|
+
medium: 'Medium',
|
|
7
|
+
low: 'Low',
|
|
8
|
+
nit: 'Nit',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function parseChangedLines(patch) {
|
|
12
|
+
const lines = new Set()
|
|
13
|
+
let newLine = 0
|
|
14
|
+
|
|
15
|
+
for (const line of (patch || '').split('\n')) {
|
|
16
|
+
const hunk = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
|
17
|
+
if (hunk) {
|
|
18
|
+
newLine = Number(hunk[1])
|
|
19
|
+
continue
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
23
|
+
lines.add(newLine)
|
|
24
|
+
newLine += 1
|
|
25
|
+
} else if (line.startsWith('-') && !line.startsWith('---')) {
|
|
26
|
+
continue
|
|
27
|
+
} else if (!line.startsWith('\\')) {
|
|
28
|
+
newLine += 1
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return lines
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatFinding(finding) {
|
|
36
|
+
const pieces = [
|
|
37
|
+
`**${severityLabel[finding.severity] || finding.severity} / ${finding.category}: ${finding.title}**`,
|
|
38
|
+
'',
|
|
39
|
+
finding.body,
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
if (finding.suggested_fix) {
|
|
43
|
+
pieces.push('', `Suggested fix: ${finding.suggested_fix}`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pieces.push('', `Confidence: ${Math.round(finding.confidence * 100)}%`)
|
|
47
|
+
|
|
48
|
+
return pieces.join('\n')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeDecision(report, shouldRequestChanges) {
|
|
52
|
+
if (shouldRequestChanges) return 'request_changes'
|
|
53
|
+
if (report.decision === 'approve' && !report.findings?.length)
|
|
54
|
+
return 'approve'
|
|
55
|
+
if (report.decision === 'request_changes') return 'request_changes'
|
|
56
|
+
return report.findings?.length ? 'comment' : 'approve'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decisionLabel(decision) {
|
|
60
|
+
if (decision === 'request_changes') return '🛑 Request changes'
|
|
61
|
+
if (decision === 'approve') return '✅ Approved'
|
|
62
|
+
return '💬 Comment'
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
module.exports = async ({ github, context, core }) => {
|
|
66
|
+
const report = JSON.parse(fs.readFileSync('codex-review.json', 'utf8'))
|
|
67
|
+
const config = JSON.parse(
|
|
68
|
+
fs.readFileSync('.github/codex/review-config.json', 'utf8'),
|
|
69
|
+
)
|
|
70
|
+
const marker =
|
|
71
|
+
config.summaryCommentMarker || '<!-- siteally-codex-review-summary -->'
|
|
72
|
+
const pull = context.payload.pull_request
|
|
73
|
+
|
|
74
|
+
if (!pull) {
|
|
75
|
+
core.info('No pull_request payload found; skipping review post.')
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const files = await github.paginate(github.rest.pulls.listFiles, {
|
|
80
|
+
owner: context.repo.owner,
|
|
81
|
+
repo: context.repo.repo,
|
|
82
|
+
pull_number: pull.number,
|
|
83
|
+
per_page: 100,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const changedLinesByPath = new Map(
|
|
87
|
+
files.map((file) => [file.filename, parseChangedLines(file.patch)]),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const inlineComments = []
|
|
91
|
+
const summaryOnlyFindings = []
|
|
92
|
+
|
|
93
|
+
const maxInlineComments = Number(config.maxInlineComments || 20)
|
|
94
|
+
|
|
95
|
+
for (const finding of report.findings || []) {
|
|
96
|
+
const changedLines = changedLinesByPath.get(finding.path)
|
|
97
|
+
|
|
98
|
+
if (
|
|
99
|
+
changedLines?.has(finding.line) &&
|
|
100
|
+
inlineComments.length < maxInlineComments
|
|
101
|
+
) {
|
|
102
|
+
inlineComments.push({
|
|
103
|
+
path: finding.path,
|
|
104
|
+
line: finding.line,
|
|
105
|
+
side: 'RIGHT',
|
|
106
|
+
body: formatFinding(finding),
|
|
107
|
+
})
|
|
108
|
+
} else {
|
|
109
|
+
summaryOnlyFindings.push(finding)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const blockingSeverities = new Set(
|
|
114
|
+
config.blockingSeverities || ['blocker', 'high'],
|
|
115
|
+
)
|
|
116
|
+
const shouldRequestChanges = (report.findings || []).some(
|
|
117
|
+
(finding) =>
|
|
118
|
+
blockingSeverities.has(finding.severity) || finding.should_block_merge,
|
|
119
|
+
)
|
|
120
|
+
const decision = normalizeDecision(report, shouldRequestChanges)
|
|
121
|
+
// Non-blocking findings do not gate merges (the workflow files them as
|
|
122
|
+
// issues), so a 'comment' decision submits an APPROVE review: GitHub only
|
|
123
|
+
// supersedes a reviewer's earlier changes-requested review on approval or
|
|
124
|
+
// explicit dismissal, and a lingering COMMENT would leave reviewDecision
|
|
125
|
+
// stuck at CHANGES_REQUESTED forever even after every finding is fixed.
|
|
126
|
+
const reviewEvent =
|
|
127
|
+
decision === 'request_changes' ? 'REQUEST_CHANGES' : 'APPROVE'
|
|
128
|
+
|
|
129
|
+
let submittedReviewEvent = reviewEvent
|
|
130
|
+
let reviewFallbackNote = ''
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
const reviewBody =
|
|
134
|
+
decision === 'comment'
|
|
135
|
+
? `${report.summary}\n\nNon-blocking findings are posted inline and tracked as issues; approving per repository merge policy.`
|
|
136
|
+
: report.summary
|
|
137
|
+
|
|
138
|
+
const reviewPayload = {
|
|
139
|
+
owner: context.repo.owner,
|
|
140
|
+
repo: context.repo.repo,
|
|
141
|
+
pull_number: pull.number,
|
|
142
|
+
commit_id: pull.head.sha,
|
|
143
|
+
event: reviewEvent,
|
|
144
|
+
body: reviewBody,
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (inlineComments.length > 0) {
|
|
148
|
+
reviewPayload.comments = inlineComments
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
await github.rest.pulls.createReview(reviewPayload)
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (reviewEvent !== 'APPROVE') {
|
|
154
|
+
throw error
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
submittedReviewEvent = 'COMMENT'
|
|
158
|
+
reviewFallbackNote =
|
|
159
|
+
'GitHub did not allow this workflow to approve the PR. Enable repository workflow permission `Allow GitHub Actions to create and approve pull requests` if approvals should be posted by the bot.'
|
|
160
|
+
core.warning(reviewFallbackNote)
|
|
161
|
+
|
|
162
|
+
const fallbackPayload = {
|
|
163
|
+
owner: context.repo.owner,
|
|
164
|
+
repo: context.repo.repo,
|
|
165
|
+
pull_number: pull.number,
|
|
166
|
+
commit_id: pull.head.sha,
|
|
167
|
+
event: 'COMMENT',
|
|
168
|
+
body: `${report.summary}\n\n${reviewFallbackNote}`,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (inlineComments.length > 0) {
|
|
172
|
+
fallbackPayload.comments = inlineComments
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
await github.rest.pulls.createReview(fallbackPayload)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const counts = (report.findings || []).reduce((acc, finding) => {
|
|
179
|
+
acc[finding.severity] = (acc[finding.severity] || 0) + 1
|
|
180
|
+
return acc
|
|
181
|
+
}, {})
|
|
182
|
+
|
|
183
|
+
const countText =
|
|
184
|
+
Object.entries(counts)
|
|
185
|
+
.map(([severity, count]) => `${severity}: ${count}`)
|
|
186
|
+
.join(', ') || 'none'
|
|
187
|
+
|
|
188
|
+
const summaryLines = [
|
|
189
|
+
marker,
|
|
190
|
+
'## 🤖 Codex Review Summary',
|
|
191
|
+
'',
|
|
192
|
+
report.summary,
|
|
193
|
+
'',
|
|
194
|
+
'| Item | Result |',
|
|
195
|
+
'| --- | --- |',
|
|
196
|
+
`| 🧭 Review event | **${decisionLabel(decision)}** |`,
|
|
197
|
+
`| 📬 Submitted as | **${decisionLabel(submittedReviewEvent.toLowerCase())}** |`,
|
|
198
|
+
`| 🔎 Findings after threshold filtering | ${countText} |`,
|
|
199
|
+
`| 💬 Inline comments posted | ${inlineComments.length} |`,
|
|
200
|
+
]
|
|
201
|
+
|
|
202
|
+
if (reviewFallbackNote) {
|
|
203
|
+
summaryLines.push('', `⚠️ Note: ${reviewFallbackNote}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (summaryOnlyFindings.length > 0) {
|
|
207
|
+
summaryLines.push('', '📌 Findings that could not be anchored inline:')
|
|
208
|
+
for (const finding of summaryOnlyFindings) {
|
|
209
|
+
summaryLines.push(
|
|
210
|
+
`- **${finding.severity}/${finding.category}** ${finding.path}:${finding.line || '?'} - ${finding.title}: ${finding.body}`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!report.findings?.length) {
|
|
216
|
+
summaryLines.push('', '✅ No findings met the configured review threshold.')
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const body = summaryLines.join('\n')
|
|
220
|
+
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
221
|
+
owner: context.repo.owner,
|
|
222
|
+
repo: context.repo.repo,
|
|
223
|
+
issue_number: pull.number,
|
|
224
|
+
per_page: 100,
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
const existing = comments.find(
|
|
228
|
+
(comment) => comment.user?.type === 'Bot' && comment.body?.includes(marker),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if (existing) {
|
|
232
|
+
await github.rest.issues.updateComment({
|
|
233
|
+
owner: context.repo.owner,
|
|
234
|
+
repo: context.repo.repo,
|
|
235
|
+
comment_id: existing.id,
|
|
236
|
+
body,
|
|
237
|
+
})
|
|
238
|
+
} else {
|
|
239
|
+
await github.rest.issues.createComment({
|
|
240
|
+
owner: context.repo.owner,
|
|
241
|
+
repo: context.repo.repo,
|
|
242
|
+
issue_number: pull.number,
|
|
243
|
+
body,
|
|
244
|
+
})
|
|
245
|
+
}
|
|
246
|
+
}
|