Outlines phased approach for GitHub support: - Phase 1: Port github/ package from strat fork - Phase 2: Add vcs/ interface with runtime detection - Phase 3: Wire up cmd/review-bot Issue: #76
6.6 KiB
VCS Abstraction Layer Design
Issue: #76
Problem
review-bot currently only works with Gitea. We want it to work on GitHub (github.com and GHE) without maintaining two forks.
Constraints
- Must be backward compatible — existing Gitea usage unchanged
- Runtime detection — no config flag needed
- Same types — gitea/ and github/ packages already use identical structs
- Minimal diff — don't rewrite, just abstract
Current State
Two packages exist:
gitea/in rodin/review-bot — Gitea APIgithub/in strat/review-bot — GitHub API (forked from gitea/, adjusted paths)
Method signatures are identical. Types are identical. Only API endpoints differ.
Proposed Approach
Phase 1: Port github/ package
Copy strat/review-bot's github/ package into rodin/review-bot.
Files:
github/client.gogithub/client_test.gogithub/diff.go
No changes to gitea/ package.
Phase 2: Add vcs/ abstraction
vcs/
interface.go # Client interface
client.go # NewClient() with detection
doc.go # package doc
interface.go:
package vcs
import "context"
// Client abstracts VCS operations. Implemented by gitea.Client and github.Client.
type Client 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)
GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error)
GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error)
GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error)
PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error)
ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error)
GetAllFilesInPath(ctx context.Context, owner, repo, path string) (map[string]string, error)
ListReviews(ctx context.Context, owner, repo string, number int) ([]Review, error)
DeleteReview(ctx context.Context, owner, repo string, number int, reviewID int64) error
GetAuthenticatedUser(ctx context.Context) (string, error)
}
// Types re-exported from gitea package (canonical source)
type (
PullRequest = gitea.PullRequest
ChangedFile = gitea.ChangedFile
CommitStatus = gitea.CommitStatus
ReviewComment = gitea.ReviewComment
ContentEntry = gitea.ContentEntry
Review = gitea.Review
)
client.go:
package vcs
import (
"strings"
"gitea.weiker.me/rodin/review-bot/gitea"
"gitea.weiker.me/rodin/review-bot/github"
)
// NewClient creates a VCS client based on the server URL.
// GitHub detection: github.com, api.github.com, or "github" in domain (GHE).
func NewClient(baseURL, token string) Client {
if isGitHub(baseURL) {
return github.NewClient(baseURL, token)
}
return gitea.NewClient(baseURL, token)
}
func isGitHub(baseURL string) bool {
lower := strings.ToLower(baseURL)
if strings.Contains(lower, "github.com") {
return true
}
// GHE: github.mycompany.com but NOT gitea.mycompany.com
if strings.Contains(lower, "github") && !strings.Contains(lower, "gitea") {
return true
}
return false
}
Phase 3: Update cmd/review-bot
Change from:
client := gitea.NewClient(baseURL, token)
To:
client := vcs.NewClient(baseURL, token)
All callers already use the interface implicitly (same method names). This is a one-line change.
API Differences Handled in github/
| Operation | Gitea | GitHub |
|---|---|---|
| API prefix | /api/v1/repos/ |
/repos/ (github.com uses api.github.com) |
| Get diff | GET /pulls/{n}.diff |
GET /pulls/{n} + Accept: application/vnd.github.diff |
| Auth header | Authorization: token X |
Authorization: Bearer X |
The github/ package already handles these. No new code needed.
Methods NOT in interface (Gitea-only)
These methods exist in gitea/ but aren't needed for core review flow:
GetTimelineReviewCommentID— Gitea-specific timeline APIGetTimelineReviewCommentIDForReview— Gitea-specificEditComment— supersede flow (Gitea-specific)ResolveComment— Gitea-specific
If a caller needs these, they can type-assert: if gc, ok := client.(*gitea.Client); ok { ... }
Testing Strategy
- Unit tests for
isGitHub()detection - Existing gitea/ tests unchanged
- Existing github/ tests from strat fork
- Integration test: mock server, verify correct client selected
Phases
| PR | Content | Exit Criteria |
|---|---|---|
| 1 | Port github/ package | go test ./github/... passes |
| 2 | Add vcs/ interface | go build ./... compiles |
| 3 | Update cmd/review-bot | All existing tests pass, GitHub detection works |
Open Questions
-
Type re-export vs duplicate — Using type aliases (
type PullRequest = gitea.PullRequest) means callers import vcs and get gitea types. Alternative: duplicate types in vcs/. Preference? -
github.com API base URL — For github.com, API is at
api.github.com, notgithub.com/api. The github/ package needs to handle this. Does strat's version already? -
GHE API path — GHE uses
/api/v3/repos/. Verify strat's github/ package handles this.
Answers to Open Questions
Q1: Type re-export vs duplicate
Answer: Type aliases.
- Keeps one source of truth (gitea/ package)
- If types ever diverge, we'll know at compile time
- Callers importing
vcsget clean namespace
Q2 & Q3: API URL handling
Answer: NewClient needs URL transformation.
The github/ package expects the API root:
- github.com →
https://api.github.com - GHE →
https://github.concur.com/api/v3
But callers pass server URLs (from github.server_url context):
- github.com →
https://github.com - GHE →
https://github.concur.com
Updated client.go:
func NewClient(serverURL, token string) Client {
if isGitHub(serverURL) {
apiURL := toGitHubAPIURL(serverURL)
return github.NewClient(apiURL, token)
}
return gitea.NewClient(serverURL, token)
}
func toGitHubAPIURL(serverURL string) string {
lower := strings.ToLower(serverURL)
if strings.Contains(lower, "github.com") && !strings.Contains(lower, "api.github.com") {
return "https://api.github.com"
}
// GHE: append /api/v3
return strings.TrimRight(serverURL, "/") + "/api/v3"
}
This keeps the github/ package unchanged (it already handles the API URL format).