Compare commits
4 Commits
3d0ca57a5f
...
bc5a4a1dcd
| Author | SHA1 | Date | |
|---|---|---|---|
| bc5a4a1dcd | |||
| d30f3d4278 | |||
| 2507ee22e7 | |||
| c39845ca03 |
@@ -344,6 +344,18 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Self-request as reviewer (ensures we appear in required-reviewer checks)
|
||||||
|
authUser, err := giteaClient.GetAuthenticatedUser(ctx)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("could not determine authenticated user for reviewer self-request", "error", err)
|
||||||
|
} else if authUser != "" {
|
||||||
|
if err := giteaClient.RequestReviewer(ctx, owner, repoName, prNumber, authUser); err != nil {
|
||||||
|
slog.Warn("could not self-request as reviewer", "user", authUser, "error", err)
|
||||||
|
} else {
|
||||||
|
slog.Debug("self-requested as reviewer", "user", authUser, "pr", prNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST new review
|
// POST new review
|
||||||
slog.Info("posting review", "event", event, "pr", prNumber)
|
slog.Info("posting review", "event", event, "pr", prNumber)
|
||||||
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
|
posted, err := giteaClient.PostReview(ctx, owner, repoName, prNumber, event, reviewBody, inlineComments)
|
||||||
@@ -357,10 +369,39 @@ func main() {
|
|||||||
if existingReview != nil && existingCommentID > 0 {
|
if existingReview != nil && existingCommentID > 0 {
|
||||||
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*giteaURL, "/"), owner, repoName, prNumber, posted.ID)
|
newReviewURL := fmt.Sprintf("%s/%s/%s/pulls/%d#pullrequestreview-%d", strings.TrimRight(*giteaURL, "/"), owner, repoName, prNumber, posted.ID)
|
||||||
supersededBody := buildSupersededBody(existingReview.Body, existingReview.CommitID, newReviewURL, sentinel)
|
supersededBody := buildSupersededBody(existingReview.Body, existingReview.CommitID, newReviewURL, sentinel)
|
||||||
|
supersedeOK := false
|
||||||
if err := giteaClient.EditComment(ctx, owner, repoName, existingCommentID, supersededBody); err != nil {
|
if err := giteaClient.EditComment(ctx, owner, repoName, existingCommentID, supersededBody); err != nil {
|
||||||
slog.Warn("could not mark old review as superseded", "comment_id", existingCommentID, "error", err)
|
slog.Warn("could not mark old review as superseded", "comment_id", existingCommentID, "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("marked old review as superseded", "old_state", existingReview.State, "new_review_id", posted.ID, "pr", prNumber)
|
slog.Info("marked old review as superseded", "old_state", existingReview.State, "new_review_id", posted.ID, "pr", prNumber)
|
||||||
|
supersedeOK = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve old review's inline comments only after successful supersede
|
||||||
|
if supersedeOK {
|
||||||
|
oldComments, err := giteaClient.ListReviewComments(ctx, owner, repoName, prNumber, existingReview.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("could not list old review comments for resolution", "review_id", existingReview.ID, "error", err)
|
||||||
|
} else {
|
||||||
|
resolved, failed := 0, 0
|
||||||
|
for _, c := range oldComments {
|
||||||
|
if c.ID == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := giteaClient.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", "count", resolved, "pr", prNumber)
|
||||||
|
}
|
||||||
|
if failed > 0 {
|
||||||
|
slog.Warn("some inline comments could not be resolved", "failed", failed, "pr", prNumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+111
@@ -82,6 +82,7 @@ type ChangedFile struct {
|
|||||||
|
|
||||||
// ReviewComment represents an inline comment to attach to a review.
|
// ReviewComment represents an inline comment to attach to a review.
|
||||||
type ReviewComment struct {
|
type ReviewComment struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
NewPosition int64 `json:"new_position"`
|
NewPosition int64 `json:"new_position"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
@@ -460,3 +461,113 @@ func (c *Client) EditComment(ctx context.Context, owner, repo string, commentID
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetAuthenticatedUser returns the login of the user authenticated by the token.
|
||||||
|
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/user", c.baseURL)
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get authenticated user: %w", err)
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parse user response: %w", err)
|
||||||
|
}
|
||||||
|
return result.Login, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestReviewer adds the given user as a requested reviewer on a pull request.
|
||||||
|
// This is idempotent — requesting an already-requested reviewer is a no-op.
|
||||||
|
func (c *Client) RequestReviewer(ctx context.Context, owner, repo string, number int, reviewer string) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/requested_reviewers",
|
||||||
|
c.baseURL,
|
||||||
|
url.PathEscape(owner),
|
||||||
|
url.PathEscape(repo),
|
||||||
|
number)
|
||||||
|
|
||||||
|
payload := struct {
|
||||||
|
Reviewers []string `json:"reviewers"`
|
||||||
|
}{Reviewers: []string{reviewer}}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal reviewer request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create reviewer request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("request reviewer: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
|
return fmt.Errorf("request reviewer failed (status %d): %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListReviewComments returns the inline comments attached to a specific review.
|
||||||
|
// Paginates through all pages.
|
||||||
|
func (c *Client) ListReviewComments(ctx context.Context, owner, repo string, prNumber int, reviewID int64) ([]ReviewComment, error) {
|
||||||
|
const pageSize = 50
|
||||||
|
var all []ReviewComment
|
||||||
|
for page := 1; ; page++ {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews/%d/comments?limit=%d&page=%d",
|
||||||
|
c.baseURL,
|
||||||
|
url.PathEscape(owner),
|
||||||
|
url.PathEscape(repo),
|
||||||
|
prNumber,
|
||||||
|
reviewID,
|
||||||
|
pageSize,
|
||||||
|
page)
|
||||||
|
body, err := c.doGet(ctx, reqURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list review comments (page %d): %w", page, err)
|
||||||
|
}
|
||||||
|
var batch []ReviewComment
|
||||||
|
if err := json.Unmarshal(body, &batch); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse review comments (page %d): %w", page, err)
|
||||||
|
}
|
||||||
|
all = append(all, batch...)
|
||||||
|
if len(batch) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveComment marks an inline review comment as resolved.
|
||||||
|
func (c *Client) ResolveComment(ctx context.Context, owner, repo string, commentID int64) error {
|
||||||
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/comments/%d/resolve",
|
||||||
|
c.baseURL,
|
||||||
|
url.PathEscape(owner),
|
||||||
|
url.PathEscape(repo),
|
||||||
|
commentID)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create resolve request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "token "+c.token)
|
||||||
|
|
||||||
|
resp, err := c.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolve comment: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
|
return fmt.Errorf("resolve comment failed (status %d): %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -602,3 +604,142 @@ func TestIsNotFound(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetAuthenticatedUser(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/user" {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "token test-token" {
|
||||||
|
t.Error("missing or wrong auth header")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"login":"my-bot","id":42}`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
login, err := client.GetAuthenticatedUser(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetAuthenticatedUser() error = %v", err)
|
||||||
|
}
|
||||||
|
if login != "my-bot" {
|
||||||
|
t.Errorf("login = %q, want %q", login, "my-bot")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestReviewer(t *testing.T) {
|
||||||
|
var gotBody []byte
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
expected := "/api/v1/repos/owner/repo/pulls/7/requested_reviewers"
|
||||||
|
if r.URL.Path != expected {
|
||||||
|
t.Errorf("path = %q, want %q", r.URL.Path, expected)
|
||||||
|
}
|
||||||
|
gotBody, _ = io.ReadAll(r.Body)
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 7, "bot-user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RequestReviewer() error = %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(gotBody), `"bot-user"`) {
|
||||||
|
t.Errorf("body = %s, want to contain bot-user", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestReviewer_204(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RequestReviewer() should accept 204, got error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestReviewer_Error(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
fmt.Fprint(w, "no permission")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
err := client.RequestReviewer(context.Background(), "owner", "repo", 1, "user")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 403 response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "403") {
|
||||||
|
t.Errorf("error should mention status code: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListReviewComments(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.Contains(r.URL.Path, "/pulls/1/reviews/42/comments") {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `[{"id":100,"path":"main.go","new_position":5,"body":"finding"},{"id":101,"path":"lib.go","new_position":10,"body":"another"}]`)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
comments, err := client.ListReviewComments(context.Background(), "owner", "repo", 1, 42)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListReviewComments() error = %v", err)
|
||||||
|
}
|
||||||
|
if len(comments) != 2 {
|
||||||
|
t.Fatalf("got %d comments, want 2", len(comments))
|
||||||
|
}
|
||||||
|
if comments[0].ID != 100 {
|
||||||
|
t.Errorf("comments[0].ID = %d, want 100", comments[0].ID)
|
||||||
|
}
|
||||||
|
if comments[1].Path != "lib.go" {
|
||||||
|
t.Errorf("comments[1].Path = %q, want %q", comments[1].Path, "lib.go")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveComment(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
t.Errorf("expected POST, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if !strings.Contains(r.URL.Path, "/pulls/comments/99/resolve") {
|
||||||
|
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveComment() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveComment_Error(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprint(w, "not found")
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
err := client.ResolveComment(context.Background(), "owner", "repo", 99)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for 404 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user