diff --git a/Makefile b/Makefile index ae5df944..0568b17d 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -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) diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index d97d9c9d..80ca2583 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -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 @@ -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: diff --git a/cmd/opencodereview/publish_cmd.go b/cmd/opencodereview/publish_cmd.go new file mode 100644 index 00000000..ba758750 --- /dev/null +++ b/cmd/opencodereview/publish_cmd.go @@ -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 ` 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`) +} diff --git a/examples/README.md b/examples/README.md index 8a4caea1..9c837842 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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. \ No newline at end of file diff --git a/examples/gitflic_ci/README.md b/examples/gitflic_ci/README.md new file mode 100644 index 00000000..17af16f1 --- /dev/null +++ b/examples/gitflic_ci/README.md @@ -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/ --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 --project --mr \ + --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`. diff --git a/examples/gitflic_ci/gitflic-ci.yaml b/examples/gitflic_ci/gitflic-ci.yaml new file mode 100644 index 00000000..93ebde92 --- /dev/null +++ b/examples/gitflic_ci/gitflic-ci.yaml @@ -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 diff --git a/internal/publish/gitflic/client.go b/internal/publish/gitflic/client.go new file mode 100644 index 00000000..b29ad838 --- /dev/null +++ b/internal/publish/gitflic/client.go @@ -0,0 +1,84 @@ +// Package gitflic posts code review results onto GitFlic merge requests +// (gitflic.ru or a self-hosted instance) as discussions. +package gitflic + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// DefaultBaseURL is the GitFlic SaaS REST API endpoint. +const DefaultBaseURL = "https://api.gitflic.ru" + +// Client is a minimal GitFlic REST API client. +type Client struct { + baseURL string + token string + http *http.Client +} + +// NewClient creates a Client for the given API base URL and access token. +// An empty baseURL falls back to DefaultBaseURL. +func NewClient(baseURL, token string) *Client { + if baseURL == "" { + baseURL = DefaultBaseURL + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } +} + +// Discussion is the payload for POST .../discussions/create. +// A general comment needs only Message. An inline (code) comment requires +// all four of NewLine, OldLine, NewPath and OldPath: if any of them is +// missing, GitFlic silently creates a general comment instead. +// +// NewLine and OldLine are pointers so that an unset value is omitted from the +// payload (nil) rather than serialized as 0: with a plain int and omitempty a +// deliberate caller-set 0 would be silently dropped, turning an intended +// inline comment into a general one. +type Discussion struct { + Message string `json:"message"` + NewLine *int `json:"newLine,omitempty"` + OldLine *int `json:"oldLine,omitempty"` + NewPath string `json:"newPath,omitempty"` + OldPath string `json:"oldPath,omitempty"` +} + +// CreateDiscussion posts a discussion (general or inline) on a merge request. +func (c *Client) CreateDiscussion(ctx context.Context, owner, project, mrID string, d Discussion) error { + endpoint := fmt.Sprintf("%s/project/%s/%s/merge-request/%s/discussions/create", + c.baseURL, url.PathEscape(owner), url.PathEscape(project), url.PathEscape(mrID)) + + body, err := json.Marshal(d) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("gitflic API %s: %s", resp.Status, strings.TrimSpace(string(snippet))) + } + return nil +} diff --git a/internal/publish/gitflic/e2e_test.go b/internal/publish/gitflic/e2e_test.go new file mode 100644 index 00000000..e75a6c1b --- /dev/null +++ b/internal/publish/gitflic/e2e_test.go @@ -0,0 +1,328 @@ +//go:build gitflic_e2e + +package gitflic + +// End-to-end test against a live GitFlic instance (e.g. a local GitFlic CE +// in Docker). Excluded from regular `make test` by the gitflic_e2e build tag. +// +// Run: +// GITFLIC_TOKEN= go test -tags gitflic_e2e -count=1 -v ./internal/publish/gitflic/ +// +// Environment: +// GITFLIC_TOKEN access token (required; test skips when unset) +// GITFLIC_E2E_API_URL REST API base URL (default http://localhost:8080/rest-api) +// GITFLIC_E2E_GIT_URL web/git base URL (default http://localhost:8080) +// GITFLIC_E2E_GIT_USER git push login (default adminuser@admin.local) +// GITFLIC_E2E_GIT_PASSWORD git push password (default qwerty123) +// +// GitFlic CE does not accept REST tokens for git push, hence the separate +// login/password pair (defaults match the stock CE admin account). + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/open-code-review/open-code-review/internal/diff" + "github.com/open-code-review/open-code-review/internal/model" +) + +const ( + e2eOldMain = `package main + +import "fmt" + +func main() { + name := "world" + fmt.Println("hello", name) + fmt.Println("done") +} +` + e2eNewMain = `package main + +import "fmt" + +func main() { + name := "GitFlic" + greet(name) + fmt.Println("hello", name) + fmt.Println("done") +} +` + e2eUtil = `package main + +import "fmt" + +func greet(name string) { + fmt.Println("greetings,", name) +} +` +) + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +// e2eAPI is a tiny helper around the GitFlic REST API for test fixtures. +type e2eAPI struct { + t *testing.T + baseURL string + token string +} + +func (a *e2eAPI) request(method, path string, body any, out any) error { + var reqBody *strings.Reader = strings.NewReader("") + if body != nil { + data, err := json.Marshal(body) + if err != nil { + return err + } + reqBody = strings.NewReader(string(data)) + } + req, err := http.NewRequest(method, a.baseURL+path, reqBody) + if err != nil { + return err + } + req.Header.Set("Authorization", "token "+a.token) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%s %s: %s", method, path, resp.Status) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +func (a *e2eAPI) must(method, path string, body any, out any) { + a.t.Helper() + if err := a.request(method, path, body, out); err != nil { + a.t.Fatalf("%s %s: %v", method, path, err) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } +} + +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +// e2eDiscussion is the discussion shape we assert on. The _embedded list key +// differs between GitFlic cloud and CE versions, so the envelope is decoded +// generically. +type e2eDiscussion struct { + RootNote struct { + RawMessage string `json:"rawMessage"` + NewLine *int `json:"newLine"` + OldLine *int `json:"oldLine"` + NewPath string `json:"newPath"` + OldPath string `json:"oldPath"` + } `json:"rootNote"` +} + +func listDiscussions(t *testing.T, api *e2eAPI, owner, alias string, mrID int) []e2eDiscussion { + t.Helper() + var envelope struct { + Embedded map[string]json.RawMessage `json:"_embedded"` + } + api.must("GET", fmt.Sprintf("/project/%s/%s/merge-request/%d/discussions?size=50", owner, alias, mrID), nil, &envelope) + for _, raw := range envelope.Embedded { + var items []e2eDiscussion + if err := json.Unmarshal(raw, &items); err != nil { + t.Fatalf("decode discussions: %v", err) + } + return items + } + return nil +} + +func TestE2EPublishToGitFlic(t *testing.T) { + token := os.Getenv("GITFLIC_TOKEN") + if token == "" { + t.Skip("GITFLIC_TOKEN not set; skipping GitFlic e2e") + } + apiURL := envOr("GITFLIC_E2E_API_URL", "http://localhost:8080/rest-api") + gitURL := envOr("GITFLIC_E2E_GIT_URL", "http://localhost:8080") + gitUser := envOr("GITFLIC_E2E_GIT_USER", "adminuser@admin.local") + gitPassword := envOr("GITFLIC_E2E_GIT_PASSWORD", "qwerty123") + + api := &e2eAPI{t: t, baseURL: strings.TrimRight(apiURL, "/"), token: token} + + // 1. Resolve current user and create a throwaway project. + var me struct { + Username string `json:"username"` + } + api.must("GET", "/user/me", nil, &me) + owner := me.Username + + alias := "ocr-e2e-" + strconv.FormatInt(time.Now().UnixNano(), 36) + api.must("POST", "/project", map[string]any{ + "title": "OCR publish e2e", + "alias": alias, + "isPrivate": true, + "ownerAlias": owner, + "ownerAliasType": "USER", + }, nil) + t.Cleanup(func() { + var proj struct { + ID string `json:"id"` + } + if err := api.request("GET", "/project/"+owner+"/"+alias, nil, &proj); err == nil && proj.ID != "" { + _ = api.request("DELETE", "/project/"+proj.ID+"/delete", nil, nil) + } + }) + + // 2. Seed the repo: master, then a feature branch with a modified line, + // an inserted line and a new file. + repoDir := t.TempDir() + pushTarget, err := url.Parse(gitURL) + if err != nil { + t.Fatalf("parse GITFLIC_E2E_GIT_URL: %v", err) + } + pushTarget.User = url.UserPassword(gitUser, gitPassword) + pushTarget.Path = fmt.Sprintf("/project/%s/%s.git", owner, alias) + + runGit(t, repoDir, "init", "-q", "-b", "master") + runGit(t, repoDir, "config", "user.email", "e2e@test.local") + runGit(t, repoDir, "config", "user.name", "OCR e2e") + writeFile(t, repoDir, "main.go", e2eOldMain) + runGit(t, repoDir, "add", "-A") + runGit(t, repoDir, "commit", "-qm", "initial") + runGit(t, repoDir, "remote", "add", "origin", pushTarget.String()) + runGit(t, repoDir, "push", "-q", "-u", "origin", "master:master") + + runGit(t, repoDir, "checkout", "-qb", "feature") + writeFile(t, repoDir, "main.go", e2eNewMain) + writeFile(t, repoDir, "util.go", e2eUtil) + runGit(t, repoDir, "add", "-A") + runGit(t, repoDir, "commit", "-qm", "feature: add greet") + runGit(t, repoDir, "push", "-q", "-u", "origin", "feature:feature") + + // 3. Open a merge request feature -> master. + var proj struct { + ID string `json:"id"` + } + api.must("GET", "/project/"+owner+"/"+alias, nil, &proj) + var mr struct { + LocalID int `json:"localId"` + } + api.must("POST", "/project/"+owner+"/"+alias+"/merge-request", map[string]any{ + "title": "OCR e2e MR", + "description": "merge request created by the gitflic_e2e test", + "sourceProject": map[string]string{"id": proj.ID}, + "targetProject": map[string]string{"id": proj.ID}, + "sourceBranch": map[string]string{"id": "feature"}, + "targetBranch": map[string]string{"id": "master"}, + }, &mr) + if mr.LocalID == 0 { + t.Fatal("merge request was not created") + } + + // 4. Publish a synthetic review result. + result := &ReviewResult{ + Comments: []model.LlmComment{ + {Path: "main.go", Content: "e2e: comment on a modified line", StartLine: 6, EndLine: 6, + ExistingCode: `name := "GitFlic"`, SuggestionCode: "name := os.Args[1]"}, + {Path: "main.go", Content: "e2e: comment on an added line", StartLine: 7, EndLine: 7}, + {Path: "util.go", Content: "e2e: comment in a new file", StartLine: 6, EndLine: 6}, + {Path: "ghost.go", Content: "e2e: comment that must fall back", StartLine: 1, EndLine: 1}, + }, + } + ctx := context.Background() + diffs, err := diff.NewProvider(repoDir, "master", "feature", nil).GetDiff(ctx) + if err != nil { + t.Fatalf("GetDiff: %v", err) + } + + publisher := NewPublisher(NewClient(apiURL, token)) + stats, err := publisher.Publish(ctx, Options{ + Owner: owner, + Project: alias, + MRID: strconv.Itoa(mr.LocalID), + }, result, diffs) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if stats.Inline != 3 || stats.Fallback != 1 { + t.Fatalf("stats = %+v, want 3 inline / 1 fallback", stats) + } + + // 5. Verify what GitFlic actually stored. + discussions := listDiscussions(t, api, owner, alias, mr.LocalID) + if len(discussions) != 5 { + t.Fatalf("expected 5 discussions (3 inline + fallback + summary), got %d", len(discussions)) + } + + type position struct { + newLine, oldLine int + oldPath string + } + inline := map[string]position{} + var general []string + for _, d := range discussions { + n := d.RootNote + if n.NewPath == "" { + general = append(general, n.RawMessage) + continue + } + if n.NewLine == nil || n.OldLine == nil { + t.Errorf("inline discussion on %s lacks line info", n.NewPath) + continue + } + inline[fmt.Sprintf("%s:%d", n.NewPath, *n.NewLine)] = position{*n.NewLine, *n.OldLine, n.OldPath} + } + + expectations := map[string]position{ + "main.go:6": {6, 6, "main.go"}, // modified line maps to its old position + "main.go:7": {7, 6, "main.go"}, // added line anchors to the preceding old line + "util.go:6": {6, 1, "util.go"}, // new file anchors to its own path, line 1 + } + for key, want := range expectations { + got, ok := inline[key] + if !ok { + t.Errorf("missing inline discussion %s (have %v)", key, inline) + continue + } + if got != want { + t.Errorf("%s position = %+v, want %+v", key, got, want) + } + } + + if len(general) != 2 { + t.Fatalf("expected 2 general notes (fallback + summary), got %d", len(general)) + } + joined := strings.Join(general, "\n===\n") + if !strings.Contains(joined, "could not be posted inline") || !strings.Contains(joined, "ghost.go") { + t.Errorf("fallback note missing or incomplete:\n%s", joined) + } + if !strings.Contains(joined, "**4** issue(s)") { + t.Errorf("summary note missing or incomplete:\n%s", joined) + } +} diff --git a/internal/publish/gitflic/linemap.go b/internal/publish/gitflic/linemap.go new file mode 100644 index 00000000..b1b05547 --- /dev/null +++ b/internal/publish/gitflic/linemap.go @@ -0,0 +1,57 @@ +package gitflic + +import "github.com/open-code-review/open-code-review/internal/diff" + +// oldLineFor maps a line number in the new version of a file to the +// corresponding line in the old version, using the file's diff hunks. +// Lines added by the diff have no old counterpart, so they are anchored to +// the closest preceding old line — GitFlic only needs a plausible old-side +// position to render the code comment next to the insertion point. +// The returned value is always >= 1. +func oldLineFor(hunks []diff.Hunk, newLine int) int { + delta := 0 // cumulative (new - old) line count shift from preceding hunks + for _, h := range hunks { + if newLine < h.NewStart { + break + } + if newLine < h.NewStart+h.NewCount { + return oldLineInHunk(h, newLine) + } + delta += h.NewCount - h.OldCount + } + return clampLine(newLine - delta) +} + +// oldLineInHunk walks the hunk lines tracking both line counters until it +// reaches newLine. +func oldLineInHunk(h diff.Hunk, newLine int) int { + oldLn, newLn := h.OldStart, h.NewStart + lastOld := h.OldStart - 1 // last old line seen before the current position + for _, l := range h.Lines { + switch l.Type { + case diff.HunkContext: + if newLn == newLine { + return clampLine(oldLn) + } + lastOld = oldLn + oldLn++ + newLn++ + case diff.HunkDeleted: + lastOld = oldLn + oldLn++ + case diff.HunkAdded: + if newLn == newLine { + return clampLine(lastOld) + } + newLn++ + } + } + return clampLine(lastOld) +} + +func clampLine(n int) int { + if n < 1 { + return 1 + } + return n +} diff --git a/internal/publish/gitflic/linemap_test.go b/internal/publish/gitflic/linemap_test.go new file mode 100644 index 00000000..814a7ed9 --- /dev/null +++ b/internal/publish/gitflic/linemap_test.go @@ -0,0 +1,104 @@ +package gitflic + +import ( + "testing" + + "github.com/open-code-review/open-code-review/internal/diff" +) + +// sampleDiff: old file lines 1..10; line 3 modified, a line inserted after +// old line 5, old line 8 deleted. +const sampleDiff = `diff --git a/main.go b/main.go +--- a/main.go ++++ b/main.go +@@ -1,10 +1,10 @@ + line1 + line2 +-line3 old ++line3 new + line4 + line5 ++inserted after5 + line6 + line7 +-line8 + line9 + line10 +` + +func TestOldLineFor(t *testing.T) { + hunks := diff.ParseHunks(sampleDiff) + if len(hunks) != 1 { + t.Fatalf("expected 1 hunk, got %d", len(hunks)) + } + + cases := []struct { + name string + newLine int + want int + }{ + {"context before changes", 1, 1}, + {"modified line maps to deleted position anchor", 3, 3}, + {"context after modification", 4, 4}, + {"added line anchors to preceding old line", 6, 5}, + {"context shifted by insertion", 7, 6}, + {"context after deletion", 9, 9}, + {"last context line", 10, 10}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := oldLineFor(hunks, tc.newLine); got != tc.want { + t.Errorf("oldLineFor(%d) = %d, want %d", tc.newLine, got, tc.want) + } + }) + } +} + +func TestOldLineForOutsideHunks(t *testing.T) { + hunks := diff.ParseHunks(sampleDiff) + + // Lines after the hunk shift by the cumulative delta (here: 0, since + // one line was added and one deleted). + if got := oldLineFor(hunks, 42); got != 42 { + t.Errorf("oldLineFor(42) = %d, want 42", got) + } +} + +func TestOldLineForMultipleHunks(t *testing.T) { + const multiHunk = `@@ -1,2 +1,4 @@ + line1 ++added2 ++added3 + line2 +@@ -10,3 +12,3 @@ + line10 +-line11 old ++line11 new + line12 +` + hunks := diff.ParseHunks(multiHunk) + if len(hunks) != 2 { + t.Fatalf("expected 2 hunks, got %d", len(hunks)) + } + + // Between hunks: new line 8 = old line 6 (two lines added by hunk 1). + if got := oldLineFor(hunks, 8); got != 6 { + t.Errorf("oldLineFor(8) = %d, want 6", got) + } + // Inside second hunk: modified new line 13 anchors to old line 11. + if got := oldLineFor(hunks, 13); got != 11 { + t.Errorf("oldLineFor(13) = %d, want 11", got) + } +} + +func TestOldLineForPureAdditionAtTop(t *testing.T) { + const topAddition = `@@ -0,0 +1,2 @@ ++first ++second +` + hunks := diff.ParseHunks(topAddition) + // No preceding old line exists; result is clamped to 1. + if got := oldLineFor(hunks, 1); got != 1 { + t.Errorf("oldLineFor(1) = %d, want 1", got) + } +} diff --git a/internal/publish/gitflic/publisher.go b/internal/publish/gitflic/publisher.go new file mode 100644 index 00000000..bd2a7f29 --- /dev/null +++ b/internal/publish/gitflic/publisher.go @@ -0,0 +1,206 @@ +package gitflic + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "strings" + + "github.com/open-code-review/open-code-review/internal/diff" + "github.com/open-code-review/open-code-review/internal/model" +) + +// ReviewResult mirrors the JSON emitted by `ocr review --format json`. +type ReviewResult struct { + Status string `json:"status"` + Message string `json:"message"` + Comments []model.LlmComment `json:"comments"` + Warnings []ReviewWarning `json:"warnings"` +} + +// ReviewWarning mirrors agent.AgentWarning in the review JSON output. +type ReviewWarning struct { + File string `json:"file"` + Message string `json:"message"` + Type string `json:"type"` +} + +// LoadReviewResult reads a review JSON produced by `ocr review --format json`. +// Path "-" reads from stdin. +func LoadReviewResult(path string) (*ReviewResult, error) { + var data []byte + var err error + if path == "-" { + data, err = io.ReadAll(os.Stdin) + } else { + data, err = os.ReadFile(path) + } + if err != nil { + return nil, fmt.Errorf("read review result: %w", err) + } + var result ReviewResult + if err := json.Unmarshal(data, &result); err != nil { + return nil, fmt.Errorf("parse review result: %w", err) + } + return &result, nil +} + +// Options configures Publish. +type Options struct { + Owner string // project owner alias + Project string // project alias + MRID string // merge request local id + DryRun bool // print discussions instead of posting them +} + +// Stats summarizes a Publish run. +type Stats struct { + Inline int // comments posted as inline discussions + Fallback int // comments folded into the fallback note +} + +// Publisher posts review comments onto a GitFlic merge request. +type Publisher struct { + client *Client + logf func(format string, args ...any) +} + +// NewPublisher wraps a Client. Progress and errors are logged to stderr. +func NewPublisher(client *Client) *Publisher { + return &Publisher{ + client: client, + logf: func(format string, args ...any) { + fmt.Fprintf(os.Stderr, format+"\n", args...) + }, + } +} + +// Publish posts the review result onto the merge request: an inline +// discussion per comment that maps onto the diff, a single fallback note +// collecting the comments that do not, and a final summary note. +// diffs must cover the same range the review was run on, so that comment +// line numbers (new file side) align with the hunks. +func (p *Publisher) Publish(ctx context.Context, opts Options, result *ReviewResult, diffs []model.Diff) (Stats, error) { + var stats Stats + + if len(result.Comments) == 0 { + message := result.Message + if message == "" { + message = "No comments generated. Looks good to me." + } + return stats, p.post(ctx, opts, Discussion{Message: "✅ **OpenCodeReview**: " + message}) + } + + byPath := make(map[string]model.Diff, len(diffs)) + for _, d := range diffs { + byPath[d.NewPath] = d + } + + // Parse each file's diff hunks at most once, even with several comments. + hunksByPath := make(map[string][]diff.Hunk) + + var failed []model.LlmComment + for _, c := range result.Comments { + d, ok := byPath[c.Path] + if !ok { + p.logf("no diff for %s; folding comment into the summary note", c.Path) + failed = append(failed, c) + continue + } + if d.IsBinary || d.IsDeleted || c.EndLine <= 0 { + failed = append(failed, c) + continue + } + + oldPath := d.OldPath + oldLine := 1 + if d.IsNew || oldPath == "" || oldPath == "/dev/null" { + // GitFlic has no old side for a new file; anchor to the new path. + oldPath = d.NewPath + } else { + hunks, cached := hunksByPath[c.Path] + if !cached { + hunks = diff.ParseHunks(d.Diff) + hunksByPath[c.Path] = hunks + } + oldLine = oldLineFor(hunks, c.EndLine) + } + + disc := Discussion{ + Message: formatComment(c), + NewLine: intPtr(c.EndLine), + OldLine: intPtr(oldLine), + NewPath: c.Path, + OldPath: oldPath, + } + if err := p.post(ctx, opts, disc); err != nil { + p.logf("inline comment failed for %s:%d: %v", c.Path, c.EndLine, err) + failed = append(failed, c) + continue + } + stats.Inline++ + } + stats.Fallback = len(failed) + + if len(failed) > 0 { + var sb strings.Builder + sb.WriteString("🔍 **OpenCodeReview** found issues that could not be posted inline:\n\n---\n\n") + for _, c := range failed { + sb.WriteString(formatCommentFallback(c)) + sb.WriteString("\n\n---\n\n") + } + if err := p.post(ctx, opts, Discussion{Message: sb.String()}); err != nil { + return stats, fmt.Errorf("post fallback note: %w", err) + } + } + + summary := fmt.Sprintf("🔍 **OpenCodeReview** found **%d** issue(s) in this MR.", len(result.Comments)) + summary += fmt.Sprintf("\n- ✅ %d posted as inline comment(s)", stats.Inline) + summary += fmt.Sprintf("\n- 📝 %d posted as summary (could not be placed inline)", stats.Fallback) + if len(result.Warnings) > 0 { + summary += fmt.Sprintf("\n\n⚠️ %d warning(s) occurred during review.", len(result.Warnings)) + } + if err := p.post(ctx, opts, Discussion{Message: summary}); err != nil { + return stats, fmt.Errorf("post summary note: %w", err) + } + return stats, nil +} + +func (p *Publisher) post(ctx context.Context, opts Options, d Discussion) error { + if opts.DryRun { + position := "general" + if d.NewPath != "" && d.NewLine != nil && d.OldLine != nil { + position = fmt.Sprintf("%s:%d (old %s:%d)", d.NewPath, *d.NewLine, d.OldPath, *d.OldLine) + } + fmt.Printf("--- dry-run discussion [%s] ---\n%s\n\n", position, d.Message) + return nil + } + return p.client.CreateDiscussion(ctx, opts.Owner, opts.Project, opts.MRID, d) +} + +// intPtr returns a pointer to i, for Discussion's nullable line fields. +func intPtr(i int) *int { return &i } + +// formatComment renders an inline discussion body. +func formatComment(c model.LlmComment) string { + body := c.Content + if c.SuggestionCode != "" && c.ExistingCode != "" { + body += "\n\n**Suggestion:**\n```\n" + c.SuggestionCode + "\n```" + } + return body +} + +// formatCommentFallback renders a comment for the fallback (non-inline) note. +func formatCommentFallback(c model.LlmComment) string { + md := fmt.Sprintf("### 📄 `%s`", c.Path) + if c.StartLine > 0 && c.EndLine > 0 { + md += fmt.Sprintf(" (L%d-L%d)", c.StartLine, c.EndLine) + } + md += "\n\n" + c.Content + if c.SuggestionCode != "" && c.ExistingCode != "" { + md += "\n\n**Before:**\n```\n" + c.ExistingCode + "\n```\n\n**After:**\n```\n" + c.SuggestionCode + "\n```" + } + return md +} diff --git a/internal/publish/gitflic/publisher_test.go b/internal/publish/gitflic/publisher_test.go new file mode 100644 index 00000000..83d1c0f8 --- /dev/null +++ b/internal/publish/gitflic/publisher_test.go @@ -0,0 +1,228 @@ +package gitflic + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/open-code-review/open-code-review/internal/model" +) + +type recordedRequest struct { + path string + auth string + body Discussion +} + +func newRecordingServer(t *testing.T, requests *[]recordedRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var d Discussion + if err := json.NewDecoder(r.Body).Decode(&d); err != nil { + t.Errorf("decode request body: %v", err) + } + *requests = append(*requests, recordedRequest{ + path: r.URL.Path, + auth: r.Header.Get("Authorization"), + body: d, + }) + w.WriteHeader(http.StatusOK) + })) +} + +func silentPublisher(client *Client) *Publisher { + p := NewPublisher(client) + p.logf = func(string, ...any) {} + return p +} + +// val dereferences a *int line field, returning -1 for nil so assertions can +// report a stable value instead of panicking. +func val(p *int) int { + if p == nil { + return -1 + } + return *p +} + +func TestPublishInlineAndSummary(t *testing.T) { + var requests []recordedRequest + srv := newRecordingServer(t, &requests) + defer srv.Close() + + result := &ReviewResult{ + Comments: []model.LlmComment{ + {Path: "main.go", Content: "possible nil dereference", StartLine: 6, EndLine: 6, + ExistingCode: "x := y.Field", SuggestionCode: "if y != nil { x = y.Field }"}, + }, + } + diffs := []model.Diff{ + {OldPath: "main.go", NewPath: "main.go", Diff: sampleDiff}, + } + + publisher := silentPublisher(NewClient(srv.URL, "secret-token")) + stats, err := publisher.Publish(context.Background(), Options{Owner: "fedor", Project: "demo", MRID: "7"}, result, diffs) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if stats.Inline != 1 || stats.Fallback != 0 { + t.Fatalf("stats = %+v, want 1 inline / 0 fallback", stats) + } + if len(requests) != 2 { + t.Fatalf("expected 2 requests (inline + summary), got %d", len(requests)) + } + + inline := requests[0] + wantPath := "/project/fedor/demo/merge-request/7/discussions/create" + if inline.path != wantPath { + t.Errorf("path = %q, want %q", inline.path, wantPath) + } + if inline.auth != "token secret-token" { + t.Errorf("auth header = %q, want %q", inline.auth, "token secret-token") + } + if val(inline.body.NewLine) != 6 || val(inline.body.OldLine) != 5 { + t.Errorf("position = new %d / old %d, want new 6 / old 5", val(inline.body.NewLine), val(inline.body.OldLine)) + } + if inline.body.NewPath != "main.go" || inline.body.OldPath != "main.go" { + t.Errorf("paths = %q / %q, want main.go / main.go", inline.body.NewPath, inline.body.OldPath) + } + if !strings.Contains(inline.body.Message, "possible nil dereference") || + !strings.Contains(inline.body.Message, "**Suggestion:**") { + t.Errorf("inline message missing content or suggestion: %q", inline.body.Message) + } + + summary := requests[1] + if summary.body.NewPath != "" { + t.Errorf("summary should be a general comment, got path %q", summary.body.NewPath) + } + if !strings.Contains(summary.body.Message, "**1** issue(s)") { + t.Errorf("summary message = %q", summary.body.Message) + } +} + +func TestPublishFallbackForUnmappedComment(t *testing.T) { + var requests []recordedRequest + srv := newRecordingServer(t, &requests) + defer srv.Close() + + result := &ReviewResult{ + Comments: []model.LlmComment{ + {Path: "missing.go", Content: "issue in file absent from diff", StartLine: 1, EndLine: 1}, + }, + Warnings: []ReviewWarning{{File: "a.go", Message: "skipped", Type: "subtask_error"}}, + } + + publisher := silentPublisher(NewClient(srv.URL, "tok")) + stats, err := publisher.Publish(context.Background(), Options{Owner: "o", Project: "p", MRID: "1"}, result, nil) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if stats.Inline != 0 || stats.Fallback != 1 { + t.Fatalf("stats = %+v, want 0 inline / 1 fallback", stats) + } + if len(requests) != 2 { + t.Fatalf("expected 2 requests (fallback + summary), got %d", len(requests)) + } + if !strings.Contains(requests[0].body.Message, "could not be posted inline") || + !strings.Contains(requests[0].body.Message, "`missing.go`") { + t.Errorf("fallback message = %q", requests[0].body.Message) + } + if !strings.Contains(requests[1].body.Message, "1 warning(s)") { + t.Errorf("summary message = %q", requests[1].body.Message) + } +} + +func TestPublishInlineErrorFallsBack(t *testing.T) { + var requests []recordedRequest + calls := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var d Discussion + _ = json.NewDecoder(r.Body).Decode(&d) + // Reject the inline attempt (first call), accept the rest. + if calls == 1 { + w.WriteHeader(http.StatusForbidden) + return + } + requests = append(requests, recordedRequest{path: r.URL.Path, body: d}) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + result := &ReviewResult{ + Comments: []model.LlmComment{ + {Path: "main.go", Content: "finding", StartLine: 1, EndLine: 1}, + }, + } + diffs := []model.Diff{{OldPath: "main.go", NewPath: "main.go", Diff: sampleDiff}} + + publisher := silentPublisher(NewClient(srv.URL, "tok")) + stats, err := publisher.Publish(context.Background(), Options{Owner: "o", Project: "p", MRID: "1"}, result, diffs) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if stats.Inline != 0 || stats.Fallback != 1 { + t.Fatalf("stats = %+v, want 0 inline / 1 fallback", stats) + } + if len(requests) != 2 { + t.Fatalf("expected fallback + summary after inline failure, got %d", len(requests)) + } +} + +func TestPublishNoComments(t *testing.T) { + var requests []recordedRequest + srv := newRecordingServer(t, &requests) + defer srv.Close() + + result := &ReviewResult{Message: "No comments generated. Looks good to me."} + + publisher := silentPublisher(NewClient(srv.URL, "tok")) + stats, err := publisher.Publish(context.Background(), Options{Owner: "o", Project: "p", MRID: "1"}, result, nil) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if stats.Inline != 0 || stats.Fallback != 0 { + t.Fatalf("stats = %+v, want zero", stats) + } + if len(requests) != 1 || !strings.Contains(requests[0].body.Message, "Looks good to me") { + t.Fatalf("expected single LGTM note, got %+v", requests) + } +} + +func TestPublishNewFileAnchorsToNewPath(t *testing.T) { + var requests []recordedRequest + srv := newRecordingServer(t, &requests) + defer srv.Close() + + const newFileDiff = `diff --git a/added.go b/added.go +--- /dev/null ++++ b/added.go +@@ -0,0 +1,3 @@ ++package main ++ ++func main() {} +` + result := &ReviewResult{ + Comments: []model.LlmComment{ + {Path: "added.go", Content: "empty main", StartLine: 3, EndLine: 3}, + }, + } + diffs := []model.Diff{{OldPath: "/dev/null", NewPath: "added.go", IsNew: true, Diff: newFileDiff}} + + publisher := silentPublisher(NewClient(srv.URL, "tok")) + stats, err := publisher.Publish(context.Background(), Options{Owner: "o", Project: "p", MRID: "1"}, result, diffs) + if err != nil { + t.Fatalf("Publish: %v", err) + } + if stats.Inline != 1 { + t.Fatalf("stats = %+v, want 1 inline", stats) + } + inline := requests[0].body + if inline.OldPath != "added.go" || val(inline.OldLine) != 1 || val(inline.NewLine) != 3 { + t.Errorf("new-file position = old %s:%d new line %d, want added.go:1 / 3", + inline.OldPath, val(inline.OldLine), val(inline.NewLine)) + } +}