# How Tuicr's PR Review Submission Pipeline Works: Inline Comment Mapping Explained

> Discover how Tuicr's PR review submission pipeline maps inline comments through three phases: pre-flight mapping, resolver UI, and payload construction, turning drafts into API calls.

- Repository: [Almog Gavra/tuicr](https://github.com/agavra/tuicr)
- Tags: internals
- Published: 2026-08-02

---

**Tuicr submits pull-request reviews through a three-phase pipeline—pre‑flight mapping, resolver UI for unmappable comments, and payload construction—that transforms local draft comments into GitHub or GitLab API calls.**

The `tuicr` terminal UI code review tool handles the tricky problem of turning your local, line‑anchored comments into properly positioned remote PR reviews. According to the agavra/tuicr source code, this process involves diff‑aware coordinate translation, edge‑case handling for binary files and deletions, and a fallback UI for comments that cannot be placed inline.

## The Three Phases of PR Review Submission

Tuicr's pipeline is implemented in **[`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs)** and orchestrated from **[`src/app.rs`](https://github.com/agavra/tuicr/blob/main/src/app.rs)**. The flow splits into three distinct stages that progressively refine user input into a forge‑ready payload.

### Phase 1: Pre‑flight—Mapping Draft Comments to Inline Positions

When you trigger `:submit*`, `App::start_submit` gathers every draft comment from the current `ReviewSession`. For each comment, it determines the **anchor type** (`CommentAnchor`)—file‑level, single‑line, or multi‑line range—then delegates to `map_comment`:

```rust
pub fn map_comment(
    comment: &Comment,
    anchor: CommentAnchor,
    file: &DiffFile,
    config: &ForgeConfig,
) -> MappedComment { … }

```

Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 1990‑2005

`map_comment` handles four distinct cases:

- **Binary or oversized files** — Immediately marked *unmappable* with `UnmappableReason::BinaryFile` or `TooLargeFile`. The GitHub API cannot receive inline comments on these files.

- **File‑level comments** — `CommentAnchor::FileLevel` attempts to locate the first valid line on the *new* side via `file.first_valid_line(LineSide::New)`. If the file is a pure deletion, it becomes `UnmappableReason::FileLevelNoAnchor`. Otherwise, an `InlineComment` is built with `side = GhSide::Right`.

  Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 1616‑1639

- **Single‑line comments** — `CommentAnchor::Line` searches the diff for the requested line on the requested side using `find_line_with_counterpart`. Missing lines yield `UnmappableReason::LineNotInDiff`. Found lines produce an `InlineComment` with `GhSide::Left` for deletions or `GhSide::Right` for additions.

  Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 1648‑1665

- **Range comments** — `CommentAnchor::Range` validates that the entire range lies on a single side via `range_endpoints_present`. If `comment.side` is `None` or endpoints are missing, the result is `MixedSideRange`. Valid ranges emit an `InlineComment` with `start_line`/`start_side` populated for multi‑line spans.

  Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 1700‑1710 and 1734‑1745

During mapping, `build_inline_body` optionally prepends **type prefixes** (`[ISSUE]`, `[NOTE]`, etc.) when `ForgeConfig::comment_type_prefix` is enabled, and adds "File‑level:" markers for file‑level comments.

Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 52‑70

The pre‑flight phase produces a `PreflightResult` containing:

| Field | Purpose |
|-------|---------|
| `event` | Review type: `Comment`, `Approve`, `RequestChanges`, or `Draft` |
| `mappable` | `Vec<InlineComment>` ready for direct submission |
| `unmappable` | `Vec<UnmappableItem>` requiring user resolution |
| `commit_id` | PR head SHA captured at this moment to prevent anchor drift |

Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 408‑416

### Phase 2: Resolver UI—Handling Unmappable Comments

Comments that cannot be mapped inline surface in a resolver interface. Each `UnmappableItem` displays its `UnmappableReason` via `human_label()`, and you choose between two actions:

- **MoveToSummary** — Places the comment into the review body under an "Unplaced comments" section
- **Omit** — Discards the comment for this submission

The default selection is `MoveToSummary`. Your choices populate `ResolverAction` structures, which convert to `MovedToSummaryItem`s for the final payload.

Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 28‑35 (`ResolverAction` enum)

### Phase 3: Payload Construction and Remote Submission

`build_review_body` assembles the review body from three components:

1. **Review‑level comments** — From `session.review_comments`
2. **Unplaced comments** — The `MovedToSummaryItem`s from phase 2
3. **Optional footer** — Reserved for future extensions

Components join with `\n\n` separators, respecting the same `comment_type_prefix` toggle used during mapping.

Source: [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs), lines 450‑492

The final payload structure sent to the forge contains:

```rust
{
    event: SubmitEvent::github_event(),  // "COMMENT", "APPROVE", etc.
    commit_id: "abc123...",              // Captured at pre‑flight
    comments: Vec<InlineComment>,        // From mappable list
    body: String,                        // From build_review_body
}

```

**GitHub submission** occurs in [`src/forge/github/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/github/submit.rs), where `InlineComment` serializes to GitHub's GraphQL API shape. The actual network call uses `gh api --input -`, passing JSON on **STDIN** to avoid command‑line length limits.

**GitLab submission** follows an identical pattern in [`src/forge/gitlab/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/gitlab/submit.rs), using `glab` instead of `gh`.

Source: [`src/forge/github/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/github/submit.rs) (see `run_with_stdin` usage)

## Key Mapping Algorithm: Coordinate Translation

The core challenge—translating your local line numbers to GitHub's diff coordinates—happens inside `map_comment` via helper functions:

- `find_line_with_counterpart` — Locates a line in the diff hunk structure, validating that the requested side (`LineSide::Old` or `LineSide::New`) actually exists in the displayed diff
- `range_endpoints_present` — Ensures multi‑line ranges don't cross the old/new boundary, which GitHub's API cannot represent

The `GhSide` enum (aliased to GitHub's `LEFT`/`RIGHT` terminology) bridges Tuicr's internal `LineSide` to the remote API:

| Tuicr `LineSide` | `GhSide` | GitHub meaning |
|-----------------|----------|----------------|
| `Old` | `Left` | Deletion (left side of split diff) |
| `New` | `Right` | Addition (right side of split diff) |

## Minimal Working Example

This Rust snippet demonstrates the complete mapping flow found in the real UI:

```rust
use tuicr::forge::submit::{map_comment, CommentAnchor, PreflightResult, build_review_body};
use tuicr::model::comment::Comment;
use tuicr::model::diff_types::{DiffFile, LineSide};
use tuicr::config::ForgeConfig;

// 1. Obtain a diff file from the current session
let diff_file: DiffFile = /* from App::diff_files */;

// 2. Create a draft comment targeting new line 42
let mut comment = Comment::new(
    "please rename this var".to_string(),
    tuicr::model::comment::CommentType::from_id("issue"),
    Some(LineSide::New),
);
comment.line_context = Some(tuicr::model::comment::LineContext {
    new_line: Some(42),
    old_line: None,
    content: String::new(),
});

// 3. Map to inline coordinate or unmappable reason
let anchor = CommentAnchor::Line { line: 42, side: LineSide::New };
let mapped = map_comment(&comment, anchor, &diff_file, &ForgeConfig::default());

// 4. Build preflight result with mapped comments
let preflight = PreflightResult {
    event: tuicr::forge::submit::SubmitEvent::Comment,
    mappable: match mapped {
        tuicr::forge::submit::MappedComment::Inline(ic) => vec![ic],
        _ => vec![],
    },
    unmappable: vec![],
    commit_id: "deadbeef1234".to_string(),
};

// 5. Assemble review body (empty unplaced list in this case)
let body = build_review_body(&[], &[], &ForgeConfig::default());

// 6. GitHub backend serializes preflight and calls: gh api --input -

```

This mirrors the actual production path: `App::start_submit` → `map_comment` → `PreflightResult` → forge backend → `gh api` call.

## Critical Source Files

| File | Responsibility |
|------|--------------|
| [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs) | Core pipeline: `map_comment`, `PreflightResult`, `build_review_body`, resolver types |
| [`src/forge/github/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/github/submit.rs) | GitHub‑specific payload serialization and `gh` CLI invocation |
| [`src/forge/gitlab/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/gitlab/submit.rs) | Parallel GitLab implementation using `glab` |
| [`src/forge/traits.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/traits.rs) | `ForgeBackend` trait defining the `create_review` interface |
| [`src/model/comment.rs`](https://github.com/agavra/tuicr/blob/main/src/model/comment.rs) | `Comment`, `CommentType`, `LineContext` definitions |
| [`src/model/diff_types.rs`](https://github.com/agavra/tuicr/blob/main/src/model/diff_types.rs) | `DiffFile`, `DiffHunk`, `LineSide`, `LineRange` structures |
| [`src/app.rs`](https://github.com/agavra/tuicr/blob/main/src/app.rs) | Orchestrates submission entry point and resolver UI |

## Summary

- **Three-phase pipeline** — Pre‑flight mapping, resolver UI for edge cases, then payload construction and remote submission
- **Anchor-aware mapping** — `map_comment` in [`src/forge/submit.rs`](https://github.com/agavra/tuicr/blob/main/src/forge/submit.rs) translates local line numbers to GitHub/GitLab diff coordinates using `CommentAnchor`
- **Graceful degradation** — Unmappable comments (binary files, pure deletions, mixed‑side ranges) surface in a resolver UI with Move/Omit options
- **STDIN-based submission** — Large payloads bypass command‑line limits via `gh api --input -`
- **Commit pinning** — `commit_id` captured at pre‑flight prevents anchor drift if the PR updates during your review

## Frequently Asked Questions

### How does Tuicr handle comments on binary files?

Binary files cannot receive inline comments on GitHub, so `map_comment` immediately returns `UnmappableReason::BinaryFile`. These appear in the resolver UI where you can move them to the review summary or omit them. The same applies to files exceeding size limits (`TooLargeFile`).

### What happens to comments when the PR's head commit changes?

Tuicr captures the `commit_id` during the pre‑flight phase and includes it in the submission payload. This ensures your comments anchor to the SHA you reviewed, preventing GitHub from misplacing them against a newer commit. If you reload the PR and the commit changed, you must re‑map your drafts.

### Why are some line ranges marked as "mixed side"?

GitHub's review API requires inline comments to sit entirely on the left side (deletions), entirely on the right side (additions), or span multiple lines on one side only. Tuicr's `range_endpoints_present` check rejects ranges that cross the old/new boundary, flagging them as `MixedSideRange` for the resolver UI.

### Can I customize how comments appear in the review body?

Yes. The `ForgeConfig::comment_type_prefix` option enables prefixes like `[ISSUE]` or `[NOTE]` based on your comment's type. File‑level comments automatically receive a "File‑level:" marker. These formatting rules apply consistently in both `map_comment` via `build_inline_body` and `build_review_body` for unplaced comments.