Skip to content
Closed
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
7 changes: 6 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: build test clean run help fmt vet check \
.PHONY: build test test-e2e-gitflic clean run help fmt vet check \
build-all dist sha256sum version-info \
build-linux-amd64 build-linux-arm64 build-darwin-amd64 build-darwin-arm64 \
build-windows-amd64 build-windows-arm64
Expand Down Expand Up @@ -32,6 +32,11 @@ build:
test:
$(GO) test -v -race -count=1 ./...

# E2E against a live GitFlic instance (opt-in; see internal/publish/gitflic/e2e_test.go).
# Requires GITFLIC_TOKEN; defaults target a local GitFlic CE on localhost:8080.
test-e2e-gitflic:
$(GO) test -tags gitflic_e2e -count=1 -v ./internal/publish/gitflic/

clean:
rm -rf $(DIST_DIR)

Expand Down
3 changes: 3 additions & 0 deletions cmd/opencodereview/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ func dispatch() error {
return runRules(args[1:])
case "viewer":
return runViewer(args[1:])
case "publish":
return runPublish(args[1:])
case "-h", "--help":
printTopLevelUsage()
return nil
Expand All @@ -74,6 +76,7 @@ Commands:
config Manage configuration settings
llm LLM utility commands
viewer Start the WebUI session viewer
publish Post review results to a code host (gitflic)
version Show version information

Examples:
Expand Down
156 changes: 156 additions & 0 deletions cmd/opencodereview/publish_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package main

import (
"context"
"fmt"
"os"

"github.com/open-code-review/open-code-review/internal/diff"
"github.com/open-code-review/open-code-review/internal/publish/gitflic"
)

// runPublish routes `ocr publish <target>` subcommands.
func runPublish(args []string) error {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" {
printPublishUsage()
return nil
}
switch args[0] {
case "gitflic":
return runPublishGitflic(args[1:])
default:
return fmt.Errorf("unknown publish target: %s\nRun 'ocr publish -h' for usage", args[0])
}
}

type publishGitflicOptions struct {
file string
owner string
project string
mr string
apiURL string
repoDir string
from string
to string
dryRun bool
showHelp bool
}

func parsePublishGitflicFlags(args []string) (*publishGitflicOptions, error) {
opts := &publishGitflicOptions{}
fs := newOcrFlagSet("publish gitflic")

defaultFrom := ""
if target := os.Getenv("CI_MERGE_REQUEST_TARGET_BRANCH_NAME"); target != "" {
defaultFrom = "origin/" + target
}

fs.StringVarP(&opts.file, "file", "f", "-", "Review result JSON from 'ocr review --format json' ('-' = stdin)")
fs.StringVar(&opts.owner, "owner", os.Getenv("CI_PROJECT_NAMESPACE"), "Project owner alias (default: $CI_PROJECT_NAMESPACE)")
fs.StringVar(&opts.project, "project", os.Getenv("CI_PROJECT_NAME"), "Project alias (default: $CI_PROJECT_NAME)")
fs.StringVar(&opts.mr, "mr", os.Getenv("CI_MERGE_REQUEST_LOCAL_ID"), "Merge request local id (default: $CI_MERGE_REQUEST_LOCAL_ID)")
fs.StringVar(&opts.apiURL, "api-url", os.Getenv("GITFLIC_API_URL"), "GitFlic REST API base URL (default: $GITFLIC_API_URL or "+gitflic.DefaultBaseURL+")")
fs.StringVar(&opts.repoDir, "repo", ".", "Git repository root")
fs.StringVar(&opts.from, "from", defaultFrom, "Base ref of the reviewed range (default: origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME)")
fs.StringVar(&opts.to, "to", os.Getenv("CI_COMMIT_SHA"), "Head ref of the reviewed range (default: $CI_COMMIT_SHA)")
fs.BoolVar(&opts.dryRun, "dry-run", false, "Print discussions instead of posting them")

if err := fs.Parse(args); err != nil {
return nil, err
}
opts.showHelp = fs.showHelp
return opts, nil
}

func runPublishGitflic(args []string) error {
opts, err := parsePublishGitflicFlags(args)
if err != nil {
return fmt.Errorf("parse flags: %w", err)
}
if opts.showHelp {
printPublishUsage()
return nil
}

for _, required := range []struct{ name, value string }{
{"--owner", opts.owner},
{"--project", opts.project},
{"--mr", opts.mr},
{"--from", opts.from},
{"--to", opts.to},
} {
if required.value == "" {
return fmt.Errorf("%s is required (not set via flag or CI environment)", required.name)
}
}

token := os.Getenv("GITFLIC_TOKEN")
if token == "" && !opts.dryRun {
return fmt.Errorf("GITFLIC_TOKEN environment variable is required")
}

result, err := gitflic.LoadReviewResult(opts.file)
if err != nil {
return err
}

repoDir, err := resolveRepoDir(opts.repoDir)
if err != nil {
return fmt.Errorf("resolve repo: %w", err)
}
if err := requireGitRepo(repoDir); err != nil {
return err
}

ctx := context.Background()
diffs, err := diff.NewProvider(repoDir, opts.from, opts.to, nil).GetDiff(ctx)
if err != nil {
// Without diffs inline positions cannot be computed; comments will
// still be posted via the fallback note.
fmt.Fprintf(os.Stderr, "Warning: cannot read diff %s..%s, posting all comments as fallback: %v\n", opts.from, opts.to, err)
diffs = nil
}

publisher := gitflic.NewPublisher(gitflic.NewClient(opts.apiURL, token))
stats, err := publisher.Publish(ctx, gitflic.Options{
Owner: opts.owner,
Project: opts.project,
MRID: opts.mr,
DryRun: opts.dryRun,
}, result, diffs)
if err != nil {
return err
}

fmt.Printf("Posted %d inline comment(s), %d via fallback note (%d total).\n",
stats.Inline, stats.Fallback, len(result.Comments))
return nil
}

func printPublishUsage() {
fmt.Println(`Post review results to a code host.

Usage:
ocr publish gitflic [flags]

Reads the JSON produced by 'ocr review --format json' and posts it onto a
GitFlic merge request: inline discussions where possible, plus a summary note.

Flags:
-f, --file Review result JSON file ('-' = stdin, default)
--owner Project owner alias (default: $CI_PROJECT_NAMESPACE)
--project Project alias (default: $CI_PROJECT_NAME)
--mr Merge request local id (default: $CI_MERGE_REQUEST_LOCAL_ID)
--api-url GitFlic REST API base URL (default: $GITFLIC_API_URL or https://api.gitflic.ru)
--repo Git repository root (default: current dir)
--from Base ref of the reviewed range (default: origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME)
--to Head ref of the reviewed range (default: $CI_COMMIT_SHA)
--dry-run Print discussions instead of posting them

Authentication:
GITFLIC_TOKEN GitFlic access token (required unless --dry-run)

Example (inside GitFlic CI merge request pipeline):
ocr review --from "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" --to "$CI_COMMIT_SHA" \
--format json --audience agent | ocr publish gitflic`)
}
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ This directory contains examples for integrating OpenCodeReview (OCR) into vario

- **[github_actions/](./github_actions/)** - GitHub Actions integration example
- **[gitlab_ci/](./gitlab_ci/)** - GitLab CI integration example
- **[gitflic_ci/](./gitflic_ci/)** - GitFlic CI integration example (uses the native `ocr publish gitflic` command)

Each subdirectory contains its own README with detailed setup instructions.
69 changes: 69 additions & 0 deletions examples/gitflic_ci/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# OpenCodeReview - GitFlic CI Demo

This demo shows how to integrate OpenCodeReview into a [GitFlic](https://gitflic.ru) CI/CD pipeline to automatically review Merge Requests and post the findings as MR discussions — inline on the changed lines where possible.

Unlike the GitHub Actions and GitLab CI examples, no embedded posting script is needed: the `ocr publish gitflic` command does the posting natively, including mapping each comment to the correct old/new line pair required by the GitFlic Discussions API.

## How It Works

```
MR Created/Updated → Merge Request Pipeline → ocr review → ocr publish gitflic → Discussions on MR
```

1. A Merge Request Pipeline triggers the `code-review` job
2. It installs OCR via npm in a `node:20` image
3. Runs `ocr review --from origin/<target> --to $CI_COMMIT_SHA --format json --audience agent`
4. Runs `ocr publish gitflic`, which reads the JSON and posts:
- **Inline discussions** on the changed lines (`POST .../discussions/create` with `newLine`/`oldLine`/`newPath`/`oldPath`)
- **A fallback note** collecting comments that could not be placed inline
- **A summary note** with the totals

The MR context (owner, project, MR id, branch refs) is picked up automatically from the predefined GitFlic CI variables (`CI_PROJECT_NAMESPACE`, `CI_PROJECT_NAME`, `CI_MERGE_REQUEST_LOCAL_ID`, `CI_MERGE_REQUEST_TARGET_BRANCH_NAME`, `CI_COMMIT_SHA`), so `ocr publish gitflic` needs no arguments in CI. Outside CI every value can be passed via flags — see `ocr publish -h`.

## Setup

### 1. Enable Merge Request Pipelines

Go to **Project Settings → CI/CD Settings** and enable **Merge Request Pipeline**. New merge requests will then trigger the pipeline automatically.

### 2. Copy the pipeline file

Copy `gitflic-ci.yaml` to your repository root (GitFlic expects this exact file name).

### 3. Configure CI/CD Variables

Go to **Settings → CI/CD → Variables** and add:

| Variable | Required | Description |
|----------|----------|-------------|
| `OCR_LLM_URL` | Yes | LLM API endpoint URL |
| `OCR_LLM_AUTH_TOKEN` | Yes | LLM API authentication token |
| `GITFLIC_TOKEN` | Yes | GitFlic access token used to post discussions |
| `OCR_LLM_MODEL` | No | Model name (e.g., `gpt-4o`) |
| `GITFLIC_API_URL` | No | REST API base URL for self-hosted GitFlic (default: `https://api.gitflic.ru`) |

> **Note:** GitFlic CI/CD does not accept variables with values shorter than 8 characters, so `use_anthropic` cannot be set as a CI variable. The pipeline sets it to `false`; to use Anthropic Claude models, edit `gitflic-ci.yaml` directly.

### 4. Create a GitFlic Access Token

Create a token in **User Settings → Access Tokens** (or a dedicated service account — its name becomes the bot name shown in discussions) and store it in the `GITFLIC_TOKEN` variable. The token owner must have access to the project sufficient for commenting on merge requests.

## 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. `ocr publish gitflic` computes the old-side position from the same merge-base diff the review ran on, and anchors added lines to the closest preceding old 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.

## Debugging

Test the posting step locally without touching the MR:

```bash
ocr review --from origin/main --to HEAD --format json > /tmp/r.json
ocr publish gitflic --file /tmp/r.json \
--owner <owner> --project <project> --mr <id> \
--from origin/main --to HEAD --dry-run
```

`--dry-run` prints every discussion (with the computed positions) instead of posting, and does not require `GITFLIC_TOKEN`.
64 changes: 64 additions & 0 deletions examples/gitflic_ci/gitflic-ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# OpenCodeReview - GitFlic CI Merge Request Auto-Review Demo
#
# Reviews Merge Requests with OpenCodeReview and posts the findings onto the
# MR as discussions (inline where possible) using `ocr publish gitflic`.
#
# Requirements:
# - "Merge Request Pipeline" enabled in Project Settings → CI/CD Settings
# - A runner able to run the node:20 image (or a shell runner with node 20+ and git)
#
# Required CI/CD Variables (Settings → CI/CD → Variables):
# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions)
# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API
# GITFLIC_TOKEN - GitFlic access token used to post MR discussions
#
# Optional CI/CD Variables:
# OCR_LLM_MODEL - Model name (e.g., gpt-4o)
# GITFLIC_API_URL - GitFlic REST API base URL; only needed for
# self-hosted instances (defaults to https://api.gitflic.ru)
#
# `ocr publish gitflic` picks up the MR context automatically from the
# predefined GitFlic CI variables: CI_PROJECT_NAMESPACE, CI_PROJECT_NAME,
# CI_MERGE_REQUEST_LOCAL_ID, CI_MERGE_REQUEST_TARGET_BRANCH_NAME, CI_COMMIT_SHA.

stages:
- review

code-review:
stage: review
image: node:20
script:
# Run only in merge request pipelines
- |
if [ -z "$CI_MERGE_REQUEST_LOCAL_ID" ]; then
echo "Not a merge request pipeline, skipping review."
exit 0
fi

# Install OpenCodeReview
- npm install -g @alibaba-group/open-code-review

# Configure OCR
- |
ocr config set llm.url $OCR_LLM_URL
ocr config set llm.auth_token $OCR_LLM_AUTH_TOKEN
ocr config set llm.model $OCR_LLM_MODEL
ocr config set llm.use_anthropic false
ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}'

# Make sure the target branch and full history are available for merge-base diff
- git fetch --unshallow 2>/dev/null || true
- git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"

# Run OCR review (CI_COMMIT_SHA as head supports forked MRs as well)
- |
ocr review \
--from "origin/${CI_MERGE_REQUEST_TARGET_BRANCH_NAME}" \
--to "${CI_COMMIT_SHA}" \
--format json \
--audience agent \
> /tmp/ocr-result.json || true
echo "OCR review completed."

# Post review comments onto the MR (inline discussions + summary note)
- ocr publish gitflic --file /tmp/ocr-result.json
Loading