feat(cmd): wire --provider and --base-url flags into CLI (Phase 5) #106
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -85,16 +84,8 @@ func main() {
|
|||||||
aicoreAPIURL := flag.String("aicore-api-url", envOrDefault("AICORE_API_URL", ""), "SAP AI Core API URL (for provider=aicore)")
|
aicoreAPIURL := flag.String("aicore-api-url", envOrDefault("AICORE_API_URL", ""), "SAP AI Core API URL (for provider=aicore)")
|
||||||
|
|
|||||||
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
|
aicoreResourceGroup := flag.String("aicore-resource-group", envOrDefault("AICORE_RESOURCE_GROUP", "default"), "SAP AI Core resource group (for provider=aicore)")
|
||||||
|
rodin marked this conversation as resolved
sonnet-review-bot
commented
[NIT] The **[NIT]** The `--gitea-url` alias comment block is long (12 lines) and explains a subtle flag.StringVar trick. Consider extracting the alias registration to a small helper or at minimum shortening the comment to the key invariant: `flag.StringVar shares the pointer, so whichever flag is set last wins.` The current comment is accurate but its length makes it easy to miss the ORDERING constraint.
|
|||||||
|
|
||||||
// Register --gitea-url as a backward-compatible alias for --vcs-url.
|
// Backward-compatible alias: --gitea-url shares vcsURL's pointer (last flag wins).
|
||||||
|
sonnet-review-bot
commented
[MINOR] The **[MINOR]** The `--gitea-url` backward-compatible alias is registered with `flag.StringVar(vcsURL, "gitea-url", *vcsURL, ...)` where `*vcsURL` is the default value captured at the moment of the call — before `flag.Parse()`. This is correct as written because `*vcsURL` is still the default string at that point, but it creates a subtle ordering dependency: if `vcsURL` ever acquired a non-default value between its declaration and this `flag.StringVar` call (e.g., if any code ran between them), the alias would get a stale default. The comment acknowledges this (`Must stay after vcsURL declaration and before flag.Parse()`), but the fragility is worth noting. A cleaner pattern would be `flag.StringVar(vcsURL, "gitea-url", "", "...")` or extracting the default value to a named constant.
|
|||||||
// StringVar shares the *string pointer with vcsURL, so whichever flag is
|
// Must stay after vcsURL declaration and before flag.Parse().
|
||||||
// set last by flag.Parse wins — both point to the same underlying value.
|
|
||||||
// NOTE: If a user passes both --vcs-url and --gitea-url, the last one on
|
|
||||||
// the command line takes effect (standard flag package behavior). This is
|
|
||||||
// acceptable since --gitea-url is deprecated and both serve the same purpose.
|
|
||||||
//
|
|
||||||
// ORDERING: This must remain AFTER vcsURL's flag.String declaration and BEFORE
|
|
||||||
// flag.Parse(). The *vcsURL dereference captures the env-var-resolved default
|
|
||||||
// at registration time; moving flag.Parse() above this line would break it.
|
|
||||||
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
|
flag.StringVar(vcsURL, "gitea-url", *vcsURL, "Deprecated: use --vcs-url instead")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -110,10 +101,8 @@ func main() {
|
|||||||
slog.Info("review-bot starting", "version", version)
|
slog.Info("review-bot starting", "version", version)
|
||||||
|
|
||||||
// Validate VCS provider
|
// Validate VCS provider
|
||||||
switch *provider {
|
vcsProvider := vcs.VCSProvider(*provider)
|
||||||
case "gitea", "github":
|
if !vcsProvider.Valid() {
|
||||||
// valid
|
|
||||||
default:
|
|
||||||
fmt.Fprintf(os.Stderr, "Error: invalid --provider %q (valid: gitea, github)\n", *provider)
|
fmt.Fprintf(os.Stderr, "Error: invalid --provider %q (valid: gitea, github)\n", *provider)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -126,7 +115,7 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
// --vcs-url is required only for gitea provider
|
// --vcs-url is required only for gitea provider
|
||||||
if *provider == "gitea" && *vcsURL == "" {
|
if vcsProvider == vcs.ProviderGitea && *vcsURL == "" {
|
||||||
fmt.Fprintf(os.Stderr, "Error: --vcs-url (or --gitea-url) is required for provider=gitea\n")
|
fmt.Fprintf(os.Stderr, "Error: --vcs-url (or --gitea-url) is required for provider=gitea\n")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -169,18 +158,16 @@ func main() {
|
|||||||
|
|
||||||
// Initialize VCS client
|
// Initialize VCS client
|
||||||
var client vcs.Client
|
var client vcs.Client
|
||||||
switch *provider {
|
switch vcsProvider {
|
||||||
case "gitea":
|
case vcs.ProviderGitea:
|
||||||
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
|
giteaClient := gitea.NewClient(*vcsURL, *reviewerToken)
|
||||||
client = gitea.NewAdapter(giteaClient)
|
client = gitea.NewAdapter(giteaClient)
|
||||||
case "github":
|
case vcs.ProviderGithub:
|
||||||
ghBaseURL := *baseURL
|
client = github.NewClient(*reviewerToken, *baseURL)
|
||||||
if ghBaseURL == "" {
|
default:
|
||||||
ghBaseURL = "https://api.github.com"
|
panic("unreachable: provider validation should have caught " + vcsProvider.String())
|
||||||
}
|
|
||||||
client = github.NewClient(*reviewerToken, ghBaseURL)
|
|
||||||
}
|
}
|
||||||
slog.Info("VCS client initialized", "provider", *provider)
|
slog.Info("VCS client initialized", "provider", vcsProvider)
|
||||||
|
gpt-review-bot
commented
[MINOR] Default case in the VCS provider switch uses panic("unreachable..."). Repository conventions state "never panic"; prefer logging and exiting with a non-zero status to enforce unreachable conditions. **[MINOR]** Default case in the VCS provider switch uses panic("unreachable..."). Repository conventions state "never panic"; prefer logging and exiting with a non-zero status to enforce unreachable conditions.
|
|||||||
|
|
||||||
// Initialize LLM client
|
// Initialize LLM client
|
||||||
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
|
llmClient := llm.NewClient(*llmBaseURL, *llmAPIKey, *llmModel)
|
||||||
@@ -506,11 +493,15 @@ func main() {
|
|||||||
}
|
}
|
||||||
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
|
slog.Info("review posted", "review_id", posted.ID, "user", posted.User.Login, "pr", prNumber)
|
||||||
|
|
||||||
// Supersede all old reviews
|
// Supersede all old reviews via optional interface
|
||||||
if len(oldReviews) > 0 {
|
if len(oldReviews) > 0 {
|
||||||
if err := supersedeOldReviews(ctx, client, *provider, *vcsURL, owner, repoName, prNumber, oldReviews, posted.ID, sentinel); err != nil {
|
if superseder, ok := client.(vcs.ReviewSuperseder); ok {
|
||||||
slog.Error("failed to supersede old reviews", "error", err)
|
if err := superseder.SupersedeReviews(ctx, owner, repoName, prNumber, oldReviews, posted.ID, *vcsURL, sentinel); err != nil {
|
||||||
|
sonnet-review-bot
commented
[MINOR] When **[MINOR]** When `client.(vcs.ReviewSuperseder)` returns false and `len(oldReviews) > 0`, the code logs a warning and silently skips superseding. For the GitHub provider, `github.Client` implements `ReviewSuperseder`, so this path is unreachable. For Gitea, `gitea.Adapter` also implements it. The warning will therefore never fire for any configured provider, making it dead code in practice. It's harmless, but the `slog.Warn` may mislead operators if a future provider is wired without implementing the interface — they'd see a warning but no error. Consider whether this should be an error for unknown cases.
|
|||||||
os.Exit(1)
|
slog.Error("failed to supersede old reviews", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("provider does not support review superseding", "provider", vcsProvider)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -527,88 +518,6 @@ func verdictToEvent(verdict string) vcs.ReviewEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// supersedeOldReviews marks prior reviews as superseded so only the latest review is visible.
|
|
||||||
// For GitHub: dismisses old reviews (vcsURL is unused in this path).
|
|
||||||
// For Gitea: edits the review body with a link to the new review and resolves inline comments.
|
|
||||||
//
|
|
||||||
// The vcsURL parameter is only used in the Gitea path to construct review permalink URLs;
|
|
||||||
// it is accepted unconditionally to keep the function signature uniform across providers.
|
|
||||||
func supersedeOldReviews(ctx context.Context, client vcs.Client, provider, vcsURL, owner, repoName string, prNumber int, oldReviews []vcs.Review, newReviewID int64, sentinel string) error {
|
|
||||||
switch provider {
|
|
||||||
case "github":
|
|
||||||
// Best-effort dismissal: attempt all reviews, join any errors.
|
|
||||||
var errs []error
|
|
||||||
for _, old := range oldReviews {
|
|
||||||
if err := client.DismissReview(ctx, owner, repoName, prNumber, old.ID, "Superseded by new review"); err != nil {
|
|
||||||
slog.Warn("failed to dismiss review", "id", old.ID, "error", err)
|
|
||||||
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
|
|
||||||
} else {
|
|
||||||
slog.Info("dismissed old review", "review_id", old.ID, "new_review_id", newReviewID, "pr", prNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return errors.Join(errs...)
|
|
||||||
case "gitea":
|
|
||||||
// Continue to Gitea-specific logic below the switch.
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("supersedeOldReviews: unsupported provider %q", provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The type assertion below is guaranteed to succeed: the caller's provider switch
|
|
||||||
// ensures we only reach this point when provider == "gitea", and the gitea provider
|
|
||||||
// always constructs a *gitea.Adapter. The !ok branch guards against future refactors
|
|
||||||
// (e.g. wrapping the adapter in a decorator) that would silently break this path.
|
|
||||||
giteaAdapter, ok := client.(*gitea.Adapter)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("expected gitea.Adapter for gitea provider, got %T", client)
|
|
||||||
}
|
|
||||||
underlying := giteaAdapter.Underlying()
|
|
||||||
|
|
||||||
// Validate vcsURL scheme before embedding in Markdown link (defense-in-depth).
|
|
||||||
if !strings.HasPrefix(vcsURL, "http://") && !strings.HasPrefix(vcsURL, "https://") {
|
|
||||||
return fmt.Errorf("supersedeOldReviews: vcsURL must have http or https scheme, got %q", vcsURL)
|
|
||||||
}
|
|
||||||
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(vcsURL, "/"), owner, repoName, prNumber, newReviewID)
|
|
||||||
for _, oldReview := range oldReviews {
|
|
||||||
cid, err := underlying.GetTimelineReviewCommentIDForReview(ctx, owner, repoName, prNumber, oldReview.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
|
|
||||||
if err := underlying.EditComment(ctx, owner, repoName, cid, supersededBody); err != nil {
|
|
||||||
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "comment_id", cid, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
slog.Info("marked old review as superseded", "review_id", oldReview.ID, "new_review_id", newReviewID, "pr", prNumber)
|
|
||||||
|
|
||||||
// Resolve old review's inline comments
|
|
||||||
oldComments, err := underlying.ListReviewComments(ctx, owner, repoName, prNumber, oldReview.ID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
resolved, failed := 0, 0
|
|
||||||
for _, c := range oldComments {
|
|
||||||
if c.ID == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := underlying.ResolveComment(ctx, owner, repoName, c.ID); err != nil {
|
|
||||||
slog.Debug("could not resolve inline comment", "comment_id", c.ID, "error", err)
|
|
||||||
failed++
|
|
||||||
} else {
|
|
||||||
resolved++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if resolved > 0 {
|
|
||||||
slog.Info("resolved old inline comments", "review_id", oldReview.ID, "count", resolved, "pr", prNumber)
|
|
||||||
}
|
|
||||||
if failed > 0 {
|
|
||||||
slog.Warn("some inline comments could not be resolved", "review_id", oldReview.ID, "failed", failed, "pr", prNumber)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchFileContext fetches the full content of modified files from the PR branch.
|
// fetchFileContext fetches the full content of modified files from the PR branch.
|
||||||
func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref string, files []vcs.ChangedFile) string {
|
func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref string, files []vcs.ChangedFile) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
@@ -636,7 +545,7 @@ func fetchFileContext(ctx context.Context, client vcs.PRReader, owner, repo, ref
|
|||||||
// patternsRepo is comma-separated list of owner/name repos.
|
// patternsRepo is comma-separated list of owner/name repos.
|
||||||
// patternsFiles is comma-separated list of file paths or directories.
|
// patternsFiles is comma-separated list of file paths or directories.
|
||||||
// If a path ends with / or is a directory, all files within it are fetched recursively.
|
// If a path ends with / or is a directory, all files within it are fetched recursively.
|
||||||
// If patternsFiles is empty, all files from the repo root are fetched.
|
// Empty entries in patternsFiles are skipped (no implicit repo-root fetch).
|
||||||
func fetchPatterns(ctx context.Context, client vcs.FileReader, patternsRepo, patternsFiles string) string {
|
func fetchPatterns(ctx context.Context, client vcs.FileReader, patternsRepo, patternsFiles string) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
@@ -812,30 +721,7 @@ func validateWorkspacePath(path, pathName string) (string, error) {
|
|||||||
return resolvedPath, nil
|
return resolvedPath, nil
|
||||||
|
sonnet-review-bot
commented
[NIT] There is a dangling comment fragment: **[NIT]** There is a dangling comment fragment: `// with collapsed original content and the commit it was evaluated against.` — this is the second half of the `buildSupersededBody` doc comment that was removed when the function moved to `gitea/adapter.go`. The first line of the doc comment (`// buildSupersededBody creates the body for a superseded review: struck-through banner`) was deleted but this trailing line was left orphaned. It should be removed.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSupersededBody creates the body for a superseded review: struck-through banner
|
|
||||||
// with collapsed original content and the commit it was evaluated against.
|
// with collapsed original content and the commit it was evaluated against.
|
||||||
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
|
|
||||||
shortSHA := commitSHA
|
|
||||||
if len(shortSHA) > 8 {
|
|
||||||
shortSHA = shortSHA[:8]
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("~~Original review~~\n\n")
|
|
||||||
sb.WriteString("**Superseded** \u2014 [see current review](")
|
|
||||||
sb.WriteString(newReviewURL)
|
|
||||||
sb.WriteString(") for up-to-date findings.\n\n")
|
|
||||||
if shortSHA != "" {
|
|
||||||
sb.WriteString("<details><summary>Previous findings (commit ")
|
|
||||||
sb.WriteString(shortSHA)
|
|
||||||
sb.WriteString(")</summary>\n\n")
|
|
||||||
} else {
|
|
||||||
sb.WriteString("<details><summary>Previous findings</summary>\n\n")
|
|
||||||
}
|
|
||||||
sb.WriteString(originalBody)
|
|
||||||
sb.WriteString("\n\n</details>\n\n")
|
|
||||||
sb.WriteString(sentinel)
|
|
||||||
return sb.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasSharedToken detects if another review-bot role posted under the same
|
// hasSharedToken detects if another review-bot role posted under the same
|
||||||
// VCS user. This indicates misconfiguration where two roles share a token
|
// VCS user. This indicates misconfiguration where two roles share a token
|
||||||
|
|||||||
@@ -162,54 +162,6 @@ func makeReview(id int64, login, state string, stale bool, body string) vcs.Revi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildSupersededBody(t *testing.T) {
|
|
||||||
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
|
|
||||||
sentinel := "<!-- review-bot:sonnet -->"
|
|
||||||
newURL := "https://gitea.example.com/owner/repo/pulls/1#pullrequestreview-99"
|
|
||||||
|
|
||||||
result := buildSupersededBody(original, "abcdef1234567890", newURL, sentinel)
|
|
||||||
|
|
||||||
// Should contain the struck-through banner
|
|
||||||
if !strings.Contains(result, "~~Original review~~") {
|
|
||||||
t.Error("missing struck-through banner")
|
|
||||||
}
|
|
||||||
// Should contain superseded notice with link
|
|
||||||
if !strings.Contains(result, "**Superseded**") {
|
|
||||||
t.Error("missing superseded notice")
|
|
||||||
}
|
|
||||||
if !strings.Contains(result, "[see current review]("+newURL+")") {
|
|
||||||
t.Error("missing link to new review")
|
|
||||||
}
|
|
||||||
// Should contain collapsed original
|
|
||||||
if !strings.Contains(result, "<details>") {
|
|
||||||
t.Error("missing details/collapse")
|
|
||||||
}
|
|
||||||
// Should contain short commit SHA
|
|
||||||
if !strings.Contains(result, "abcdef12") {
|
|
||||||
t.Error("missing short SHA")
|
|
||||||
}
|
|
||||||
// Should NOT contain full SHA
|
|
||||||
if strings.Contains(result, "abcdef1234567890") {
|
|
||||||
t.Error("should truncate SHA to 8 chars")
|
|
||||||
}
|
|
||||||
// Should contain the original body inside details
|
|
||||||
if !strings.Contains(result, original) {
|
|
||||||
t.Error("original body not preserved in collapsed section")
|
|
||||||
}
|
|
||||||
// Should end with sentinel
|
|
||||||
if !strings.Contains(result, sentinel) {
|
|
||||||
t.Error("missing sentinel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBuildSupersededBodyShortSHA(t *testing.T) {
|
|
||||||
// Short SHA should pass through without panic
|
|
||||||
result := buildSupersededBody("body", "abc", "https://example.com/review", "<!-- review-bot:x -->")
|
|
||||||
if !strings.Contains(result, "abc") {
|
|
||||||
t.Error("short SHA not preserved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHasSharedToken(t *testing.T) {
|
func TestHasSharedToken(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.weiker.me/rodin/review-bot/vcs"
|
"gitea.weiker.me/rodin/review-bot/vcs"
|
||||||
)
|
)
|
||||||
@@ -237,3 +239,76 @@ func (a *Adapter) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
|||||||
func (a *Adapter) RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error {
|
func (a *Adapter) RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error {
|
||||||
return a.client.RequestReviewer(ctx, owner, repo, number, user)
|
return a.client.RequestReviewer(ctx, owner, repo, number, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile-time interface conformance assertion for ReviewSuperseder.
|
||||||
|
var _ vcs.ReviewSuperseder = (*Adapter)(nil)
|
||||||
|
|
||||||
|
// SupersedeReviews marks prior reviews as superseded by editing their body with a
|
||||||
|
// link to the new review and resolving their inline comments. This is Gitea-specific
|
||||||
|
// behavior that has no GitHub equivalent (GitHub uses DismissReview instead).
|
||||||
|
//
|
||||||
|
// baseURL is the Gitea instance URL used to construct review permalink URLs.
|
||||||
|
// sentinel is the HTML comment sentinel that identifies reviews belonging to this reviewer.
|
||||||
|
func (a *Adapter) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, baseURL, sentinel string) error {
|
||||||
|
// Validate baseURL scheme before embedding in Markdown link (defense-in-depth).
|
||||||
|
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
|
||||||
|
return fmt.Errorf("SupersedeReviews: baseURL must have http or https scheme, got %q", baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
sonnet-review-bot
commented
[NIT] **[NIT]** `SupersedeReviews` silently ignores resolution errors for individual inline comments via `_ = underlying.ResolveComment(...)`. The old code in `main.go` tracked `resolved` and `failed` counts and logged them. The new adapter drops that observability entirely. Consider at minimum logging at debug level on resolution failure, matching the pattern used for `EditComment` failures a few lines above.
|
|||||||
|
underlying := a.client
|
||||||
|
|
||||||
|
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d",
|
||||||
|
strings.TrimRight(baseURL, "/"), owner, repo, prNumber, newReviewID)
|
||||||
|
|
||||||
|
for _, oldReview := range oldReviews {
|
||||||
|
cid, err := underlying.GetTimelineReviewCommentIDForReview(ctx, owner, repo, prNumber, oldReview.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("could not find comment ID for old review", "review_id", oldReview.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
supersededBody := buildSupersededBody(oldReview.Body, oldReview.CommitID, newReviewURL, sentinel)
|
||||||
|
if err := underlying.EditComment(ctx, owner, repo, cid, supersededBody); err != nil {
|
||||||
|
slog.Warn("could not mark old review as superseded", "review_id", oldReview.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve old review's inline comments
|
||||||
|
oldComments, err := underlying.ListReviewComments(ctx, owner, repo, prNumber, oldReview.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("could not list old review comments for resolution", "review_id", oldReview.ID, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, c := range oldComments {
|
||||||
|
if c.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = underlying.ResolveComment(ctx, owner, repo, c.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSupersededBody creates the body for a superseded review: struck-through banner
|
||||||
|
// with collapsed original content and the commit it was evaluated against.
|
||||||
|
func buildSupersededBody(originalBody, commitSHA, newReviewURL, sentinel string) string {
|
||||||
|
shortSHA := commitSHA
|
||||||
|
if len(shortSHA) > 8 {
|
||||||
|
shortSHA = shortSHA[:8]
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("~~Original review~~\n\n")
|
||||||
|
sb.WriteString("**Superseded** \u2014 [see current review](")
|
||||||
|
sb.WriteString(newReviewURL)
|
||||||
|
sb.WriteString(") for up-to-date findings.\n\n")
|
||||||
|
if shortSHA != "" {
|
||||||
|
sb.WriteString("<details><summary>Previous findings (commit ")
|
||||||
|
sb.WriteString(shortSHA)
|
||||||
|
sb.WriteString(")</summary>\n\n")
|
||||||
|
} else {
|
||||||
|
sb.WriteString("<details><summary>Previous findings</summary>\n\n")
|
||||||
|
}
|
||||||
|
sb.WriteString(originalBody)
|
||||||
|
sb.WriteString("\n\n</details>\n\n")
|
||||||
|
sb.WriteString(sentinel)
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package gitea
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildSupersededBody(t *testing.T) {
|
||||||
|
original := "# Review\n\nLooks good.\n\n<!-- review-bot:sonnet -->"
|
||||||
|
sentinel := "<!-- review-bot:sonnet -->"
|
||||||
|
newURL := "https://gitea.example.com/owner/repo/pulls/1#pullrequestreview-99"
|
||||||
|
|
||||||
|
result := buildSupersededBody(original, "abcdef1234567890", newURL, sentinel)
|
||||||
|
|
||||||
|
// Should contain the struck-through banner
|
||||||
|
if !strings.Contains(result, "~~Original review~~") {
|
||||||
|
t.Error("missing struck-through banner")
|
||||||
|
}
|
||||||
|
// Should contain superseded notice with link
|
||||||
|
if !strings.Contains(result, "**Superseded**") {
|
||||||
|
t.Error("missing superseded notice")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "[see current review]("+newURL+")") {
|
||||||
|
t.Error("missing link to new review")
|
||||||
|
}
|
||||||
|
// Should contain collapsed original
|
||||||
|
if !strings.Contains(result, "<details>") {
|
||||||
|
t.Error("missing details/collapse")
|
||||||
|
}
|
||||||
|
// Should contain short commit SHA
|
||||||
|
if !strings.Contains(result, "abcdef12") {
|
||||||
|
t.Error("missing short SHA")
|
||||||
|
}
|
||||||
|
// Should NOT contain full SHA in summary (it's truncated to 8)
|
||||||
|
if strings.Contains(result, "abcdef1234567890") {
|
||||||
|
t.Error("should truncate SHA to 8 chars")
|
||||||
|
}
|
||||||
|
// Should contain the original body inside details
|
||||||
|
if !strings.Contains(result, original) {
|
||||||
|
t.Error("original body not preserved in collapsed section")
|
||||||
|
}
|
||||||
|
// Should end with sentinel
|
||||||
|
if !strings.Contains(result, sentinel) {
|
||||||
|
t.Error("missing sentinel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSupersededBodyShortSHA(t *testing.T) {
|
||||||
|
// Short SHA should pass through without panic
|
||||||
|
result := buildSupersededBody("body", "abc", "https://example.com/review", "<!-- review-bot:x -->")
|
||||||
|
if !strings.Contains(result, "abc") {
|
||||||
|
t.Error("short SHA not preserved")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,3 +9,6 @@ import (
|
|||||||
// This verifies github.Client satisfies the full vcs.Client interface
|
// This verifies github.Client satisfies the full vcs.Client interface
|
||||||
// (PRReader, FileReader, Reviewer, Identity).
|
// (PRReader, FileReader, Reviewer, Identity).
|
||||||
var _ vcs.Client = (*github.Client)(nil)
|
var _ vcs.Client = (*github.Client)(nil)
|
||||||
|
|
||||||
|
// Verify github.Client implements ReviewSuperseder.
|
||||||
|
var _ vcs.ReviewSuperseder = (*github.Client)(nil)
|
||||||
|
|||||||
@@ -210,3 +210,16 @@ func (c *Client) DismissReview(ctx context.Context, owner, repo string, number i
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SupersedeReviews marks prior reviews as superseded by dismissing them.
|
||||||
|
// This implements vcs.ReviewSuperseder for the GitHub adapter.
|
||||||
|
// The baseURL and sentinel parameters are unused for GitHub (dismissal is the mechanism).
|
||||||
|
func (c *Client) SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []vcs.Review, newReviewID int64, _, _ string) error {
|
||||||
|
var errs []error
|
||||||
|
for _, old := range oldReviews {
|
||||||
|
if err := c.DismissReview(ctx, owner, repo, prNumber, old.ID, "Superseded by new review"); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("dismiss review %d: %w", old.ID, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,3 +49,12 @@ type Client interface {
|
|||||||
type ReviewerSelfRequester interface {
|
type ReviewerSelfRequester interface {
|
||||||
RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error
|
RequestReviewerSelf(ctx context.Context, owner, repo string, number int, user string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReviewSuperseder is an optional interface implemented by adapters that support
|
||||||
|
// marking old reviews as superseded. For Gitea this means editing the review body
|
||||||
|
// with a link to the new review and resolving inline comments. For GitHub this
|
||||||
|
// means dismissing old reviews.
|
||||||
|
// Consumers should use interface assertion: if rs, ok := client.(ReviewSuperseder); ok { ... }
|
||||||
|
type ReviewSuperseder interface {
|
||||||
|
SupersedeReviews(ctx context.Context, owner, repo string, prNumber int, oldReviews []Review, newReviewID int64, baseURL, sentinel string) error
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package vcs
|
||||||
|
|
||||||
|
// VCSProvider identifies a VCS platform. Using a typed string instead of bare
|
||||||
|
// strings makes provider values compiler-checkable and prevents typos from
|
||||||
|
// silently passing validation.
|
||||||
|
type VCSProvider string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProviderGitea VCSProvider = "gitea"
|
||||||
|
ProviderGithub VCSProvider = "github"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Valid reports whether p is a known VCS provider.
|
||||||
|
func (p VCSProvider) Valid() bool {
|
||||||
|
switch p {
|
||||||
|
case ProviderGitea, ProviderGithub:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the provider.
|
||||||
|
func (p VCSProvider) String() string {
|
||||||
|
return string(p)
|
||||||
|
}
|
||||||
[MINOR] The --gitea-url alias registration uses
flag.StringVar(vcsURL, "gitea-url", *vcsURL, ...)after vcsURL has already been parsed from env vars. This means the default for the alias is whatever vcsURL resolved to at registration time, not a live binding. In practice this works fine because flag.Parse() hasn't run yet, but it's subtly fragile: if the env-var chain for vcsURL ever produces an empty string, the alias and the primary flag start with different defaults. A comment explaining this would help future maintainers.[NIT] The comment says the --gitea-url flag is a hidden alias, but it is registered normally and will appear in usage. Either adjust wording to 'deprecated alias' or consider not exposing it in help if truly intended to be hidden.
[MINOR] The
--gitea-urlbackward-compatible alias is registered withflag.StringVar(vcsURL, "gitea-url", *vcsURL, ...). This dereferences*vcsURLat flag registration time to capture the current default, which is correct. However, the comment says 'whichever flag is set last by flag.Parse wins' — this is accurate but the behavior may surprise users if both--vcs-urland--gitea-urlare passed simultaneously (last one wins rather than an error). This is a known limitation of theflagpackage alias approach and should ideally be documented in the flag usage or a warning emitted if both are set.Added a comment explaining the shared
*stringpointer mechanism.flag.StringVar(vcsURL, ...)binds--gitea-urlto the same underlying*stringas--vcs-url, so there's no divergence risk — both flags always resolve to the same value afterflag.Parse(). Comment added in7d6fe27.Updated the inline comment from "hidden alias" to "backward-compatible alias" in
7d6fe27. The flag does appear in--helpoutput (Go'sflagpackage doesn't support hiding flags), so "hidden" was inaccurate. "Backward-compatible" better describes the intent.[NIT] The comment
// Backward-compatible alias: --gitea-url shares vcsURL's pointer (last flag wins).is accurate but slightly misleading: withflag.StringVar(vcsURL, "gitea-url", *vcsURL, ...)the last parsed flag wins at parse time. If both--vcs-urland--gitea-urlare supplied, the rightmost one on the command line takes effect. This is the correct and intended behaviour, but a small clarification in the comment ("last supplied on the command line wins") would help future readers.