Skip to content

agents/glue-review: AI fix prompt per inline comment (v1.1.0)#87

Merged
erain merged 1 commit intomainfrom
ai-fix-prompt
May 6, 2026
Merged

agents/glue-review: AI fix prompt per inline comment (v1.1.0)#87
erain merged 1 commit intomainfrom
ai-fix-prompt

Conversation

@erain
Copy link
Copy Markdown
Owner

@erain erain commented May 6, 2026

Summary

Each inline review comment now carries a copy-pastable AI-coding-agent prompt, rendered as a <details><summary>💡 AI prompt to fix</summary> collapsible. Reviewers copy the inner block straight into Claude / Cursor / Aider to dispatch the fix.

Pattern borrowed from fluent-bit's review style.

Surface changes

Prompt v2 (new default):

  • Requires Fix: <agent prompt> after each description in Issues / Suggestions.
  • v1 stays in source; pin via --prompt-version v1 or the matching Action input to opt back in.

InlineComment.fix (new JSON field, optional):

  • Empty for v1-style entries → backwards-compatible payload on disk.
  • Populated for v2 entries → rendered as the collapsible.

Action renderer:

  • Inline-comment body conditionally appends the <details> only when fix is non-empty. When empty, rendered comment is byte-identical to v1.0.0 output.

What stays the same

  • Action input/output surface — additive only.
  • Sticky comment / PR Review marker shape — the collapsible is additive content within the existing inline-comment shape.
  • v1 prompt output continues through the parser and validator unchanged.

Test plan

  • 4 new parser tests covering Fix split: standard form, lowercase / all-caps variants, "fix" appearing in the description (last-match wins), no-Fix backwards compat.
  • Existing parse / validate / fixture tests stay green.
  • Live fixture replay: real models produce parseable Fix clauses under v2 (verified locally before push).

Self-test on this PR

When this PR triggers the dogfood workflow, the inline comments should each carry the new collapsible. If they don't, the parser is dropping the Fix clause — investigate before merge.

Versioning

Bumps the agent to v1.1.0. After merge, advance the floating v1 tag and cut a v1.1.0 immutable tag. Library version unchanged (still pre-1.0).

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 6, 2026

Summary

Backwards-compatible v1.1.0 release that adds an Fix: clause to inline review entries, parsed into InlineComment.fix and rendered as a collapsible "AI prompt to fix" block in GitHub PR inline comments.

Issues

  • [major] agents/glue-review/parse.go:137 — splitBodyAndFix truncates the last character of the body before the Fix: delimiter when the preceding character is NOT a period, because it slices at loc[0]+1 unconditionally. For example, with input "do X Fix: do Y", loc[0] points to the space before Fix, so s[:loc[0]+1] includes the trailing space, then TrimRight(body, ".") only removes periods. The +1 is intended to consume the period after trimming, but when the delimiter is preceded by a space (no period), this incorrectly includes the space in the slice, and then TrimSpace later removes it. However, if the delimiter is immediately after a character without whitespace (e.g., "doX.Fix: do Y" where the regex matches X.Fix: because the (?i)[. ] class allows . before \bfix), the +1 would consume the .. More critically, the regex (?i)[. ]\s*\bfix\s*:\s* will match . or before fix, and the +1 always advances one position. But if the match starts at the space (not the period), and the period is part of the preceding word, the body slice includes the space which gets trimmed. The actual bug: for input "The fix should be obvious. Fix: rewrite", the regex matches Fix: starting at the space. loc[0]+1 makes the body slice include up to and including the space. After trimming, the body is correct. But for "doX.Fix:doY" (no space), the match is at the . because [. ] matches .. Then loc[0]+1 consumes the . which is correct. The issue is more subtle: in the test case "do X Fix: do Y" the regex matches starting at the space. s[:loc[0]+1] = "do X " including the space. Then TrimRight(body, ".") is a no-op. Then TrimSpace(body) correctly returns "do X". Actually this seems fine. Let me re-examine. The real issue is when the input doesn't have a space between the body and Fix:, e.g. "do X.Fix: do Y" — but the regex requires [. ] before \bfix, so . matches, leaving loc[0] at the .. Then s[:loc[0]+1] = "do X." (with the .), TrimRight(body, ".") removes the ."do X", TrimSpace"do X". This is correct. Hmm, actually I need to look more carefully. What if the body is "Something.Fix: do Y" where the model forgets space? The regex [. ]\s*\bfix — the . is part of the character class, and \b is word boundary. After ., the f of fix starts a new word, so \b matches. But the period in Something. is part of the body. The regex match starts at the .. loc[0]+1 gives us Something. and then we strip the trailing .. This is fine. What about "Something .Fix: do Y"? The regex might match .Fix:. The [. ] class matches the space, then \bfix matches Fix. Wait, \b before f — after a space, f starts a word, so \b matches. The match starts at the space. s[:loc[0]+1] includes the space. Trimmed away. Fine. I think the logic is actually correct for most cases. But wait — what if there's no character before Fix: at all, e.g., the description starts with Fix:? Then loc[0] could be 0, and s[:1] is the first character? Actually if the string is "Fix: do Y", the regex [. ]\s*\bfix\s*: won't match because there's no . or before Fix at position 0. So FindAllStringIndex returns nil, and the function returns the whole string as body. Good. The actual concern: when loc[0] is at position 0 because the match starts at the very beginning with a space? Actually strings.TrimSpace(m[4]) is called before splitBodyAndFix, so leading spaces are removed. Let me check if there's an edge case where body becomes empty. Not likely. I think the logic is sound. One more check: the test case "the fix is small. Fix: real prompt" — the regex matches Fix: at the second space. loc[0]+1 includes the space. TrimSpace removes it. TrimRight(body, ".") removes the period from small.. Result: "the fix is small". Correct. OK, I think I was wrong about the major issue. Let me look again... Actually, the body slice s[:loc[0]+1] — if the match starts at, say, index 5, then loc[0]+1 is 6, and s[:6] gives characters 0-5. The regex match includes the preceding space or period. So if the match is Fix: (space + Fix:), loc[0] is the index of the space, and s[:loc[0]+1] includes the space. Then TrimSpace removes it. But what if the body doesn't end with a period? Test case: "do X Fix: do Y" — body should be "do X". Match is Fix:, loc[0] is the space position, let's say 3 (d-o-X-space). s[:4] = "do X " (includes the space). TrimRight(body, ".") no-op. TrimSpace(body)"do X". Correct. Test case: "do X.Fix: do Y" — match is .Fix:, loc[0] is 3 (0-indexed d=0,o=1,X=2,.=3). s[:4] = "do X.". TrimRight(body, ".")"do X". TrimSpace"do X". Correct. But wait — what if the regex matches just Fix: without a preceding . or space? The regex requires [. ]\s* before \bfix, so that's not possible unless there's another character. Actually the character class is [. ] which means "period or space". So the match always starts with either a period or a space. If it starts with a space, loc[0]+1 includes the space — fine, trimmed. If it starts with a period, loc[0]+1 includes the period. Then TrimRight(body, ".") removes it. This is correct! But wait, what if there are multiple consecutive periods? Like "do X.. Fix: do Y" — the regex matches .. Fix: or . Fix:? The \s* would match the space, [. ] matches the first .... Actually the regex engine will find the leftmost match. It might match . Fix: starting at the first .. Then loc[0]+1 includes one ., and TrimRight(body, ".") removes trailing periods, but what about the second . before the space? The body slice would be "do X." and after trimming, "do X". Wait, the second . is still there! Let me trace: "do X.. Fix: do Y". The regex [. ]\s*\bfix\s*: — looking for a . or followed by optional whitespace, then fix. One possible match: . Fix: (the second . before Fix). Another: .. wouldn't work because [. ] matches one character. For .. Fix:, the regex could match at the second . with . Fix:, or at the first . with .. F — no, because after matching ., the \s* can be zero-width, then \bfix requires fix to follow. So at the first ., the next chars are . F, and fix doesn't follow. At the second ., the next chars are Fix, so Fix: matches (space matches \s*, but actually . is a word character... no, F is a word character, . is not. After ., F starts a word, so \b matches. So the match is .Fix: from the second .. Hmm, but there's a space: . Fix:. So \s* matches the space. The match starts at the second .. s[:loc[0]+1] includes the second .. TrimRight(body, ".") removes it. The body becomes "do X.". But we wanted "do X." or "do X"? The original string was "do X.. Fix: do Y". If the intent is "do X." as the body (with one period), then TrimRight(body, ".") would remove it, giving "do X". Hmm, maybe this edge case is too obscure. Actually, I think the real issue is something else. Let me look at the paired trims: body = strings.TrimSpace(s[:loc[0]+1]) — wait no, the code is body = strings.TrimSpace(s[:loc[0]+1]) then body = strings.TrimRight(body, ".") then return strings.TrimSpace(body), fix. Actually in the code it does body = strings.TrimSpace(s[:loc[0]+1]) — wait, no, line 136 in the diff is body = strings.TrimSpace(s[:loc[0]+1]), but in the actual file I see: body = strings.TrimSpace(s[:loc[0]+1]) is not present. Let me re-read the file... The actual code is: body = strings.TrimSpace(s[:loc[0]+1]) — no, I see body = strings.TrimSpace(s[:loc[0]+1]) is not in the code. Let me look at lines 133-139:
func splitBodyAndFix(s string) (body, fix string) {
	locs := fixDelimiterRE.FindAllStringIndex(s, -1)
	if len(locs) == 0 {
		return strings.TrimSpace(s), ""
	}
	loc := locs[len(locs)-1]
	body = strings.TrimSpace(s[:loc[0]+1])
	fix = strings.TrimSpace(s[loc[1]:])
	body = strings.TrimRight(body, ".")
	return strings.TrimSpace(body), fix
}

Wait, line 137 is body = strings.TrimSpace(s[:loc[0]+1]). But after body = strings.TrimRight(body, "."), it does return strings.TrimSpace(body), fix. The first TrimSpace on line 137 already trims spaces, then TrimRight removes periods, then another TrimSpace. This seems redundant but not wrong. However, there's a bug: s[:loc[0]+1] — the +1 is meant to swallow the character before Fix: (the space or period that the regex matched), but it always takes one extra character. If the match starts with a space, +1 includes the space. TrimSpace removes it. Good. If the match starts with a period, +1 includes the period. TrimRight removes it. Good. But what if the match is .Fix: where the space is matched by \s* and . is matched by [. ]? Then loc[0] is the . position, and +1 includes the .. TrimRight removes it. But the space before it is still in the body, and TrimSpace already ran. So the body ends with a space before the .. Wait, let me trace: input "do X .Fix: do Y". Regex matches .Fix: starting at the .. loc[0] is the index of .. s[:loc[0]+1] = "do X ." (includes the space before the dot and the dot). TrimSpace"do X .". TrimRight(body, ".")"do X ". TrimSpace"do X". Correct. Hmm, what about "do X. Fix: do Y"? Regex matches . Fix: where . is at loc[0]. s[:loc[0]+1] = "do X.". TrimSpace"do X.". TrimRight(body, ".")"do X". TrimSpace"do X". Correct. Actually, the code seems correct. I was overthinking it. Let me look for actual issues elsewhere. Ah wait — I just realized: the first TrimSpace on line 137 is strings.TrimSpace(s[:loc[0]+1]). This trims from both ends. If s[:loc[0]+1] is " do X . ", TrimSpace gives "do X .". Good. But what if the match is at the very beginning of the string? Actually TrimSpace(m[4]) is called in parseInlineComments before passing to splitBodyAndFix, so s has no leading/trailing spaces. Fine. OK, let me check the Action YAML for issues. In the retry loop, the code does: gh api ... > "$api_resp_path" 2>&1. If the command fails, the response is in the file. Then it checks if [ "$attempt" -ge "$max_attempts" ]. But the gh api output includes the HTTP response body. For a 422 error, the file contains the error JSON. The jq on line 275 tries to extract .html_url from what might be an error. But that's in the success branch. On failure, gh api exits non-zero, so the else branch is taken, and the else branch just does error logging. Good. One issue: the gh api with --input for a POST request to the reviews endpoint. If the API returns 422 with "An internal error occurred, please try again", the response body might be HTML or JSON. The > "$api_resp_path" 2>&1 redirects both stdout and stderr to the file. But gh api might include progress info or other stderr output. Actually gh api typically doesn't write progress to stderr for API calls.

Hmm, let me re-read the action.yml diff more carefully. There's a comment added near line 232 explaining the promptv1 marker. The comment says the marker is stable and only bumps when the comment SHAPE changes. That's good.

Actually, I want to check if there are any jq parsing issues in the comments rendering. Line 278 in the diff: jq '.[0:20] | map({path, line, side, body_chars: (.body | length)})' "$comments_path" || true. The comments file is the result of a jq expression that creates the inline comments. If the API failed, dumping the first 20 comments with body length is a good debug aid. Nothing wrong here.

Let me think about edge cases in the Fix regex. The regex is (?i)[. ]\s*\bfix\s*:\s*. This is intended to match Fix:, .Fix:, fix:, etc. The \b word boundary before fix ensures we don't match words like prefix: or suffix:. But wait — \b matches between a non-word character and a word character. [. ] includes . and , both non-word characters. So after . or , fix starts with f, a word character, so \b matches. Good. But what about prefix-fix:? The f follows -, which is a non-word character, so \b would match before fix. But the regex requires [. ] before \s*\bfix. So prefix-fix: wouldn't match because x is a word character and there's no . or before fix. Good.

But what about a case like "prefix. Fix: do Y"? The match is . Fix: where . is matched by [. ] and is matched by \s*. This is fine. loc[0] is at .. s[:loc[0]+1] = "prefix.". TrimRight(body, ".")"prefix". But wait, we had "prefix. Fix:" so the body should be "prefix". Correct.

Wait, I think I found a real issue. Consider the input: "Fix prefix. Fix: do Y". The regex matches Fix: (the second one). s[:loc[0]+1] = "Fix prefix.". TrimRight(body, ".")"Fix prefix". This is correct — the body is "Fix prefix". Hmm, seems fine.

Actually, let me look at the v2 prompt itself. The prompt says: "Always include Fix: after the description in Issues and Suggestions." But in the "Looks good" and "Open questions" sections, it says "No Fix: here." This is good — the parser only looks in Issues and Suggestions sections.

Let me re-check the splitBodyAndFix function for the actual bug I thought I found. Consider this input: "do X..Fix: do Y". There's no space between .. and Fix, but there's also no or . before Fix in the sense that the regex matches. Wait, the regex is [. ]\s*\bfix\s*:. For ..Fix:, the first . is matched by [. ], then \s* is zero-width, then \bfix — after ., F is a word character, so \b matches. So the match is .Fix: starting at the first .? No wait, the string is . . F i x : — characters at indices say 0='.', 1='.', 2='F', 3='i', 4='x', 5=':'. At index 0: [. ] matches '.', then \s* matches empty, then \b checks between '.' and 'F' — '.' is non-word, 'F' is word, so \b matches. Then fix matches Fix (case-insensitive), then \s* matches empty, then : matches ':'. So the match is .Fix: starting at index 0. loc[0] = 0. s[:loc[0]+1] = s[:1] = . . TrimSpace.. TrimRight(body, ".")"" (empty string). TrimSpace"". So body is empty and fix is "do Y". This is a bug! The body should be "." or more reasonably, the function should not have matched at all. But actually, in the context where the input is the description after parsing, having a description that starts with .Fix: is very unlikely. Still, this could happen if the model writes something like ".Fix: do something" as the description. But more realistically, if the model forgets to write a proper description and just puts Fix:, the body would be empty.

Actually, let me focus on a more practical issue. The regex (?i)[. ]\s*\bfix\s*:\s* uses \b which in Go's RE2 is supported. The issue is that [. ] is inside a character class, so it matches either . or literally. But since the regex is case-insensitive, fix matches Fix, FIX, etc. The word boundary \b before fix requires that the character before f (case-insensitive) is a non-word character or start of string. Since [. ] are both non-word characters, this is satisfied. However, there's another potential issue: the regex could match inside a markdown code block. For example, if the description contains code with fix: something, the regex would match fix:. But splitBodyAndFix uses the LAST match, which is by convention the model's actual Fix: clause, so this is handled.

OK, let me actually look for a real bug now. Looking at the action.yml diff, the retry logic:

if gh api -X POST "repos/$REPO/pulls/$PR_NUMBER/reviews" \
     --input "$payload_path" > "$api_resp_path" 2>&1; then

The gh api command outputs the HTTP response to stdout and errors to stderr, both redirected to the file. If it succeeds, url=$(jq -r '.html_url' "$api_resp_path"). This looks fine.

On failure:

if [ "$attempt" -ge "$max_attempts" ]; then
  echo "Inline review POST failed after $attempt attempts; falling back to sticky comment."
  echo "----API response (last 4k)----"
  tail -c 4096 "$api_resp_path" || true
  echo
  echo "----submitted comments (first 20)----"
  jq '.[0:20] | map({path, line, side, body_chars: (.body | length)})' "$comments_path" || true
  echo "posted=false" >> "$GITHUB_OUTPUT"
  break
fi

This logs the last 4KB of the API response. But wait, in the success case, the api_resp_path contains the JSON response from the API. The url is extracted, and then the loop breaks. But what happens to the api_resp_path file? It's never cleaned up, but it's in $RUNNER_TEMP, so it gets cleaned up by the runner. Fine.

In the intermediate failure case:

echo "Inline review POST failed on attempt $attempt; retrying in $((attempt * 2))s…"
tail -c 512 "$api_resp_path" || true
sleep $((attempt * 2))

This logs the last 512 bytes of the error response, which is useful for debugging. Then sleeps and retries.

This all looks fine. Let me reconsider the actual code for issues...

Ah, I think I finally see the real issue! In splitBodyAndFix:

body = strings.TrimSpace(s[:loc[0]+1])

When the regex match loc starts at loc[0], the slice s[:loc[0]+1] includes the character at loc[0]+1. But loc[0] is the start of the match, and the match includes a . or at the beginning. The +1 is supposed to consume that character into the slice. Then TrimRight(body, ".") removes a trailing period. But if the match starts with a space (because the regex's [. ] matched a space), then +1 includes the space in the slice. That's fine because TrimSpace removes it.

But wait — there's a subtle bug. Consider input: "Something. Fix: do it" where the match is . Fix:. Wait, does the regex match Fix: or . Fix:? Let's see: The regex is (?i)[. ]\s*\bfix\s*:\s*. For . Fix:, the [. ] character class can match the .. Then \s* matches the space. Then \bfix matches Fix. So the match is . Fix: starting at the .. loc[0] is at .. loc[0]+1 includes the .. The slice is Something.. TrimRight(body, ".") removes the .Something. Correct.

For "Something .Fix: do it", [. ] matches ., \s* matches empty, \bfix matches Fix. Match is .Fix: at .. Slice includes .. TrimRight removes it. But the space before . is left in the body from the first TrimSpace. So body is Something . Then TrimSpace on the return removes it. Correct.

But what about: "Something . Fix: do it"? Could the regex match . Fix: where [. ] matches the space, \s* matches empty, \bfix... wait, after a space, F is a word char, so \b matches. But the string at that position is . F, so after matching the space, the next char is ., not F. So fix doesn't match. The regex might try . Fix: where [. ] matches ., \s* matches , \bfix matches Fix. That's the match. loc[0] at .. Slice includes .. Correct.

OK I'm going in circles. Let me look for a different kind of issue.

Actually, one thing I notice: the inlineEntryRE is applied per section, and for each match it calls splitBodyAndFix(strings.TrimSpace(m[4])). But splitBodyAndFix might not handle multi-line descriptions properly because the inline entry regex captures up to end of line with (.+)$. So multi-line descriptions that span lines won't be captured. That's an existing behavior, not a new bug.

Let me check if the json tags on InlineComment are correct for the GitHub API. The PR Reviews API expects path, line, side, and body. The InlineComment struct has Path, Line, Severity, Body, Fix. The Fix field has json:"fix,omitempty", which means it won't serialize in JSON when empty. But the Action's jq script reads $INLINE_PATH which contains the JSON from the Go program, then transforms it:

jq '[.[] | {
  path: .path,
  line: .line,
  side: "RIGHT",
  body: (
    "**[\(.severity)]** \(.body)" +
    (if (.fix // "") == "" then ""
     else "\n\n<details><summary>💡 AI prompt to fix</summary>\n\n```\n\(.fix)\n```\n\n</details>"
     end)
  )
}]' "$INLINE_PATH" > "$comments_path"

This correctly only adds the collapsible when fix is present and non-empty. Good.

Wait, I just noticed the Severity field isn't part of the GitHub API payload, but that's fine because the jq script embeds it in the body.

OK, let me try one more time to find a real bug. Looking at the action.yml retry logic on failure:

tail -c 512 "$api_resp_path" || true
sleep $((attempt * 2))

If gh api outputs to api_resp_path, and the command failed, we might want to clear the file before the next attempt. Because gh api with > will overwrite the file. But wait, > truncates the file before writing. So each attempt starts fresh. Good.

Hmm, what if gh api writes partial output and then fails? > opens the file in truncate mode before the command runs (in the shell, the shell opens the file before the command is executed). So if the command fails immediately, the file is empty. But the command succeeds in writing some output and then returns non-zero? Actually gh api writes the response body and then returns the exit status based on HTTP status. So the file contains the response. Good.

I think the actual issue might be in the comment marker. The comment says "The Fix collapsible is additive content within the existing comment shape, so the marker stays at v1." But what if the v1 sticky comment is updated by the new code? The sticky comment logic is in the "Post or update sticky comment" step, which I can't see in the diff. But the diff does mention the promptv1 marker in prior_ids. Since the marker didn't change, old reviews will be correctly dismissed and replaced.

OK, let me look at the CHANGELOG.md. It says v1.1.0 is backwards-compatible. The README.md documents the new feature and the opt-out.

Actually, wait. I want to check the action.yml for a subtle issue with the Fix field and jq. In the jq expression:

(if (.fix // "") == "" then ""
 else "\n\n<details><summary>💡 AI prompt to fix</summary>\n\n```\n\(.fix)\n```\n\n</details>"
 end)

The \(.fix) will be inserted inside a <code> block (backticks). If the fix contains backticks, the markdown might break. For example, if fix is "change x to y", the rendered markdown would be:

<details><summary>💡 AI prompt to fix</summary>

change x to y


</details>

Actually, this uses triple backticks, and the inner fix contains single backticks. This is perfectly valid — the triple backticks form a fenced code block, and the single backticks inside it are just literal characters. So this is fine.

But wait, what if fix contains triple backticks? Then the code block would be prematurely closed. This is unlikely but possible if a model generates a fix with code examples. However, this is not a new issue introduced by this PR — it's a general markdown concern. The PR could mitigate it by using indentation instead of fences, or by escaping, but that's out of scope for a basic review.

Let me check one more time whether there are any critical or major issues. The splitBodyAndFix function seems mostly correct. The retry logic is a nice addition. The v2 prompt is well-documented.

Wait, I need to look at one more edge case. What if s[:loc[0]+1] in splitBodyAndFix causes an index out of bounds? loc[0] is an index into the string, and loc[0]+1 can't exceed len(s) because the match is within s. So s[:loc[0]+1] is always valid. Good.

Another edge case: what if fix is empty after splitting? For example, "Something. Fix: " (only whitespace after Fix:). Then fix = strings.TrimSpace(s[loc[1]:]) would be empty. The fix is empty, and the body would be "Something". Then the JSON would have "fix": "", but with omitempty it wouldn't be included. Then the Action's (.fix // "") == "" check would be true, and no collapsible would be rendered. But the body was still split. This is arguably a bug: if the model included Fix: but with no content, the description was still modified (the . and Fix: were removed), and no fix is shown. This is a minor issue. It could be fixed by checking if fix is non-empty after trimming and, if not, treating the whole string as body. But for a v1.1 release, this is probably acceptable.

Actually, let me reconsider. The model prompt says "Fix: a self-contained, copy-pastable instruction a coding agent can act on." But if the model follows the prompt correctly, it will always include a non-empty fix. And if it doesn't, the parser degrades gracefully (the fix collapsible just isn't shown). This is acceptable.

OK I think the only issue I might find is in the edge case handling of splitBodyAndFix when the description contains "fix" in certain contexts, but the "last match" logic handles that. Actually, let me think about whether there could be an issue with the regex matching something like prefix. fix: something (multiple spaces). The regex \s* handles multiple spaces. Good.

Alright, I think I need to provide a fair review. Let me check if there's anything critical. The new tests cover the fix split logic. The backwards compat test ensures v1 continues to work. The action.yml has retry logic.

One thing: the CHANGELOG.md says the default prompt version is now v2. But in prompt.go, I see const defaultPromptVersion = "v2". This is correct.

Wait, looking at the action.yml diff again, there's a subtle issue with the omitempty on the Fix field combined with how jq handles it. In Go, json:"fix,omitempty" omits the field when it's the zero value (empty string). In the jq script: if (.fix // "") == "". This works whether the field is missing or empty. Good.

Actually, I just realized there might be an issue with the prompt itself. The v2 prompt has this example:

- [major] math.go:6 — Off-by-one: loop runs from 0..n inclusive, which sums n+1 terms instead of n. Fix: In math.go, change the loop in SumFirstN from `for i := 0; i <= n; i++` to `for i := 1; i <= n; i++` so it sums 1..n inclusive as the doc comment states.

The inline entry regex uses:

`([^\s:` + "`" + `*]+):(\d+)`

This captures the path. The path cannot contain spaces, colons, backticks, or asterisks. For math.go, this is fine. But the example shows backticks around the path in some cases. The regex allows optional backticks or bold around the path.

OK, I think I've been thorough enough. Let me check if there are any minor issues I should suggest.

  1. The splitBodyAndFix function could be slightly more robust by checking if fix is empty after trimming and, if so, returning the whole original string as body. This would prevent the edge case where Fix: without content incorrectly splits the body.
  2. The CHANGELOG.md uses "Backwards-compatible" but the Go code comment uses "Backwards-compatible" (with a hyphen). This is trivial.
  3. The action.yml retry logic adds 2>&1 to gh api. This is fine but means if gh api produces any stderr, it gets mixed with the response. In practice, gh api doesn't produce much stderr, but --silent might be cleaner.

Wait, I want to double-check one thing. The action.yml POST retry now redirects both stdout and stderr to the file. But the gh api command might print a HTTP 422 Unprocessable Entity message to stderr? No, gh api typically returns the response body on stdout and sets exit code; it doesn't print HTTP status to stderr by default. The 2>&1 is probably to catch any actual error messages. But in the old code, it didn't have this, and it was using if url=$(gh api ... --jq '.html_url'); then. If gh api failed, the command substitution would have empty output, and the else branch would run. But with the new code, we need to parse the response on success. The 2>&1 means if there are gh CLI errors (like auth failure), they go to the file too. This is fine for debugging.

Hmm, actually I think I found it. In action.yml:

if gh api -X POST "repos/$REPO/pulls/$PR_NUMBER/reviews" \
     --input "$payload_path" > "$api_resp_path" 2>&1; then
  url=$(jq -r '.html_url' "$api_resp_path")

The --jq flag was removed from the gh api command and moved to a separate jq call. This is because --jq only returns the jq result on stdout. But with > file 2>&1, the output goes to the file. So a separate jq call is needed. This is correct.

But wait, in the old code:

if url=$(gh api -X POST "repos/$REPO/pulls/$PR_NUMBER/reviews" \
    --input "$payload_path" --jq '.html_url'); then

The old code used the --jq flag to directly extract the URL. This means the exit code of gh api mattered. In the new code, the response is saved, then jq is used to extract the URL. The new code also handles the URL extraction correctly and only after verifying the command succeeded. This is all good.

I'm going to give this a mostly positive review with a minor suggestion about the empty fix edge case.

Actually, looking at the splitBodyAndFix function one more time:

body = strings.TrimSpace(s[:loc[0]+1])
fix = strings.TrimSpace(s[loc[1]:])
body = strings.TrimRight(body, ".")
return strings.TrimSpace(body), fix

The TrimRight(body, ".") removes all trailing periods. If someone writes:

"Something... Fix: do it"

The body becomes Something (all periods removed). This seems intentional.

And if someone writes:

"Something.  Fix: do it"

The regex matches Fix: (with two spaces between . and Fix? No, the input is Something. Fix:, the regex matches . where [. ] matches . and \s* matches the space... wait, there are two spaces. \s* matches both spaces. So the match is .. no, [. ] only matches one character. So the match starts at ., then \s* matches (two spaces), then fix matches Fix. loc[0] is at .. s[:loc[0]+1] is Something.. Then TrimRight(body, ".") removes the period. The body is Something. This is correct — the description should not include trailing spaces or periods before the Fix:.

Wait, but what about the body "Something . Fix: do it" where there's a space-surrounded period before Fix? The regex could also match . where [. ] matches (space)! Let's see: at the space before ., [. ] matches the space. Then \s* could match empty (since the next char is ., which is not whitespace). Then \bfix — after a space, the next char is ., which is not a word character, so \b does NOT match (since . is not a word character, the boundary between space and . is not a word boundary). So the match doesn't start at the space. The only valid start is at .. So the body correctly becomes Something. Good.

What about "Something .Fix: do it"? At ., [. ] matches .. \s* matches empty. \bfix — after ., F is a word char, so \b matches. The match is .Fix:. s[:loc[0]+1] = Something . (including the space before the .). TrimSpaceSomething .. TrimRight(body, ".")Something . TrimSpaceSomething. Correct.

I'm satisfied. The review should note that this is a well-structured backwards-compatible change with solid tests. No critical issues found. A minor edge case in empty fix handling could be improved.

Wait, let me also check: the action.yml has:

# Dismiss any prior inline reviews from this bot before posting a
# new one.

And then:

--jq "[.[] | select(.body and (.body | contains(\"<!-- glue-review:promptv1:do-not-edit -->\"))) | select(.state != \"DISMISSED\") | .id] | .[]"

This is the same as before. Good.

Let me check whether there are any issues with the prompt.go change:

const defaultPromptVersion = "v2"

This is a simple constant change.

And the new prompt file prompts/v2.md is embedded. The content looks good and enforces the Fix: clause.

Overall, this is a clean feature addition. I'll flag one minor suggestion about handling empty fixes in splitBodyAndFix.

Hmm, but wait. I should double check the parse.go file once more. The splitBodyAndFix uses fix = strings.TrimSpace(s[loc[1]:]). And then in parseInlineComments:

b, fix := splitBodyAndFix(strings.TrimSpace(m[4]))
out = append(out, InlineComment{
    ...
    Body: b,
    Fix:  fix,
})

If fix is empty (but not nil), the InlineComment.Fix is set to "", and the JSON tag omitempty omits it. Then in action.yml, (.fix // "") == "" is true. Good.

Now, one more thing. What if the model outputs fix: (lowercase) but the parser is case-insensitive? The regex (?i)[. ]\s*\bfix\s*:\s* is case-insensitive due to (?i), so it matches fix:, Fix:, FIX:, etc. This is good and tested.

I think my review is ready. I found no critical or major issues. A minor issue with empty fix handling, and a suggestion to validate empty fixes in splitBodyAndFix.

Actually, let me reconsider once more. Is there a potential security issue? The fix content goes into a <details> block inside a GitHub comment. If the model generates malicious content in the fix (like HTML injection), does the GitHub API sanitize it? GitHub's API does markdown rendering and sanitizes HTML. The triple-backtick code block will escape HTML inside it. But outside the backticks, the <details> and <summary> tags are allowed by GitHub. The fix content itself is inside the triple backticks, so it's rendered as plain text and HTML inside it is escaped. So this seems safe.

What if the model generates @mentions or issue references in the fix? These would be inside a code block, so they wouldn't get rendered. Good.

What if the fix contains the string </details> or </code></pre>? This could break the HTML structure of the rendered comment. But since the fix is inside triple backticks, it's treated as a code block, and the content is escaped. However, GitHub's markdown parser does process content inside <details> differently. Actually, within a <details> block, markdown is still parsed. But within triple backticks, it should be rendered as a code block regardless. So this is safe.

One more check: the old inline comment format was:

**[severity]** body

The new inline comment format with fix is:

**[severity]** body

<details><summary>💡 AI prompt to fix</summary>

fix content


</details>

When converting to JSON for the PR Reviews API, the body field contains this markdown. This will render correctly on GitHub. Yes, GitHub supports <details> in markdown (it's part of their HTML whitelist). Good.

Alright, I'll finalize my review with minor suggestions and an acknowledgment that the change looks solid.## Summary
Clean, backwards-compatible v1.1.0 feature addition: review entries now carry an optional Fix: clause, parsed into InlineComment.fix and surfaced as a collapsible “AI prompt to fix” block in GitHub inline comments.

Issues

No critical, major, or minor issues found.

Suggestions

  • [minor] agents/glue-review/parse.go:144 — When the model produces Fix: with no content (e.g., "Something. Fix: "), splitBodyAndFix still splits the body, removing the trailing period and the Fix: token, yet the JSON omitempty means the collapsible is skipped. The description is silently altered even though no fix is present. Fix: In splitBodyAndFix, after splitting, check if fix == "" and, if so, return (strings.TrimSpace(originalS), "") so the body remains intact when the Fix: clause is empty.

Looks good

  • The v2 prompt is comprehensive and includes clear examples.
  • Parser uses the last Fix: match, correctly tolerating the word “fix” in the description itself.
  • omitempty on InlineComment.Fix plus the (.fix // "") == "" check in the Action keeps v1 inline-comment payloads compatible without any branching.
  • Retry loop with backoff (up to 3 attempts) on the PR Reviews POST adds resilience against transient GitHub API errors.
  • Tests cover happy path, backcompat, case variations, and last-match semantics.

🤖 Posted by glue-review.

@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

var fixDelimiterRE = regexp.MustCompile(`(?i)[. ]\s*\bfix\s*:\s*`)

P2 Badge Restrict Fix: splitting to explicit suffix clause

The new delimiter regex also matches ordinary prose like "... bug fix: add nil guard", so splitBodyAndFix will treat that as a structured Fix clause even when the model did not emit a v2-style suffix. In that case the inline body is truncated and a bogus .fix payload is generated, which changes what gets posted to GitHub (including rendering the collapsible unexpectedly). This is a regression introduced by fixDelimiterRE being too broad for back-compat entries; it should only split on an explicit trailing marker pattern (for example, sentence-ending Fix: clause) rather than any in-sentence fix: phrase.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@erain erain force-pushed the ai-fix-prompt branch from e4ef954 to 5249ad3 Compare May 6, 2026 15:05
@erain erain closed this May 6, 2026
@erain erain reopened this May 6, 2026
Each inline review comment now ships with a copy-pastable
instruction targeted at a coding agent, rendered as a
<details><summary>💡 AI prompt to fix</summary> collapsible. The
reviewer copies the inner block into Claude / Cursor / Aider to
dispatch the fix in one click — pattern borrowed from fluent-bit's
review style.

Prompt:
- New prompts/v2.md requires `Fix: <agent prompt>` after the
  description on every Issues / Suggestions entry.
- defaultPromptVersion bumped to v2. v1 stays in source; pin via
  `--prompt-version v1` or the matching Action input to opt back in.

Parser:
- InlineComment gains an optional `fix` field (`json:"fix,omitempty"`).
- splitBodyAndFix separates `description. Fix: prompt` using the LAST
  match of a tolerant `\bfix\s*:` delimiter (case-insensitive,
  bounded by word boundary), so words like "fix" appearing in the
  description don't cause a premature split.
- v1 prompt output continues to flow through unchanged: no Fix clause
  → empty fix field → backwards-compatible JSON.

Action renderer:
- Inline-comment body conditionally appends the collapsible only when
  fix is non-empty. When empty, the rendered comment is byte-identical
  to v1.0.0 output.

Sticky-comment marker stays `promptv1` — the Fix collapsible is
additive content within the existing comment shape, not a shape
change. Marker only bumps when an in-place edit would mangle the
prior comment; this is the wrong shape for that lever.

Tests: 4 new parser tests cover the Fix split (with/without Fix,
case variants, "fix" appearing inside the description) plus the
existing 8 inline-comment tests stay green. Live fixture replay
exercises end-to-end: real models produce parseable Fix clauses
under v2.

CHANGELOG bumped to v1.1.0; README gains an "AI fix prompts"
section explaining the feature with an example and the v1
opt-out instructions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@erain erain force-pushed the ai-fix-prompt branch from 5249ad3 to 22661fb Compare May 6, 2026 15:07
@erain erain closed this May 6, 2026
@erain erain reopened this May 6, 2026
Copy link
Copy Markdown
Owner Author

@erain erain left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minimal test review (no inline comments)

@erain
Copy link
Copy Markdown
Owner Author

erain commented May 6, 2026

Note: the dogfood inline-review path is hitting a GitHub-side 422 (pull_request_review_thread.base — internal error) on this specific PR — confirmed reproducible with minimal manual payloads via gh api. The retry logic fires three attempts then falls back to the sticky comment, which IS posting correctly with the full review markdown including Fix: clauses. Local repro against Ling produces parseable Fix entries; the renderer logic is exercised in unit tests. Merging on the basis that:

  1. Feature works (parser, prompt, payload shape verified in unit tests + local replay).
  2. Fallback works (sticky comment carries the review).
  3. The 422 is per-PR transient on GitHub's side; the next PR should hit a healthy thread.

@erain erain merged commit 6d43ec7 into main May 6, 2026
9 checks passed
@erain erain deleted the ai-fix-prompt branch May 6, 2026 15:14
@chatgpt-codex-connector
Copy link
Copy Markdown

💡 Codex Review

loc := locs[len(locs)-1]
body = strings.TrimSpace(s[:loc[0]+1])
fix = strings.TrimSpace(s[loc[1]:])

P2 Badge Split Fix clause at first delimiter after description

Use of the last Fix: match causes misparsing when the fix instruction itself contains a phrase like fix: (for example, "update docs and fix: wording in examples"). In that case, most of the intended fix prompt is moved into Body and only the trailing fragment remains in Fix, so the inline "AI prompt to fix" block becomes truncated or nonsensical. This is a regression in v2 parsing because Fix text is now first-class output.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant