Skip to content

Commit ecafdba

Browse files
feat(server): agent review single-pass detection, server-side delivery, bot command (#957)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3299754 commit ecafdba

108 files changed

Lines changed: 7076 additions & 2664 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci-docker-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ jobs:
128128
with:
129129
image-name: "ls1intum/hephaestus/agent-claude-code"
130130
docker-file: "./docker/agents/claude-code/Dockerfile"
131-
docker-context: "./docker/agents/claude-code"
131+
docker-context: "./docker/agents"
132132
registry: "ghcr.io"
133133
tags: |
134134
${{ github.ref_name }}
@@ -149,7 +149,7 @@ jobs:
149149
with:
150150
image-name: "ls1intum/hephaestus/agent-opencode"
151151
docker-file: "./docker/agents/opencode/Dockerfile"
152-
docker-context: "./docker/agents/opencode"
152+
docker-context: "./docker/agents"
153153
registry: "ghcr.io"
154154
tags: |
155155
${{ github.ref_name }}

docker/agents/claude-code/Dockerfile

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
ARG NODE_TAG=22-slim
55
FROM node:${NODE_TAG}
66

7-
RUN apt-get update -qq && apt-get install -y --no-install-recommends git findutils tree jq && \
7+
RUN apt-get update -qq && apt-get install -y --no-install-recommends git findutils tree jq curl && \
88
rm -rf /var/lib/apt/lists/*
99

1010
ARG CLAUDE_CODE_VERSION=2.1.76
@@ -14,6 +14,14 @@ RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION} && \
1414
echo '{"hasCompletedOnboarding":true}' > /home/agent/.claude.json && \
1515
chown -R 1000:1000 /home/agent /workspace
1616

17+
# Bun runtime for precomputation scripts (static analysis before agent runs)
18+
ARG BUN_VERSION=1.3.11
19+
RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash -s "bun-v${BUN_VERSION}"
20+
21+
# Precompute runner + shared libraries (practice scripts injected at runtime from DB)
22+
COPY --chown=1000:1000 precompute/runner.ts /opt/precompute/runner.ts
23+
COPY --chown=1000:1000 precompute/lib/ /opt/precompute/lib/
24+
1725
# Git security: neutralize hooks and external commands from mounted repos.
1826
# System-level config cannot be overridden by repo-local .git/config.
1927
RUN git config --system core.hooksPath /nonexistent && \

docker/agents/opencode/Dockerfile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
ARG NODE_TAG=22-slim
55
FROM node:${NODE_TAG}
66

7-
RUN apt-get update -qq && apt-get install -y --no-install-recommends git findutils tree jq && \
7+
RUN apt-get update -qq && apt-get install -y --no-install-recommends git findutils tree jq curl ca-certificates unzip && \
88
rm -rf /var/lib/apt/lists/*
99

1010
ARG OPENCODE_VERSION=1.2.26
@@ -13,6 +13,18 @@ RUN npm install -g opencode-ai@${OPENCODE_VERSION} && \
1313
mkdir -p /home/agent /workspace && \
1414
chown -R 1000:1000 /home/agent /workspace
1515

16+
# Bun runtime for precomputation scripts (static analysis before agent runs)
17+
ARG BUN_VERSION=1.3.11
18+
RUN curl -fsSL "https://github.com/oven-sh/bun/releases/download/bun-v${BUN_VERSION}/bun-linux-x64.zip" -o /tmp/bun.zip && \
19+
unzip -o /tmp/bun.zip -d /tmp && \
20+
mv /tmp/bun-linux-x64/bun /usr/local/bin/bun && \
21+
chmod +x /usr/local/bin/bun && \
22+
rm -rf /tmp/bun*
23+
24+
# Precompute runner + shared libraries (practice scripts injected at runtime from DB)
25+
COPY --chown=1000:1000 precompute/runner.ts /opt/precompute/runner.ts
26+
COPY --chown=1000:1000 precompute/lib/ /opt/precompute/lib/
27+
1628
# Git security: neutralize hooks and external commands from mounted repos.
1729
# System-level config cannot be overridden by repo-local .git/config.
1830
RUN git config --system core.hooksPath /nonexistent && \
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { DiffFile, DiffHunk } from "./types";
2+
3+
/**
4+
* Parse a unified diff (with optional [L<n>] annotations) into structured DiffFile objects.
5+
* Returns a Map of file path -> DiffFile.
6+
*/
7+
export function parseDiff(diffContent: string): Map<string, DiffFile> {
8+
const files = new Map<string, DiffFile>();
9+
10+
// Split on "diff --git" boundaries
11+
const fileDiffs = diffContent.split(/^diff --git /m).filter(Boolean);
12+
13+
for (const fileDiff of fileDiffs) {
14+
const lines = fileDiff.split("\n");
15+
16+
// Extract file path from "a/path b/path"
17+
const headerMatch = lines[0]?.match(/a\/(.+?)\s+b\/(.+)/);
18+
if (!headerMatch) continue;
19+
const filePath = headerMatch[2];
20+
21+
const addedLines = new Map<number, string>();
22+
const removedLines = new Map<number, string>();
23+
const hunks: DiffHunk[] = [];
24+
25+
let currentHunk: DiffHunk | null = null;
26+
let newLineNum = 0;
27+
let oldLineNum = 0;
28+
29+
for (const line of lines) {
30+
// Hunk header: @@ -oldStart,oldCount +newStart,newCount @@
31+
const hunkMatch = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
32+
if (hunkMatch) {
33+
currentHunk = {
34+
oldStart: parseInt(hunkMatch[1]),
35+
oldCount: parseInt(hunkMatch[2] ?? "1"),
36+
newStart: parseInt(hunkMatch[3]),
37+
newCount: parseInt(hunkMatch[4] ?? "1"),
38+
lines: [],
39+
};
40+
hunks.push(currentHunk);
41+
oldLineNum = currentHunk.oldStart;
42+
newLineNum = currentHunk.newStart;
43+
continue;
44+
}
45+
46+
if (!currentHunk) continue;
47+
48+
// Strip [L<n>] annotation if present
49+
const stripped = line.replace(/^\[L\d+\]\s*/, "");
50+
51+
if (stripped.startsWith("+") && !stripped.startsWith("+++")) {
52+
addedLines.set(newLineNum, stripped.slice(1));
53+
currentHunk.lines.push(stripped);
54+
newLineNum++;
55+
} else if (stripped.startsWith("-") && !stripped.startsWith("---")) {
56+
removedLines.set(oldLineNum, stripped.slice(1));
57+
currentHunk.lines.push(stripped);
58+
oldLineNum++;
59+
} else if (!stripped.startsWith("\\")) {
60+
// Context line
61+
currentHunk.lines.push(stripped);
62+
newLineNum++;
63+
oldLineNum++;
64+
}
65+
}
66+
67+
// Normalize path: strip leading ./
68+
const normalizedPath = filePath.replace(/^\.\//, "");
69+
files.set(normalizedPath, { path: normalizedPath, addedLines, removedLines, hunks });
70+
}
71+
72+
return files;
73+
}
74+
75+
/** Check if a given file path + line number is in the diff (on a + line) */
76+
export function isInDiff(diffFiles: Map<string, DiffFile>, filePath: string, lineNum: number): boolean {
77+
// Try exact match first, then suffix match
78+
const df = diffFiles.get(filePath) ?? [...diffFiles.values()].find(f => filePath.endsWith(f.path) || f.path.endsWith(filePath));
79+
if (!df) return false;
80+
return df.addedLines.has(lineNum);
81+
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import type { Hint } from "./types";
2+
import type { DiffFile } from "./types";
3+
import { isInDiff } from "./diff-parser";
4+
5+
interface GrepMatch {
6+
file: string;
7+
line: number;
8+
content: string;
9+
}
10+
11+
/**
12+
* Run grep on a directory. Returns structured matches.
13+
*
14+
* @param pattern — regex pattern (or fixed string if fixedString=true)
15+
* @param dir — directory to search
16+
* @param opts.glob — file glob filter (REQUIRED for language-specific searches, defaults to all files)
17+
* @param opts.maxResults — cap results (default 500)
18+
* @param opts.fixedString — use -F instead of -E (default false)
19+
*/
20+
export async function grep(
21+
pattern: string,
22+
dir: string,
23+
opts: { glob?: string; maxResults?: number; fixedString?: boolean } = {}
24+
): Promise<GrepMatch[]> {
25+
const { glob = "*", maxResults = 500, fixedString = false } = opts;
26+
27+
const fixedFlag = fixedString ? "-F" : "-E";
28+
// Use single quotes for pattern to avoid shell interpretation; escape any ' in pattern
29+
const escapedPattern = pattern.replace(/'/g, "'\\''");
30+
const cmd = `grep -rn ${fixedFlag} --include='${glob}' -m ${maxResults} '${escapedPattern}' '${dir}' 2>/dev/null || true`;
31+
32+
const result = await Bun.spawn(["bash", "-c", cmd], {
33+
stdout: "pipe",
34+
stderr: "pipe",
35+
});
36+
37+
const stdout = await new Response(result.stdout).text();
38+
const matches: GrepMatch[] = [];
39+
40+
for (const line of stdout.split("\n")) {
41+
if (!line.trim()) continue;
42+
// Format: filepath:linenum:content
43+
const m = line.match(/^(.+?):(\d+):(.*)$/);
44+
if (m) {
45+
matches.push({
46+
file: m[1].replace(dir + "/", ""), // relative path
47+
line: parseInt(m[2]),
48+
content: m[3].trim(),
49+
});
50+
}
51+
}
52+
53+
return matches;
54+
}
55+
56+
/**
57+
* Convert grep matches to Hints with diff awareness and context flags.
58+
*/
59+
export function matchesToHints(
60+
matches: GrepMatch[],
61+
pattern: string,
62+
diffFiles: Map<string, DiffFile>,
63+
flagFn?: (match: GrepMatch) => Record<string, boolean>
64+
): Hint[] {
65+
return matches.map(m => ({
66+
file: m.file,
67+
line: m.line,
68+
pattern,
69+
context: m.content,
70+
inDiff: isInDiff(diffFiles, m.file, m.line),
71+
flags: flagFn ? flagFn(m) : {},
72+
}));
73+
}
74+
75+
/**
76+
* Read a file and return its lines (1-indexed Map).
77+
*/
78+
export async function readFileLines(path: string): Promise<Map<number, string>> {
79+
try {
80+
const content = await Bun.file(path).text();
81+
const lines = new Map<number, string>();
82+
content.split("\n").forEach((line, i) => lines.set(i + 1, line));
83+
return lines;
84+
} catch {
85+
return new Map();
86+
}
87+
}
88+
89+
/**
90+
* Find all files matching a given extension in a directory.
91+
*/
92+
export async function findFiles(dir: string, extension: string): Promise<string[]> {
93+
const result = await Bun.spawn(["bash", "-c", `find "${dir}" -name "*.${extension}" -not -path "*/.*" -not -path "*/.build/*" -not -path "*/node_modules/*" 2>/dev/null`], {
94+
stdout: "pipe",
95+
});
96+
const stdout = await new Response(result.stdout).text();
97+
return stdout.split("\n").filter(Boolean);
98+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/** Precomputation types — hints and directions, never verdicts */
2+
3+
export interface Hint {
4+
file: string;
5+
line: number;
6+
pattern: string;
7+
context: string;
8+
inDiff: boolean;
9+
flags: Record<string, boolean | number | string>;
10+
}
11+
12+
export interface PracticeResult {
13+
practice: string;
14+
status: "ok" | "error" | "timeout";
15+
hints: Hint[];
16+
metrics: Record<string, number>;
17+
directions: string[];
18+
}
19+
20+
export interface DiffFile {
21+
path: string;
22+
addedLines: Map<number, string>;
23+
removedLines: Map<number, string>;
24+
hunks: DiffHunk[];
25+
}
26+
27+
export interface DiffHunk {
28+
oldStart: number;
29+
oldCount: number;
30+
newStart: number;
31+
newCount: number;
32+
lines: string[];
33+
}
34+
35+
/**
36+
* Pull request metadata — matches the JSON produced by
37+
* PullRequestReviewHandler.buildPullRequestMetadata() on the server.
38+
* Scripts should import this instead of declaring ad-hoc types.
39+
*/
40+
export interface PullRequestMetadata {
41+
pr_number: number;
42+
pr_url: string;
43+
repository_full_name: string;
44+
source_branch: string;
45+
target_branch: string;
46+
commit_sha: string;
47+
enriched: boolean;
48+
title?: string;
49+
body?: string;
50+
state?: string;
51+
is_draft?: boolean;
52+
additions?: number;
53+
deletions?: number;
54+
changed_files?: number;
55+
author?: string;
56+
}

0 commit comments

Comments
 (0)