Skip to content

Commit 4459cb7

Browse files
fix(server): let the review write its own opening, and read as one message
A review comment opened with one of a small set of server-authored lines — "Nice work …" for a clean change, "Worth keeping: …" for a strength beside a problem — chosen from a curated slug-to-phrase map. The same sentence opened every review that fell in the same bucket, and a change with a critical finding could still open on praise. The opening now comes from the review itself. A report_summary tool carries one or two sentences out in the feedback envelope as `lead`, and the note opens on them. Nothing stands in for a missing one: with no lead the note opens on its first finding. The note also stops working against itself. It no longer states its issue count twice, a strength and its next step no longer run together unpunctuated, the footer no longer carries the run's duration, and a defect found by opening a file is quoted from the diff so the note lands beside the code. The opening is model prose in the position a reader trusts most, so it is held to a narrower contract than a finding body. The runner sends an over-long lead back to be rewritten rather than cutting it mid-word; the parser takes it only when it is a string; the composer scrubs it, clamps it at a sentence boundary, and drops it whole if it reaches for markup that could restructure the comment around it, for a link, or for a verdict on merging — which the composer is never told and so could only invent. That last one was previously guaranteed by the fixed phrasing rather than checked, and a two-sentence orientation has no legitimate use for any of them. Exactly one comment may open on the lead. The auto-posted note is composed first and takes it; an approved proposal takes it only when there is no such note, which is the all-approval workspace where the proposal is the only comment the developer sees. Code fences are now balanced at egress rather than in the composer, because the length cut that runs there can reopen a block that was balanced upstream, and everything below an open fence renders as code — including the AI-generated disclosure the comment carries. Counting fences was not enough either: a block ends only on a fence of the same character and at least the opening length carrying no info string, so a quoted block inside a quote left the count even and the document broken. Quoted evidence is now wrapped in a fence that outruns any run of backticks inside it. Two holes in that egress sanitizer went with it: an unterminated comment opener matched nothing and reached the provider intact, and mentions were escaped only after bracket-like characters, so `cc,@name` shipped live. Approved proposals now go through that sanitizer, which they were bypassing entirely, and are suppressed rather than posted when they sanitize to nothing — the provider rejects an empty comment, and that exception would escape the listener and strand the approval with nothing to retry it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LK2yxSEfNL5Q5xwbAqAMWr
1 parent efbd269 commit 4459cb7

20 files changed

Lines changed: 669 additions & 446 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
Review comments no longer repeat their own issue count in the opening line, no longer run a strength and its next step together into one unpunctuated sentence, and no longer report how long the run took.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
A problem the review found by opening a file now arrives as a comment on the changed line, rather than as a paragraph at the bottom of the merge request.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
A review comment now opens with a sentence written about your change, instead of one of a handful of fixed lines that read the same on every review — including on reviews that opened with praise ahead of a serious problem. When the review has nothing worth opening on, it opens on its first finding.

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/ApprovedFeedbackDeliveryListener.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,19 @@ public void deliver(ApprovedFeedbackReadyEvent event) {
9595
return;
9696
}
9797
if (existing.kind() == ExistingDeliveryLookup.Kind.ABSENT) {
98+
String sanitized = PullRequestCommentPoster.sanitize(feedback.getBody());
99+
// A provider rejects an empty comment, and the resulting exception would escape this listener and
100+
// strand the approval in PREPARED with nothing to retry it.
101+
if (sanitized.isBlank()) {
102+
feedbackRepository.markApprovedSuppressed(
103+
event.workspaceId(),
104+
feedback.getId(),
105+
FeedbackSuppressionReason.EMPTY_AFTER_SANITIZE.name()
106+
);
107+
return;
108+
}
98109
try {
99-
commentPoster.postApprovedProposal(job, feedback.getId(), feedback.getBody());
110+
commentPoster.postApprovedProposal(job, feedback.getId(), sanitized);
100111
} catch (JobDeliverySuppressedException exception) {
101112
feedbackRepository.markApprovedSuppressed(
102113
event.workspaceId(),

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/DeliveryComposer.java

Lines changed: 80 additions & 174 deletions
Large diffs are not rendered by default.

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/IssueReviewHandler.java

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -260,12 +260,17 @@ public void deliver(AgentJob job) {
260260
);
261261
Map<String, String> why = practiceCatalogInjector.whyBySlug(job.getWorkspace(), ArtifactKinds.ISSUE);
262262
List<ComposedFeedbackUnit> units = compositionResultParser.parse(job.getOutput(), FeedbackChannel.IN_CONTEXT);
263+
String lead = compositionResultParser.lead(job.getOutput());
264+
var note = DeliveryComposer.compose(loudEnough, ArtifactKinds.ISSUE, why, null, units, lead);
265+
// An approved proposal posts as its own comment, so only one of the two may open on the lead.
266+
// The auto-posted note is composed first and takes it; the proposal takes it only when there is
267+
// no such note, which is the all-approval workspace where the proposal is the only comment.
263268
feedbackLedgerRecorder.recordProposal(
264269
job,
265-
DeliveryComposer.compose(proposals, ArtifactKinds.ISSUE, why, null, units),
270+
DeliveryComposer.compose(proposals, ArtifactKinds.ISSUE, why, null, units, note == null ? lead : null),
266271
proposals
267272
);
268-
postIssueNote(job, DeliveryComposer.compose(loudEnough, ArtifactKinds.ISSUE, why, null, units));
273+
postIssueNote(job, note);
269274
}
270275

271276
private PracticeDetectionResultParser.ValidatedObservation validated(Observation observation) {

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/PracticeFeedbackCommentFormatter.java

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import de.tum.cit.aet.hephaestus.agent.job.AgentJob;
44
import de.tum.cit.aet.hephaestus.config.ApplicationProperties;
5-
import java.time.Duration;
65
import org.jspecify.annotations.Nullable;
76
import org.springframework.stereotype.Component;
87
import org.springframework.web.util.HtmlUtils;
@@ -56,12 +55,7 @@ private static void appendMetadataFooter(StringBuilder sb, AgentJob job) {
5655
if (modelName != null && !modelName.isBlank()) {
5756
sb.append(" &middot; ").append(HtmlUtils.htmlEscape(modelName));
5857
}
59-
if (job.getStartedAt() != null && job.getCompletedAt() != null) {
60-
sb.append(" &middot; ").append(formatDuration(Duration.between(job.getStartedAt(), job.getCompletedAt())));
61-
}
62-
63-
sb.append("</sub>\n");
64-
sb.append("<sub>AI-generated feedback can be inaccurate. React with 👍 or 👎 to give feedback.</sub>\n");
58+
sb.append(" &middot; AI-generated and can be inaccurate. React with 👍 or 👎 to give feedback.</sub>\n");
6559
}
6660

6761
@Nullable
@@ -72,14 +66,4 @@ private static String snapshotModelName(@Nullable JsonNode configSnapshot) {
7266
JsonNode model = configSnapshot.path("upstreamModelId");
7367
return model.isString() ? model.asString() : null;
7468
}
75-
76-
private static String formatDuration(Duration duration) {
77-
long totalSeconds = Math.max(0, duration.toSeconds());
78-
if (totalSeconds < 60) {
79-
return totalSeconds + "s";
80-
}
81-
long minutes = totalSeconds / 60;
82-
long seconds = totalSeconds % 60;
83-
return minutes + "m " + seconds + "s";
84-
}
8569
}

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/PullRequestCommentPoster.java

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class PullRequestCommentPoster {
4141
* Lookbehind covers start-of-line, whitespace, punctuation, and markdown formatting chars
4242
* ({@code * _ ~ > | -}) to prevent bypass via {@code *@user*}, {@code >@user}, or {@code - @user}. */
4343
private static final Pattern AT_MENTION = Pattern.compile(
44-
"(?<=^|[\\s(\\[\"'*_~>|#!+={}\\-])@([a-zA-Z0-9][-a-zA-Z0-9._]*)",
44+
"(?<=^|[\\s(\\[\"'*_~>|#!+={}\\-,.:;/)])@([a-zA-Z0-9][-a-zA-Z0-9._]*)",
4545
Pattern.MULTILINE
4646
);
4747

@@ -406,6 +406,8 @@ static String sanitize(String raw) {
406406
result = result.replace("\r\n", "\n").replace("\r", "\n");
407407
result = INVISIBLE_CHARS.matcher(result).replaceAll("");
408408
result = HTML_COMMENT.matcher(result).replaceAll("");
409+
// An unterminated opener matches nothing above, and runs to end of document in both renderers.
410+
result = result.replace("<!--", "");
409411

410412
// Autolinks become plain links first, so the tag stripping below does not eat them.
411413
result = AUTOLINK.matcher(result).replaceAll("$1");
@@ -438,13 +440,58 @@ static String sanitize(String raw) {
438440
result = EXCESSIVE_NEWLINES.matcher(result).replaceAll("\n\n");
439441

440442
result = result.strip();
441-
if (result.length() > MAX_BODY_LENGTH) {
442-
result = result.substring(0, MAX_BODY_LENGTH) + "\n\n[... truncated — comment exceeded length limit]";
443+
boolean truncated = result.length() > MAX_BODY_LENGTH;
444+
if (truncated) {
445+
result = result.substring(0, MAX_BODY_LENGTH);
446+
}
447+
// Last, because the cut above can reopen a block that was balanced upstream, and because everything
448+
// below an open fence renders as code — including the AI-generated disclosure this comment carries.
449+
result = balanceCodeFences(result);
450+
if (truncated) {
451+
result += "\n\n[... truncated — comment exceeded length limit]";
443452
}
444453

445454
return result;
446455
}
447456

457+
/**
458+
* Closes a fenced block the body leaves open. Counting fences is not enough: CommonMark ends a block
459+
* only on a fence of the same character and at least the opening length that carries no info string, so
460+
* an inner fence is content, a `~~~` is a fence, and an indented line is not one.
461+
*/
462+
static String balanceCodeFences(String text) {
463+
char openChar = 0;
464+
int openLength = 0;
465+
for (String line : text.split("\n", -1)) {
466+
String stripped = line.stripLeading();
467+
if (line.length() - stripped.length() >= 4 || stripped.isEmpty()) {
468+
continue;
469+
}
470+
char marker = stripped.charAt(0);
471+
if (marker != '`' && marker != '~') {
472+
continue;
473+
}
474+
int run = 0;
475+
while (run < stripped.length() && stripped.charAt(run) == marker) {
476+
run++;
477+
}
478+
if (run < 3) {
479+
continue;
480+
}
481+
String info = stripped.substring(run);
482+
if (openLength == 0) {
483+
if (marker == '`' && info.indexOf('`') >= 0) {
484+
continue;
485+
}
486+
openChar = marker;
487+
openLength = run;
488+
} else if (marker == openChar && run >= openLength && info.isBlank()) {
489+
openLength = 0;
490+
}
491+
}
492+
return openLength == 0 ? text : text + "\n" + String.valueOf(openChar).repeat(openLength);
493+
}
494+
448495
static String requireMetadataText(@Nullable JsonNode metadata, String field) {
449496
if (metadata == null) {
450497
throw new JobDeliveryException("Missing required metadata field: " + field);

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/PullRequestReviewHandler.java

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -336,15 +336,26 @@ private void deliverAdmitted(AgentJob job) {
336336
.evaluate(job, loudEnough)
337337
.deliverable();
338338
List<ComposedFeedbackUnit> units = compositionResultParser.parse(job.getOutput(), FeedbackChannel.IN_CONTEXT);
339+
String lead = compositionResultParser.lead(job.getOutput());
339340
Map<String, String> why = practiceCatalogInjector.whyBySlug(job.getWorkspace(), ArtifactKinds.PULL_REQUEST);
341+
var content = DeliveryComposer.compose(deliverable, ArtifactKinds.PULL_REQUEST, why, unifiedDiff, units, lead);
342+
// An approved proposal posts as its own comment, so only one of the two may open on the lead.
343+
// The auto-posted note is composed first and takes it; the proposal takes it only when there is
344+
// no such note, which is the all-approval workspace where the proposal is the only comment.
340345
feedbackService.recordProposal(
341346
job,
342-
DeliveryComposer.compose(proposals, ArtifactKinds.PULL_REQUEST, why, unifiedDiff, units),
347+
DeliveryComposer.compose(
348+
proposals,
349+
ArtifactKinds.PULL_REQUEST,
350+
why,
351+
unifiedDiff,
352+
units,
353+
content == null ? lead : null
354+
),
343355
proposals
344356
);
345-
var content = DeliveryComposer.compose(deliverable, ArtifactKinds.PULL_REQUEST, why, unifiedDiff, units);
346357
feedbackService.deliverFeedback(job, content, delivered ->
347-
DeliveryComposer.recomposeMrNote(deliverable, ArtifactKinds.PULL_REQUEST, why, delivered, units)
358+
DeliveryComposer.recomposeMrNote(deliverable, ArtifactKinds.PULL_REQUEST, why, delivered, units, lead)
348359
);
349360
}
350361

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/composition/FeedbackCompositionResultParser.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public class FeedbackCompositionResultParser {
3030

3131
private static final int MAX_PRACTICE_SLUG_LENGTH = 128;
3232

33+
/** A ceiling on the payload, not on the opening: the composer clamps the lead to its own budget. */
34+
private static final int MAX_LEAD_LENGTH = 4_000;
35+
3336
public List<ComposedFeedbackUnit> parse(@Nullable JsonNode jobOutput) {
3437
if (jobOutput == null || !jobOutput.isObject()) {
3538
return List.of();
@@ -270,6 +273,12 @@ public List<ComposedFeedbackUnit> parse(@Nullable JsonNode jobOutput, FeedbackCh
270273
);
271274
}
272275

276+
/** How the review opens, in the composer's words; null when it wrote none. */
277+
@Nullable
278+
public String lead(@Nullable JsonNode jobOutput) {
279+
return jobOutput == null ? null : text(jobOutput.path("feedback"), "lead", MAX_LEAD_LENGTH);
280+
}
281+
273282
private static Map<String, StagedObservation> readObservations(@Nullable JsonNode node) {
274283
Map<String, StagedObservation> observations = new LinkedHashMap<>();
275284
if (node == null || !node.isArray()) {

0 commit comments

Comments
 (0)