This issue is self-contained. For any design questions, the canonical reference is: docs/DESIGN-vcs-abstraction.md
(Parts 2, 3, and 4 are most relevant to this issue)
Base branch:feature/github-support — open all PRs against this branch.
Goal
Wrap gitea.Client in an adapter (gitea.Adapter) that satisfies vcs.Client. The critical work is: (1) translating GitHub diff-position in ReviewComment to Gitea line numbers, and (2) translating ReviewRequest.Event canonical values to Gitea-native event strings.
Background
review-bot uses GitHub diff-position as the canonical ReviewComment format (path + position + commit_id, where position is a 1-indexed offset from the @@ hunk line). Gitea takes line numbers instead. The adapter is responsible for this translation.
ReviewRequest.Event uses GitHub canonical values ("APPROVE", "REQUEST_CHANGES", "COMMENT"). Gitea uses different strings. The adapter must translate.
The vcs.Client interface and all types live in vcs/ (created in issue #78).
Work
gitea/adapter.go
typeAdapterstruct{client*Client}funcNewAdapter(client*Client)*Adapter// Underlying returns the wrapped gitea.Client for Gitea-specific operations// that have no vcs.Client equivalent (resolve comment, timeline, supersede flow).// Callers in main.go type-assert to *Adapter and call Underlying() when needed.func(a*Adapter)Underlying()*Client
Alias strategy
Before implementing any method, check whether the gitea type can be aliased to the vcs type (see issue #78 type alias strategy). If gitea.ContentEntry = vcs.ContentEntry and gitea.CommitStatus = vcs.CommitStatus are in place, ListContents and GetCommitStatuses become true pass-throughs. Only types with structural differences (e.g. gitea.PullRequest vs vcs.PullRequest, gitea.Review vs vcs.Review) require field mapping.
func(a*Adapter)GetPullRequest(ctx,owner,repostring,numberint)(*vcs.PullRequest,error){pr,err:=a.client.GetPullRequest(ctx,owner,repo,number)iferr!=nil{returnnil,err}return&vcs.PullRequest{Number:number,// gitea.PullRequest has no Number field; use the method parameterTitle:pr.Title,Body:pr.Body,Head:vcs.PullRequest.Head{Ref:pr.Head.Ref,Sha:pr.Head.Sha},Base:vcs.PullRequest.Base{Ref:pr.Base.Ref},},nil}
(Pseudo-code; use the correct anonymous struct literal syntax for Go.)
// gitea.ChangedFile has {Filename, Status} — no Patch field in the existing struct.// vcs.ChangedFile has {Filename, Status, Patch}.// Set Patch to "" for all entries (Gitea /pulls/{n}/files does not return patch text).// Patch is populated by GetPullRequestDiff in main.go where needed.
ListReviews — maps []gitea.Review → []vcs.Review:
// Map each gitea.Review field to vcs.Review.// Gitea State values happen to match the vcs canonical values already:// "APPROVED", "REQUEST_CHANGES", "COMMENT" — pass through as-is.// If Gitea ever returns other values, map to vcs.ReviewState* constants.
// Map Context, Status, Description, TargetURL from gitea.CommitStatus to vcs.CommitStatus.
Pass-through methods
These delegate directly with no type translation (signature and return type already match):
GetPullRequestDiff
GetFileContent
GetAuthenticatedUser
DeleteReview
ListContents
Per the alias strategy (see issue #78): gitea.ContentEntry should be aliased to vcs.ContentEntry (type ContentEntry = vcs.ContentEntry). If that alias is in place, gitea.Client.ListContents already satisfies the interface with no adapter wrapper needed — it becomes a true pass-through.
If for any reason the alias cannot be applied (e.g. a conflicting field), fall back to a field-by-field map (Name, Path, Type — all strings). Add a unit test either way.
PostReview translation
Two translations required:
1. Event string — translate ReviewRequest.Event from GitHub canonical to Gitea-native:
ReviewRequest.Event (canonical)
Gitea API event string
"APPROVE"
"APPROVED"
"REQUEST_CHANGES"
"REQUEST_CHANGES"
"COMMENT"
"COMMENT"
Note: "APPROVE" (no D) → "APPROVED" (with D) is the critical translation. gitea.Client.PostReview passes the event string verbatim to the Gitea API, which requires "APPROVED".
Unit test: verify "APPROVE" produces a Gitea API call with event "APPROVED".
2. Diff-position → line number — for each ReviewComment.Position:
Fetch the PR diff using GetPullRequestDiff
Parse unified diff hunks to build a position → new-file line number map per file
Translate each ReviewComment.Position to the corresponding file line number
Set CommitID from ReviewComment.CommitID (pass through to Gitea)
Diff-position counting rules (GitHub spec)
Position is a per-file counter. For each file in the diff:
The @@ hunk header line is position 1
Every subsequent line increments position by 1 — context, additions, AND deletions all count
A new @@ hunk within the same file continues incrementing (does not reset)
Position maps to the new file line number for additions and context lines
Deletion lines have a position but no new-file line number — map to the nearest non-deletion line below
Example:
@@ -16,4 +16,5 @@ ← position 1
context ← position 2, new line 16
-deleted ← position 3, no new line
+added ← position 4, new line 17
context ← position 5, new line 18
DismissReview
func(a*Adapter)DismissReview(ctxcontext.Context,owner,repostring,numberint,reviewIDint64,messagestring)error{// Gitea supports full deletion of any review state.// The message parameter is intentionally unused — Gitea deletion has no dismissal message.returna.client.DeleteReview(ctx,owner,repo,number,reviewID)// a.client is *Client (within package gitea)}
## 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 2, 3, and 4 are most relevant to this issue)
**Base branch:** `feature/github-support` — open all PRs against this branch.
---
## Goal
Wrap `gitea.Client` in an adapter (`gitea.Adapter`) that satisfies `vcs.Client`. The critical work is: (1) translating GitHub diff-position in `ReviewComment` to Gitea line numbers, and (2) translating `ReviewRequest.Event` canonical values to Gitea-native event strings.
## Background
review-bot uses GitHub diff-position as the canonical `ReviewComment` format (`path` + `position` + `commit_id`, where `position` is a 1-indexed offset from the `@@` hunk line). Gitea takes line numbers instead. The adapter is responsible for this translation.
`ReviewRequest.Event` uses GitHub canonical values (`"APPROVE"`, `"REQUEST_CHANGES"`, `"COMMENT"`). Gitea uses different strings. The adapter must translate.
The `vcs.Client` interface and all types live in `vcs/` (created in issue #78).
## Work
### `gitea/adapter.go`
```go
type Adapter struct {
client *Client
}
func NewAdapter(client *Client) *Adapter
// Underlying returns the wrapped gitea.Client for Gitea-specific operations
// that have no vcs.Client equivalent (resolve comment, timeline, supersede flow).
// Callers in main.go type-assert to *Adapter and call Underlying() when needed.
func (a *Adapter) Underlying() *Client
```
### Alias strategy
Before implementing any method, check whether the gitea type can be aliased to the vcs type (see issue #78 type alias strategy). If `gitea.ContentEntry = vcs.ContentEntry` and `gitea.CommitStatus = vcs.CommitStatus` are in place, `ListContents` and `GetCommitStatuses` become true pass-throughs. Only types with structural differences (e.g. `gitea.PullRequest` vs `vcs.PullRequest`, `gitea.Review` vs `vcs.Review`) require field mapping.
### Methods requiring mapping (not simple pass-through)
These methods require explicit type translation, not just delegation:
**`GetPullRequest`** — maps `gitea.PullRequest` → `vcs.PullRequest`:
```go
func (a *Adapter) GetPullRequest(ctx, owner, repo string, number int) (*vcs.PullRequest, error) {
pr, err := a.client.GetPullRequest(ctx, owner, repo, number)
if err != nil { return nil, err }
return &vcs.PullRequest{
Number: number, // gitea.PullRequest has no Number field; use the method parameter
Title: pr.Title,
Body: pr.Body,
Head: vcs.PullRequest.Head{Ref: pr.Head.Ref, Sha: pr.Head.Sha},
Base: vcs.PullRequest.Base{Ref: pr.Base.Ref},
}, nil
}
```
(Pseudo-code; use the correct anonymous struct literal syntax for Go.)
**`GetPullRequestFiles`** — maps `[]gitea.ChangedFile` → `[]vcs.ChangedFile`:
```go
// gitea.ChangedFile has {Filename, Status} — no Patch field in the existing struct.
// vcs.ChangedFile has {Filename, Status, Patch}.
// Set Patch to "" for all entries (Gitea /pulls/{n}/files does not return patch text).
// Patch is populated by GetPullRequestDiff in main.go where needed.
```
**`ListReviews`** — maps `[]gitea.Review` → `[]vcs.Review`:
```go
// Map each gitea.Review field to vcs.Review.
// Gitea State values happen to match the vcs canonical values already:
// "APPROVED", "REQUEST_CHANGES", "COMMENT" — pass through as-is.
// If Gitea ever returns other values, map to vcs.ReviewState* constants.
```
**`GetCommitStatuses`** — maps `[]gitea.CommitStatus` → `[]vcs.CommitStatus`:
```go
// Map Context, Status, Description, TargetURL from gitea.CommitStatus to vcs.CommitStatus.
```
### Pass-through methods
These delegate directly with no type translation (signature and return type already match):
- `GetPullRequestDiff`
- `GetFileContent`
- `GetAuthenticatedUser`
- `DeleteReview`
### `ListContents`
Per the alias strategy (see issue #78): `gitea.ContentEntry` should be aliased to `vcs.ContentEntry` (`type ContentEntry = vcs.ContentEntry`). If that alias is in place, `gitea.Client.ListContents` already satisfies the interface with no adapter wrapper needed — it becomes a true pass-through.
If for any reason the alias cannot be applied (e.g. a conflicting field), fall back to a field-by-field map (Name, Path, Type — all strings). Add a unit test either way.
### `PostReview` translation
Two translations required:
**1. Event string** — translate `ReviewRequest.Event` from GitHub canonical to Gitea-native:
| `ReviewRequest.Event` (canonical) | Gitea API event string |
|-----------------------------------|-----------------------|
| `"APPROVE"` | `"APPROVED"` |
| `"REQUEST_CHANGES"` | `"REQUEST_CHANGES"` |
| `"COMMENT"` | `"COMMENT"` |
Note: `"APPROVE"` (no D) → `"APPROVED"` (with D) is the critical translation. `gitea.Client.PostReview` passes the event string verbatim to the Gitea API, which requires `"APPROVED"`.
Unit test: verify `"APPROVE"` produces a Gitea API call with event `"APPROVED"`.
**2. Diff-position → line number** — for each `ReviewComment.Position`:
1. Fetch the PR diff using `GetPullRequestDiff`
2. Parse unified diff hunks to build a `position → new-file line number` map per file
3. Translate each `ReviewComment.Position` to the corresponding file line number
4. Set `CommitID` from `ReviewComment.CommitID` (pass through to Gitea)
### Diff-position counting rules (GitHub spec)
Position is a **per-file** counter. For each file in the diff:
- The `@@` hunk header line is position **1**
- **Every** subsequent line increments position by 1 — context, additions, AND deletions all count
- A new `@@` hunk within the same file continues incrementing (does not reset)
- Position maps to the **new file** line number for additions and context lines
- Deletion lines have a position but no new-file line number — map to the nearest non-deletion line below
**Example:**
```
@@ -16,4 +16,5 @@ ← position 1
context ← position 2, new line 16
-deleted ← position 3, no new line
+added ← position 4, new line 17
context ← position 5, new line 18
```
### `DismissReview`
```go
func (a *Adapter) DismissReview(ctx context.Context, owner, repo string, number int, reviewID int64, message string) error {
// Gitea supports full deletion of any review state.
// The message parameter is intentionally unused — Gitea deletion has no dismissal message.
return a.client.DeleteReview(ctx, owner, repo, number, reviewID) // a.client is *Client (within package gitea)
}
```
### `GetFileContentAtRef`
Pass-through to `gitea.Client.GetFileContentRef`:
```go
func (a *Adapter) GetFileContentAtRef(ctx context.Context, owner, repo, path, ref string) (string, error) {
return a.client.GetFileContentRef(ctx, owner, repo, path, ref)
}
```
### `GetCommitStatuses`
Pass-through to `gitea.Client.GetCommitStatuses` after type mapping (see above).
### Compile-time check
```go
var _ vcs.Client = (*Adapter)(nil)
```
## Edge cases for diff-position parser
| Case | Behavior |
|------|----------|
| Binary file | No diff lines; skip (cannot comment on binary diff position) |
| New file (no context) | First `@@` is still position 1; count from there |
| Deleted file | Has diff lines but no new-file line numbers; treat like deletion lines |
| Position out of range | Return error |
| Deletion line targeted | Map to nearest non-deletion line below; if none, return error |
## Exit criteria
- `var _ vcs.Client = (*Adapter)(nil)` compiles
- All existing Gitea integration tests pass unchanged
- Unit tests for diff-position → line-number translation:
- Single hunk, multiple hunks
- Context, addition, deletion lines (deletions DO count toward position)
- New file, deleted file, binary file
- Deletion line targeted → maps to line below
- Out-of-range position → error
- Unit test for Event string translation: `"APPROVE"` → Gitea API receives `"APPROVED"` (not `"APPROVE"`)
- Unit tests for all field-mapping methods: `GetPullRequest`, `GetPullRequestFiles`, `ListReviews`, `GetCommitStatuses`
## Out of scope
- GitHub client (issues #80, #81)
- CLI flag changes (issue #82)
Update: The Reviewer interface now includes DismissReview (see #81). For the Gitea adapter, implement DismissReview as a call to the underlying gitea.Client.DeleteReview — Gitea supports deletion of any review state, so dismissal and deletion are equivalent outcomes.
**Update:** The `Reviewer` interface now includes `DismissReview` (see #81). For the Gitea adapter, implement `DismissReview` as a call to the underlying `gitea.Client.DeleteReview` — Gitea supports deletion of any review state, so dismissal and deletion are equivalent outcomes.
Update from codebase audit (issue #82): The Gitea adapter needs two additional pass-through methods beyond PostReview:
GetFileContentAtRef → pass through to gitea.Client.GetFileContentRef
GetCommitStatuses → pass through to gitea.Client.GetCommitStatuses
See #78 update comment for the full interface signatures.
**Update from codebase audit (issue #82):** The Gitea adapter needs two additional pass-through methods beyond PostReview:
1. `GetFileContentAtRef` → pass through to `gitea.Client.GetFileContentRef`
2. `GetCommitStatuses` → pass through to `gitea.Client.GetCommitStatuses`
See #78 update comment for the full interface signatures.
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
Wrap
gitea.Clientin an adapter (gitea.Adapter) that satisfiesvcs.Client. The critical work is: (1) translating GitHub diff-position inReviewCommentto Gitea line numbers, and (2) translatingReviewRequest.Eventcanonical values to Gitea-native event strings.Background
review-bot uses GitHub diff-position as the canonical
ReviewCommentformat (path+position+commit_id, wherepositionis a 1-indexed offset from the@@hunk line). Gitea takes line numbers instead. The adapter is responsible for this translation.ReviewRequest.Eventuses GitHub canonical values ("APPROVE","REQUEST_CHANGES","COMMENT"). Gitea uses different strings. The adapter must translate.The
vcs.Clientinterface and all types live invcs/(created in issue #78).Work
gitea/adapter.goAlias strategy
Before implementing any method, check whether the gitea type can be aliased to the vcs type (see issue #78 type alias strategy). If
gitea.ContentEntry = vcs.ContentEntryandgitea.CommitStatus = vcs.CommitStatusare in place,ListContentsandGetCommitStatusesbecome true pass-throughs. Only types with structural differences (e.g.gitea.PullRequestvsvcs.PullRequest,gitea.Reviewvsvcs.Review) require field mapping.Methods requiring mapping (not simple pass-through)
These methods require explicit type translation, not just delegation:
GetPullRequest— mapsgitea.PullRequest→vcs.PullRequest:(Pseudo-code; use the correct anonymous struct literal syntax for Go.)
GetPullRequestFiles— maps[]gitea.ChangedFile→[]vcs.ChangedFile:ListReviews— maps[]gitea.Review→[]vcs.Review:GetCommitStatuses— maps[]gitea.CommitStatus→[]vcs.CommitStatus:Pass-through methods
These delegate directly with no type translation (signature and return type already match):
GetPullRequestDiffGetFileContentGetAuthenticatedUserDeleteReviewListContentsPer the alias strategy (see issue #78):
gitea.ContentEntryshould be aliased tovcs.ContentEntry(type ContentEntry = vcs.ContentEntry). If that alias is in place,gitea.Client.ListContentsalready satisfies the interface with no adapter wrapper needed — it becomes a true pass-through.If for any reason the alias cannot be applied (e.g. a conflicting field), fall back to a field-by-field map (Name, Path, Type — all strings). Add a unit test either way.
PostReviewtranslationTwo translations required:
1. Event string — translate
ReviewRequest.Eventfrom GitHub canonical to Gitea-native:ReviewRequest.Event(canonical)"APPROVE""APPROVED""REQUEST_CHANGES""REQUEST_CHANGES""COMMENT""COMMENT"Note:
"APPROVE"(no D) →"APPROVED"(with D) is the critical translation.gitea.Client.PostReviewpasses the event string verbatim to the Gitea API, which requires"APPROVED".Unit test: verify
"APPROVE"produces a Gitea API call with event"APPROVED".2. Diff-position → line number — for each
ReviewComment.Position:GetPullRequestDiffposition → new-file line numbermap per fileReviewComment.Positionto the corresponding file line numberCommitIDfromReviewComment.CommitID(pass through to Gitea)Diff-position counting rules (GitHub spec)
Position is a per-file counter. For each file in the diff:
@@hunk header line is position 1@@hunk within the same file continues incrementing (does not reset)Example:
DismissReviewGetFileContentAtRefPass-through to
gitea.Client.GetFileContentRef:GetCommitStatusesPass-through to
gitea.Client.GetCommitStatusesafter type mapping (see above).Compile-time check
Edge cases for diff-position parser
@@is still position 1; count from thereExit criteria
var _ vcs.Client = (*Adapter)(nil)compiles"APPROVE"→ Gitea API receives"APPROVED"(not"APPROVE")GetPullRequest,GetPullRequestFiles,ListReviews,GetCommitStatusesOut of scope
Update: The
Reviewerinterface now includesDismissReview(see #81). For the Gitea adapter, implementDismissReviewas a call to the underlyinggitea.Client.DeleteReview— Gitea supports deletion of any review state, so dismissal and deletion are equivalent outcomes.Update from codebase audit (issue #82): The Gitea adapter needs two additional pass-through methods beyond PostReview:
GetFileContentAtRef→ pass through togitea.Client.GetFileContentRefGetCommitStatuses→ pass through togitea.Client.GetCommitStatusesSee #78 update comment for the full interface signatures.