Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion cmd/opencodereview/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ func renderComment(comment model.LlmComment, out io.Writer) {
return
}

fmt.Fprintf(out, "\n%s\n", colorf("\033[2m", "─── %s:%d-%d ───", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine))
location := fmt.Sprintf("%s:%d-%d", sanitizeTerminal(comment.Path), comment.StartLine, comment.EndLine)
if comment.Side == model.CommentSideLeft {
location += " [LEFT]"
}
fmt.Fprintf(out, "\n%s\n", colorf("\033[2m", "─── %s ───", location))

if comment.Content != "" {
badge := buildBadge(comment)
Expand Down
15 changes: 15 additions & 0 deletions cmd/opencodereview/output_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,21 @@ func TestRenderComment_ContentOnly(t *testing.T) {
}
}

func TestRenderComment_LeftSideShowsDiffSide(t *testing.T) {
got := captureStdout(t, func() {
renderComment(model.LlmComment{
Path: "deleted.go",
StartLine: 5,
EndLine: 5,
Side: model.CommentSideLeft,
Content: "review the removed call",
}, os.Stdout)
})
if !strings.Contains(got, "deleted.go:5-5 [LEFT]") {
t.Errorf("expected old-side marker, got %q", got)
}
}

func TestRenderComment_WithDiff(t *testing.T) {
got := captureStdout(t, func() {
renderComment(model.LlmComment{
Expand Down
17 changes: 11 additions & 6 deletions cmd/opencodereview/sarif.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type sarifResult struct {

type sarifLocation struct {
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
Properties map[string]string `json:"properties,omitempty"`
}

type sarifPhysicalLocation struct {
Expand Down Expand Up @@ -214,17 +215,18 @@ func sarifResults(comments []model.LlmComment) []sarifResult {
// mapping follows FR-3 in the requirements spec:
// - Path → locations[].physicalLocation.artifactLocation.uri
// - StartLine/EndLine (valid range) → locations[].physicalLocation.region
// - Side → locations[].properties.side (LEFT or RIGHT)
// - Content → message.text
// - Category (or "other") → ruleId
// - Severity → level
// - SuggestionCode + ExistingCode + valid region → fixes
// - Path + Category + ExistingCode → partialFingerprints (stable fingerprint)
//
// Fixes are only emitted when a valid region exists (StartLine > 0 &&
// EndLine >= StartLine), because replacement.deletedRegion is required by
// the SARIF schema and cannot be omitted. When the region is invalid (zero
// or inverted), the suggestion is still conveyed in message.text but no
// machine-readable fix is emitted.
// Fixes are only emitted for a valid RIGHT-side region (StartLine > 0 &&
// EndLine >= StartLine), because replacement.deletedRegion describes the
// current artifact and cannot safely represent code that only exists on the
// LEFT side. When the region is invalid or LEFT-sided, the suggestion is
// still conveyed in message.text but no machine-readable fix is emitted.
func sarifResultFromComment(c model.LlmComment) sarifResult {
category := c.Category
if category == "" {
Expand All @@ -246,6 +248,9 @@ func sarifResultFromComment(c model.LlmComment) sarifResult {
ArtifactLocation: sarifArtifactLocation{URI: c.Path},
},
}
if c.Side != "" {
loc.Properties = map[string]string{"side": c.Side}
}
if hasRegion {
loc.PhysicalLocation.Region = &sarifRegion{
StartLine: c.StartLine,
Expand All @@ -258,7 +263,7 @@ func sarifResultFromComment(c model.LlmComment) sarifResult {
// Fixes require: non-empty SuggestionCode, non-empty ExistingCode, non-empty
// Path, AND a valid region. The region is needed because deletedRegion is
// required by the SARIF schema — omitting it invalidates the entire document.
if c.SuggestionCode != "" && c.ExistingCode != "" && c.Path != "" && hasRegion {
if c.Side != model.CommentSideLeft && c.SuggestionCode != "" && c.ExistingCode != "" && c.Path != "" && hasRegion {
rep := sarifReplacement{
DeletedRegion: sarifRegion{
StartLine: c.StartLine,
Expand Down
29 changes: 29 additions & 0 deletions cmd/opencodereview/sarif_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,35 @@ func TestOutputSARIF_FullFieldMapping(t *testing.T) {
}
}

func TestOutputSARIF_LeftSideCarriesSideAndOmitsFix(t *testing.T) {
comment := model.LlmComment{
Path: "main.go",
Content: "The removed call was not replaced safely.",
SuggestionCode: "replacement()",
ExistingCode: "legacyCall()",
StartLine: 2,
EndLine: 2,
Side: model.CommentSideLeft,
}
out := captureStdout(t, func() {
if err := outputSARIF([]model.LlmComment{comment}, "v1", nil, nil, os.Stdout); err != nil {
t.Fatalf("outputSARIF: %v", err)
}
})
doc := mustUnmarshal(t, out)
result := mustGetResult(t, doc)

locations := result["locations"].([]any)
location := locations[0].(map[string]any)
properties := location["properties"].(map[string]any)
if properties["side"] != model.CommentSideLeft {
t.Fatalf("location side = %v, want %q", properties["side"], model.CommentSideLeft)
}
if _, ok := result["fixes"]; ok {
t.Fatal("deleted-side comment must not emit a current-file SARIF fix")
}
}

// --- AC-5 & AC-6: Severity → Level mapping ---

func TestSarifSeverityLevel(t *testing.T) {
Expand Down
10 changes: 9 additions & 1 deletion examples/gerrit_ci/post_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ def build_review_input(result):
line = c.get("end_line") or 0
if line > 0:
entry["line"] = line
# Gerrit CommentInput defaults to the revision (new) side. A LEFT
# coordinate belongs to the change's parent/base patchset.
if str(c.get("side", "")).upper() == "LEFT":
entry["side"] = "PARENT"
grouped.setdefault(path, []).append(entry)

if comments:
Expand Down Expand Up @@ -171,7 +175,11 @@ def fold_comments(review_input):
]
for path, entries in (review_input.get("comments") or {}).items():
for e in entries:
loc = "`%s:%d`" % (path, e["line"]) if "line" in e else "`%s`" % path
if "line" in e:
side_label = " (old file)" if e.get("side") == "PARENT" else ""
loc = "`%s:%d%s`" % (path, e["line"], side_label)
else:
loc = "`%s`" % path
parts.append("\n---\n\n%s\n\n%s" % (loc, e["message"]))
full = "\n".join(parts)
if len(full) > MAX_MESSAGE_LEN:
Expand Down
5 changes: 5 additions & 0 deletions examples/gerrit_ci/post_review_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ def assert_line(self, c, want_line):
def test_single_line_comment(self):
self.assert_line(comment(start_line=6, end_line=6), 6)

def test_left_side_uses_parent(self):
entry = entry_of(build([comment(side="LEFT")]))
self.assertEqual(entry["line"], 6)
self.assertEqual(entry["side"], "PARENT")

def test_multi_line_range(self):
self.assert_line(comment(start_line=3, end_line=6), 6)

Expand Down
2 changes: 1 addition & 1 deletion examples/gitflic_ci/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Create a token in **User Settings → Access Tokens** (or a dedicated service ac

## Notes & Limitations

- **Inline positioning** — GitFlic requires all four of `newLine`/`oldLine`/`newPath`/`oldPath` for a code comment; if any is missing it silently creates a general comment. `post_review.py` computes the old-side position from the same merge-base diff the review ran on (`git diff merge-base(from, to)..to`), and anchors added lines to the closest preceding old line.
- **Inline positioning** — GitFlic requires all four of `newLine`/`oldLine`/`newPath`/`oldPath` for a code comment; if any is missing it silently creates a general comment. `post_review.py` computes the old-side position from the same merge-base diff the review ran on (`git diff merge-base(from, to)..to`), and anchors added lines to the closest preceding old line. GitFlic has no side selector for LEFT/base coordinates, so those comments are placed in the fallback summary instead of being attached to a potentially incorrect new-side line.
- **Rate limit** — the GitFlic cloud API allows 500 requests/hour per token. One review posts `comments + 2` requests at most, which fits comfortably.
- **Self-hosted GitFlic** — set `GITFLIC_API_URL` to your instance's REST API base URL.
- **Re-reviews** — every push to the MR triggers a new pipeline and a new review. To skip already-reviewed MRs, check existing discussions for the `OpenCodeReview` marker before running the review step.
Expand Down
19 changes: 14 additions & 5 deletions examples/gitflic_ci/post_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
GitFlic's Discussions API needs an *old-side* line even for a comment on the new
side of the diff: an inline (code) discussion requires all four of
newLine/oldLine/newPath/oldPath, otherwise GitFlic silently records a plain
comment. `ocr review` only reports new-side positions, so this script computes
the old-side line itself by parsing the same merge-base diff the review ran on
(`git diff merge-base(from, to)..to`).
comment. For RIGHT-side comments, this script computes the old-side line itself
by parsing the same merge-base diff the review ran on (`git diff
merge-base(from, to)..to`). GitFlic has no side selector, so LEFT-side comments
are kept in the fallback summary instead of being attached to the wrong file.

Standard library only (json, urllib, subprocess) so it runs on the stock
node:20 / python image used by the pipeline.
Expand Down Expand Up @@ -167,7 +168,7 @@ def flush():


# --------------------------------------------------------------------------- #
# Line mapping (new file side -> old file side)
# Line mapping (RIGHT/new file side -> old file side)
# --------------------------------------------------------------------------- #


Expand Down Expand Up @@ -235,7 +236,8 @@ def format_comment_fallback(c):
start_line = c.get("start_line", 0)
end_line = c.get("end_line", 0)
if start_line and end_line:
md += " (L%d-L%d)" % (start_line, end_line)
side_label = " (old file)" if str(c.get("side", "")).upper() == "LEFT" else ""
md += " (L%d-L%d%s)" % (start_line, end_line, side_label)
md += "\n\n" + c.get("content", "")
suggestion = c.get("suggestion_code", "")
existing = c.get("existing_code", "")
Expand Down Expand Up @@ -269,6 +271,13 @@ def publish(result, diffs_by_path, post):
for c in comments:
path = c.get("path", "")
end_line = c.get("end_line", 0) or 0
# GitFlic's discussion API has no side selector. Posting a LEFT-side
# coordinate as newLine would silently annotate the wrong revision, so
# keep it in the summary fallback until the API can represent it.
if str(c.get("side", "")).upper() == "LEFT":
log("left-side comment for %s cannot be positioned by GitFlic; folding into summary" % path)
failed.append(c)
continue
fd = diffs_by_path.get(path)
if fd is None:
log("no diff for %s; folding comment into the summary note" % path)
Expand Down
11 changes: 11 additions & 0 deletions examples/gitflic_ci/post_review_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ def test_pure_addition_at_top(self):
self.assertEqual(pr.old_line_for(hunks, 1), 1)


class PublishSideTest(unittest.TestCase):
def test_left_side_falls_back_to_summary(self):
posted = []
result = {"comments": [{"path": "main.go", "content": "old", "start_line": 3, "end_line": 3, "side": "LEFT"}]}
stats = pr.publish(result, {"main.go": pr.FileDiff("main.go", "main.go")}, posted.append)
self.assertEqual(stats["inline"], 0)
self.assertEqual(stats["fallback"], 1)
self.assertNotIn("newLine", posted[0])
self.assertIn("old file", posted[0]["message"])


class ParseDiffTest(unittest.TestCase):
def test_modified_file(self):
fd = pr.parse_diff(SAMPLE_DIFF)[0]
Expand Down
Loading
Loading