This issue is self-contained. For any design questions, the canonical reference is: docs/DESIGN-vcs-abstraction.md
(Parts 1, 2, 3, and 6 are most relevant to this issue)
Base branch:feature/github-support — open all PRs against this branch.
Goal
Extract vcs.Client interface and shared types from the existing working gitea/ code. The interface is discovered from working code, not invented.
Background
review-bot currently only supports Gitea. The goal is to add GitHub and GitHub Enterprise support behind a shared vcs.Client interface. This issue is the foundation: define that interface by reading what gitea/client.go already does, then declare the shared types.
All ReviewComment types in vcs/ use GitHub diff-position convention (path + position + commit_id) — this is the canonical shape across all adapters. position is a 1-indexed offset from the @@ hunk line in the unified diff. Adapters translate to/from this format; the core never deals with platform-native formats.
ReviewRequest.Event uses GitHub's string values as canonical: "APPROVE", "REQUEST_CHANGES", "COMMENT". The Gitea adapter is responsible for translating these to Gitea-native event values before posting.
Work
vcs/interfaces.go
Role-based interfaces extracted from gitea/client.go:
Extract or alias from gitea/. Explicit required fields listed below:
typePullRequeststruct{// Number is not present on gitea.PullRequest today (main.go reads it from// the CLI flag). It is added here because the GitHub client (#80) returns// it in the API response and downstream code will need it to avoid// threading the CLI flag through every helper.NumberintTitlestringBodystringHeadstruct{RefstringShastring}// Base.Ref is not present on gitea.PullRequest today. Added for the// GitHub client (#80) which returns base branch info and may need it// for branch-targeting logic.Basestruct{Refstring}}typeChangedFilestruct{FilenamestringStatusstring// "added", "modified", "removed"Patchstring// unified diff for this file; may be empty for binary files or when not returned by provider}typeContentEntrystruct{NamestringPathstringTypestring// "file" or "dir"}typeCommitStatusstruct{ContextstringStatusstring// "success", "failure", "pending", etc.DescriptionstringTargetURLstring}// Canonical Review.State values — all adapters must translate to these.// GitHub adapter: "CHANGES_REQUESTED" → "REQUEST_CHANGES", "COMMENTED" → "COMMENT"// Gitea adapter: already uses these values nativelyconst(ReviewStateApproved="APPROVED"ReviewStateRequestChanges="REQUEST_CHANGES"ReviewStateComment="COMMENT"ReviewStateDismissed="DISMISSED")typeReviewstruct{IDint64CommitIDstringBodystringStatestring// one of the ReviewState* constants aboveUserstruct{Loginstring}}typeReviewCommentstruct{PathstringPositionint// diff-position (GitHub convention): 1-indexed offset from @@ hunk lineCommitIDstring// must be the PR head SHA; callers are responsible for setting thisBodystring}// ReviewRequest.Event uses GitHub canonical values: "APPROVE", "REQUEST_CHANGES", "COMMENT"// The Gitea adapter translates to Gitea-native event strings before posting.typeReviewRequeststruct{Eventstring// "APPROVE", "REQUEST_CHANGES", or "COMMENT"BodystringComments[]ReviewComment}
vcs/util.go
Two utility functions — do not add either to the interface:
// GetAllFilesInPath recursively lists all file paths under the given directory.// Returns a map of path -> file content.funcGetAllFilesInPath(ctxcontext.Context,clientFileReader,owner,repo,pathstring)(map[string]string,error)
Returns map[string]string (path → content). Implementation: call client.ListContents(ctx, owner, repo, path), recurse into Type == "dir" entries, fetch content for Type == "file" entries via client.GetFileContent.
2. BuildLineToPositionMap — inverts the diff-position map for use in main.go:
// BuildLineToPositionMap parses a unified diff and returns a per-file map// of new-file line number → diff-position.// Position is a 1-indexed offset from the @@ hunk line (GitHub convention).// Only addition and context lines have new-file line numbers; deletion lines// have a position but no new-file line, so they are not in the map.// Returns: map[filename]map[newFileLine]diffPositionfuncBuildLineToPositionMap(diffstring)map[string]map[int]int
This is the inverse of the Gitea adapter's position → line map. Used in main.go to convert LLM-produced line numbers to diff-positions before constructing vcs.ReviewComment. Replaces gitea.ParseDiffNewLines + diffRanges.Contains entirely.
Counting rules are identical to the diff-position spec in issue #79:
The @@ hunk header is position 1 for the first hunk
Every line (context, addition, deletion) increments position
A second @@ hunk continues the count, does not reset
Addition and context lines map to new-file line numbers
Deletion lines: skip (no entry in the map)
Unit tests for BuildLineToPositionMap:
Single hunk with context, additions, deletions
Multi-hunk file (position counter continues across hunks)
Deletion lines not present in output map
Multiple files in one diff
Unit tests for GetAllFilesInPath: empty dir, flat dir, nested dirs, mixed.
Type alias strategy
When gitea/ types have identical fields to vcs/ types, prefer type aliases to avoid mapping boilerplate:
// In gitea/types.go (or wherever the type is currently defined)typeContentEntry=vcs.ContentEntry// alias: zero mapping neededtypeCommitStatus=vcs.CommitStatus// alias: fields are identical
Use an independent copy (no alias) only when the gitea type has fields that differ from or extend the vcs type (e.g. gitea.PullRequest has no Number field — it cannot be aliased to vcs.PullRequest). In that case, map field-by-field in the adapter.
This decision affects which methods are true pass-throughs vs. require mapping — the compile-time check will make divergences visible.
review.ContentEntry and review.GiteaClient cleanup
review/repo_persona.go defines review.ContentEntry (mirroring gitea.ContentEntry to avoid import cycles) and review.GiteaClient interface. Once vcs/types.go exists, review/ can safely import vcs/ (no cycle — vcs/ has no upward dependencies).
review.ContentEntry — replace with vcs.ContentEntry throughout review/
review.GiteaClient — replace with vcs.FileReader in review.LoadRepoPersonas signature
Update review/repo_persona_test.go: mockGiteaClient.ListContents return type changes from []review.ContentEntry to []vcs.ContentEntry
These deletions are owned by #78 and must be included in the same PR as vcs/types.go. They are not deferred — #82 depends on them already being gone.
Compile-time check
var_vcs.Client=(*gitea.Client)(nil)
Expected to fail on PostReview, GetFileContentAtRef, GetCommitStatuses, DismissReview, ListContents, and ListReviews — this is intentional (return types differ: []gitea.ContentEntry/[]gitea.Review vs []vcs.ContentEntry/[]vcs.Review unless type-aliased). Each failure documents a gap the Gitea adapter (issue #79) must bridge. GetFileContent and GetAuthenticatedUser will pass without changes.
## Architecture Reference
> This issue is self-contained. For any design questions, the canonical reference is:
> [`docs/DESIGN-vcs-abstraction.md`](https://gitea.weiker.me/rodin/review-bot/src/branch/feature/github-support/docs/DESIGN-vcs-abstraction.md)
> (Parts 1, 2, 3, and 6 are most relevant to this issue)
**Base branch:** `feature/github-support` — open all PRs against this branch.
---
## Goal
Extract `vcs.Client` interface and shared types from the existing working `gitea/` code. The interface is discovered from working code, not invented.
## Background
review-bot currently only supports Gitea. The goal is to add GitHub and GitHub Enterprise support behind a shared `vcs.Client` interface. This issue is the foundation: define that interface by reading what `gitea/client.go` already does, then declare the shared types.
All `ReviewComment` types in `vcs/` use **GitHub diff-position convention** (`path` + `position` + `commit_id`) — this is the canonical shape across all adapters. `position` is a 1-indexed offset from the `@@` hunk line in the unified diff. Adapters translate to/from this format; the core never deals with platform-native formats.
`ReviewRequest.Event` uses **GitHub's string values** as canonical: `"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`. The Gitea adapter is responsible for translating these to Gitea-native event values before posting.
## Work
### `vcs/interfaces.go`
Role-based interfaces extracted from `gitea/client.go`:
```go
type PRReader interface {
GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error)
GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error)
GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error)
GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error)
GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error)
}
type FileReader interface {
GetFileContent(ctx context.Context, owner, repo, path string) (string, error)
ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error)
}
type Reviewer interface {
PostReview(ctx context.Context, owner, repo string, number int, req ReviewRequest) (*Review, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error)
DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error
DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error
}
type Identity interface {
GetAuthenticatedUser(ctx context.Context) (string, error)
}
type Client interface {
PRReader
FileReader
Reviewer
Identity
}
```
### `vcs/types.go`
Extract or alias from `gitea/`. Explicit required fields listed below:
```go
type PullRequest struct {
// Number is not present on gitea.PullRequest today (main.go reads it from
// the CLI flag). It is added here because the GitHub client (#80) returns
// it in the API response and downstream code will need it to avoid
// threading the CLI flag through every helper.
Number int
Title string
Body string
Head struct {
Ref string
Sha string
}
// Base.Ref is not present on gitea.PullRequest today. Added for the
// GitHub client (#80) which returns base branch info and may need it
// for branch-targeting logic.
Base struct {
Ref string
}
}
type ChangedFile struct {
Filename string
Status string // "added", "modified", "removed"
Patch string // unified diff for this file; may be empty for binary files or when not returned by provider
}
type ContentEntry struct {
Name string
Path string
Type string // "file" or "dir"
}
type CommitStatus struct {
Context string
Status string // "success", "failure", "pending", etc.
Description string
TargetURL string
}
// Canonical Review.State values — all adapters must translate to these.
// GitHub adapter: "CHANGES_REQUESTED" → "REQUEST_CHANGES", "COMMENTED" → "COMMENT"
// Gitea adapter: already uses these values natively
const (
ReviewStateApproved = "APPROVED"
ReviewStateRequestChanges = "REQUEST_CHANGES"
ReviewStateComment = "COMMENT"
ReviewStateDismissed = "DISMISSED"
)
type Review struct {
ID int64
CommitID string
Body string
State string // one of the ReviewState* constants above
User struct {
Login string
}
}
type ReviewComment struct {
Path string
Position int // diff-position (GitHub convention): 1-indexed offset from @@ hunk line
CommitID string // must be the PR head SHA; callers are responsible for setting this
Body string
}
// ReviewRequest.Event uses GitHub canonical values: "APPROVE", "REQUEST_CHANGES", "COMMENT"
// The Gitea adapter translates to Gitea-native event strings before posting.
type ReviewRequest struct {
Event string // "APPROVE", "REQUEST_CHANGES", or "COMMENT"
Body string
Comments []ReviewComment
}
```
### `vcs/util.go`
Two utility functions — do **not** add either to the interface:
**1. `GetAllFilesInPath`** — recursive directory traversal:
```go
// GetAllFilesInPath recursively lists all file paths under the given directory.
// Returns a map of path -> file content.
func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path string) (map[string]string, error)
```
Returns `map[string]string` (path → content). Implementation: call `client.ListContents(ctx, owner, repo, path)`, recurse into `Type == "dir"` entries, fetch content for `Type == "file"` entries via `client.GetFileContent`.
**2. `BuildLineToPositionMap`** — inverts the diff-position map for use in `main.go`:
```go
// BuildLineToPositionMap parses a unified diff and returns a per-file map
// of new-file line number → diff-position.
// Position is a 1-indexed offset from the @@ hunk line (GitHub convention).
// Only addition and context lines have new-file line numbers; deletion lines
// have a position but no new-file line, so they are not in the map.
// Returns: map[filename]map[newFileLine]diffPosition
func BuildLineToPositionMap(diff string) map[string]map[int]int
```
This is the inverse of the Gitea adapter's `position → line` map. Used in `main.go` to convert LLM-produced line numbers to diff-positions before constructing `vcs.ReviewComment`. Replaces `gitea.ParseDiffNewLines` + `diffRanges.Contains` entirely.
Counting rules are identical to the diff-position spec in issue #79:
- The `@@` hunk header is position 1 for the first hunk
- Every line (context, addition, deletion) increments position
- A second `@@` hunk continues the count, does not reset
- Addition and context lines map to new-file line numbers
- Deletion lines: skip (no entry in the map)
Unit tests for `BuildLineToPositionMap`:
- Single hunk with context, additions, deletions
- Multi-hunk file (position counter continues across hunks)
- Deletion lines not present in output map
- Multiple files in one diff
Unit tests for `GetAllFilesInPath`: empty dir, flat dir, nested dirs, mixed.
### Type alias strategy
When `gitea/` types have identical fields to `vcs/` types, prefer **type aliases** to avoid mapping boilerplate:
```go
// In gitea/types.go (or wherever the type is currently defined)
type ContentEntry = vcs.ContentEntry // alias: zero mapping needed
type CommitStatus = vcs.CommitStatus // alias: fields are identical
```
Use an **independent copy** (no alias) only when the gitea type has fields that differ from or extend the vcs type (e.g. `gitea.PullRequest` has no `Number` field — it cannot be aliased to `vcs.PullRequest`). In that case, map field-by-field in the adapter.
This decision affects which methods are true pass-throughs vs. require mapping — the compile-time check will make divergences visible.
### `review.ContentEntry` and `review.GiteaClient` cleanup
`review/repo_persona.go` defines `review.ContentEntry` (mirroring `gitea.ContentEntry` to avoid import cycles) and `review.GiteaClient` interface. Once `vcs/types.go` exists, `review/` can safely import `vcs/` (no cycle — `vcs/` has no upward dependencies).
As part of **this issue (#78)**, delete:
- `review.ContentEntry` — replace with `vcs.ContentEntry` throughout `review/`
- `review.GiteaClient` — replace with `vcs.FileReader` in `review.LoadRepoPersonas` signature
- Update `review/repo_persona_test.go`: `mockGiteaClient.ListContents` return type changes from `[]review.ContentEntry` to `[]vcs.ContentEntry`
These deletions are owned by #78 and must be included in the same PR as `vcs/types.go`. They are not deferred — #82 depends on them already being gone.
### Compile-time check
```go
var _ vcs.Client = (*gitea.Client)(nil)
```
Expected to fail on `PostReview`, `GetFileContentAtRef`, `GetCommitStatuses`, `DismissReview`, `ListContents`, and `ListReviews` — this is intentional (return types differ: `[]gitea.ContentEntry`/`[]gitea.Review` vs `[]vcs.ContentEntry`/`[]vcs.Review` unless type-aliased). Each failure documents a gap the Gitea adapter (issue #79) must bridge. `GetFileContent` and `GetAuthenticatedUser` will pass without changes.
## Exit criteria
- `vcs/interfaces.go`, `vcs/types.go`, `vcs/util.go` compile
- Compile-time check is present; failures document the gaps
- No behavior changes to existing Gitea functionality
- Types are extracted from `gitea/`, not invented
- `vcs.GetAllFilesInPath` unit tests pass
- `vcs.BuildLineToPositionMap` unit tests pass
## Out of scope
- Gitea adapter implementation (issue #79)
- GitHub client (issues #80, #81)
- CLI flag changes (issue #82)
Rationale: GitHub cannot delete submitted reviews; DismissReview is the cross-platform primitive for replacing a review. The Gitea adapter implements it by calling the underlying DeleteReview. See #81 for full details.
**Update:** `Reviewer` interface must include `DismissReview` (added in issue #81 design):
```go
type Reviewer interface {
PostReview(ctx context.Context, owner, repo string, number int, req ReviewRequest) (*Review, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error)
DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error
DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error
}
```
Rationale: GitHub cannot delete submitted reviews; `DismissReview` is the cross-platform primitive for replacing a review. The Gitea adapter implements it by calling the underlying `DeleteReview`. See #81 for full details.
Update from codebase audit (issue #82): Two methods are missing from the interface design and must be added to vcs/interfaces.go:
GetFileContentAtRef — the current code calls GetFileContentRef(ctx, owner, repo, path, ref) to fetch file content at a specific git ref (branch/SHA). The current GetFileContent in the design takes no ref parameter. Add:
Add CommitStatus to vcs/types.go: { Context, Status, Description string }
The compile-time check var _ vcs.Client = (*gitea.Client)(nil) will fail on both of these — which is correct and expected, as they need adapter coverage.
**Update from codebase audit (issue #82):** Two methods are missing from the interface design and must be added to `vcs/interfaces.go`:
1. **`GetFileContentAtRef`** — the current code calls `GetFileContentRef(ctx, owner, repo, path, ref)` to fetch file content at a specific git ref (branch/SHA). The current `GetFileContent` in the design takes no `ref` parameter. Add:
```go
GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error)
```
2. **`GetCommitStatuses`** — used for CI status checks. Add to `PRReader`:
```go
GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error)
```
Add `CommitStatus` to `vcs/types.go`: `{ Context, Status, Description string }`
The compile-time check `var _ vcs.Client = (*gitea.Client)(nil)` will fail on both of these — which is correct and expected, as they need adapter coverage.
Update: vcs/util.go — GetAllFilesInPath is recursive directory traversal, not a VCS primitive. Do not add it to the vcs.Client interface. Instead, add a utility function in this issue:
// vcs/util.go// GetAllFilesInPath recursively lists all file paths under the given directory// using the FileReader interface. Works for any provider.funcGetAllFilesInPath(ctxcontext.Context,clientFileReader,owner,repo,pathstring)([]string,error)
Implementation: call client.ListContents(ctx, owner, repo, path), recurse into entries where Type == "dir", collect entries where Type == "file".
Add a unit test for: empty dir, flat dir, nested dirs, mixed files and dirs.
**Update: `vcs/util.go`** — `GetAllFilesInPath` is recursive directory traversal, not a VCS primitive. Do **not** add it to the `vcs.Client` interface. Instead, add a utility function in this issue:
```go
// vcs/util.go
// GetAllFilesInPath recursively lists all file paths under the given directory
// using the FileReader interface. Works for any provider.
func GetAllFilesInPath(ctx context.Context, client FileReader, owner, repo, path string) ([]string, error)
```
Implementation: call `client.ListContents(ctx, owner, repo, path)`, recurse into entries where `Type == "dir"`, collect entries where `Type == "file"`.
Add a unit test for: empty dir, flat dir, nested dirs, mixed files and dirs.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Architecture Reference
Base branch:
feature/github-support— open all PRs against this branch.Goal
Extract
vcs.Clientinterface and shared types from the existing workinggitea/code. The interface is discovered from working code, not invented.Background
review-bot currently only supports Gitea. The goal is to add GitHub and GitHub Enterprise support behind a shared
vcs.Clientinterface. This issue is the foundation: define that interface by reading whatgitea/client.goalready does, then declare the shared types.All
ReviewCommenttypes invcs/use GitHub diff-position convention (path+position+commit_id) — this is the canonical shape across all adapters.positionis a 1-indexed offset from the@@hunk line in the unified diff. Adapters translate to/from this format; the core never deals with platform-native formats.ReviewRequest.Eventuses GitHub's string values as canonical:"APPROVE","REQUEST_CHANGES","COMMENT". The Gitea adapter is responsible for translating these to Gitea-native event values before posting.Work
vcs/interfaces.goRole-based interfaces extracted from
gitea/client.go:vcs/types.goExtract or alias from
gitea/. Explicit required fields listed below:vcs/util.goTwo utility functions — do not add either to the interface:
1.
GetAllFilesInPath— recursive directory traversal:Returns
map[string]string(path → content). Implementation: callclient.ListContents(ctx, owner, repo, path), recurse intoType == "dir"entries, fetch content forType == "file"entries viaclient.GetFileContent.2.
BuildLineToPositionMap— inverts the diff-position map for use inmain.go:This is the inverse of the Gitea adapter's
position → linemap. Used inmain.goto convert LLM-produced line numbers to diff-positions before constructingvcs.ReviewComment. Replacesgitea.ParseDiffNewLines+diffRanges.Containsentirely.Counting rules are identical to the diff-position spec in issue #79:
@@hunk header is position 1 for the first hunk@@hunk continues the count, does not resetUnit tests for
BuildLineToPositionMap:Unit tests for
GetAllFilesInPath: empty dir, flat dir, nested dirs, mixed.Type alias strategy
When
gitea/types have identical fields tovcs/types, prefer type aliases to avoid mapping boilerplate:Use an independent copy (no alias) only when the gitea type has fields that differ from or extend the vcs type (e.g.
gitea.PullRequesthas noNumberfield — it cannot be aliased tovcs.PullRequest). In that case, map field-by-field in the adapter.This decision affects which methods are true pass-throughs vs. require mapping — the compile-time check will make divergences visible.
review.ContentEntryandreview.GiteaClientcleanupreview/repo_persona.godefinesreview.ContentEntry(mirroringgitea.ContentEntryto avoid import cycles) andreview.GiteaClientinterface. Oncevcs/types.goexists,review/can safely importvcs/(no cycle —vcs/has no upward dependencies).As part of this issue (#78), delete:
review.ContentEntry— replace withvcs.ContentEntrythroughoutreview/review.GiteaClient— replace withvcs.FileReaderinreview.LoadRepoPersonassignaturereview/repo_persona_test.go:mockGiteaClient.ListContentsreturn type changes from[]review.ContentEntryto[]vcs.ContentEntryThese deletions are owned by #78 and must be included in the same PR as
vcs/types.go. They are not deferred — #82 depends on them already being gone.Compile-time check
Expected to fail on
PostReview,GetFileContentAtRef,GetCommitStatuses,DismissReview,ListContents, andListReviews— this is intentional (return types differ:[]gitea.ContentEntry/[]gitea.Reviewvs[]vcs.ContentEntry/[]vcs.Reviewunless type-aliased). Each failure documents a gap the Gitea adapter (issue #79) must bridge.GetFileContentandGetAuthenticatedUserwill pass without changes.Exit criteria
vcs/interfaces.go,vcs/types.go,vcs/util.gocompilegitea/, not inventedvcs.GetAllFilesInPathunit tests passvcs.BuildLineToPositionMapunit tests passOut of scope
Update:
Reviewerinterface must includeDismissReview(added in issue #81 design):Rationale: GitHub cannot delete submitted reviews;
DismissReviewis the cross-platform primitive for replacing a review. The Gitea adapter implements it by calling the underlyingDeleteReview. See #81 for full details.Update from codebase audit (issue #82): Two methods are missing from the interface design and must be added to
vcs/interfaces.go:GetFileContentAtRef— the current code callsGetFileContentRef(ctx, owner, repo, path, ref)to fetch file content at a specific git ref (branch/SHA). The currentGetFileContentin the design takes norefparameter. Add:GetCommitStatuses— used for CI status checks. Add toPRReader:Add
CommitStatustovcs/types.go:{ Context, Status, Description string }The compile-time check
var _ vcs.Client = (*gitea.Client)(nil)will fail on both of these — which is correct and expected, as they need adapter coverage.Update:
vcs/util.go—GetAllFilesInPathis recursive directory traversal, not a VCS primitive. Do not add it to thevcs.Clientinterface. Instead, add a utility function in this issue:Implementation: call
client.ListContents(ctx, owner, repo, path), recurse into entries whereType == "dir", collect entries whereType == "file".Add a unit test for: empty dir, flat dir, nested dirs, mixed files and dirs.
rodin referenced this issue2026-05-12 19:31:28 +00:00