Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d640eb6e71 | |||
| 2339999d37 | |||
| bfca28b2b2 | |||
| f047c994bf | |||
| b51a19d8b9 | |||
| ceefa4c2e0 | |||
| b1f5dd4b5f | |||
| fd179b891b | |||
| b78d9972ac | |||
| 3c785c5502 | |||
| c2595d0263 | |||
| d80d6a23a2 |
@@ -33,7 +33,7 @@ jobs:
|
|||||||
token_secret: GPT_REVIEW_TOKEN
|
token_secret: GPT_REVIEW_TOKEN
|
||||||
model: gpt-4.1
|
model: gpt-4.1
|
||||||
- name: security
|
- name: security
|
||||||
token_secret: SONNET_REVIEW_TOKEN
|
token_secret: SECURITY_REVIEW_TOKEN
|
||||||
model: gpt-5
|
model: gpt-5
|
||||||
system_prompt_file: SECURITY_REVIEW.md
|
system_prompt_file: SECURITY_REVIEW.md
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -69,14 +69,28 @@ jobs:
|
|||||||
|
|
||||||
echo "Release ID: ${RELEASE_ID}"
|
echo "Release ID: ${RELEASE_ID}"
|
||||||
|
|
||||||
# Upload each asset
|
# Upload each asset (idempotent: delete existing asset with same name first)
|
||||||
for file in dist/*; do
|
for file in dist/*; do
|
||||||
filename=$(basename "$file")
|
filename=$(basename "$file")
|
||||||
echo "Uploading ${filename}..."
|
echo "Uploading ${filename}..."
|
||||||
|
|
||||||
|
# Check if asset already exists and delete it
|
||||||
|
EXISTING_ID=$(export ASSET_NAME="${filename}"; curl -sS \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
|
||||||
|
| python3 -c "import json,sys,os; name=os.environ['ASSET_NAME']; assets=json.load(sys.stdin); print(next((str(a['id']) for a in assets if a['name']==name),''))" 2>/dev/null)
|
||||||
|
|
||||||
|
if [ -n "$EXISTING_ID" ]; then
|
||||||
|
echo " Asset ${filename} already exists (id=${EXISTING_ID}), deleting..."
|
||||||
|
curl -sSf -X DELETE \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
curl -sSf -X POST \
|
curl -sSf -X POST \
|
||||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
-H "Content-Type: application/octet-stream" \
|
-H "Content-Type: application/octet-stream" \
|
||||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}" \
|
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=$(printf '%s' "${filename}" | jq -sRr @uri)" \
|
||||||
--data-binary "@${file}"
|
--data-binary "@${file}"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
+47
-52
@@ -266,16 +266,13 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Warning: could not list existing reviews: %v", err)
|
log.Printf("Warning: could not list existing reviews: %v", err)
|
||||||
} else {
|
} else {
|
||||||
// Worst-wins: escalate if a sibling blocks (need own login from existing review)
|
// Detect shared-token misconfiguration: if detected, skip all
|
||||||
ownLogin := ""
|
// update logic (PATCH/supersede) to avoid clobbering a sibling's review.
|
||||||
|
sharedToken := hasSharedToken(existingReviews, sentinel)
|
||||||
|
if sharedToken {
|
||||||
|
log.Printf("Shared token mode: skipping update-in-place logic to avoid clobbering sibling review")
|
||||||
|
} else {
|
||||||
existing := findOwnReview(existingReviews, sentinel)
|
existing := findOwnReview(existingReviews, sentinel)
|
||||||
if existing != nil {
|
|
||||||
ownLogin = existing.User.Login
|
|
||||||
}
|
|
||||||
if event == "APPROVED" && shouldEscalate(existingReviews, 0, ownLogin, sentinel) {
|
|
||||||
log.Printf("Sibling review has REQUEST_CHANGES; escalating to REQUEST_CHANGES")
|
|
||||||
event = "REQUEST_CHANGES"
|
|
||||||
}
|
|
||||||
|
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
if reviewUnchanged(existingReviews, reviewBody, event, sentinel) {
|
if reviewUnchanged(existingReviews, reviewBody, event, sentinel) {
|
||||||
@@ -313,6 +310,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST new review (first run, or state transition fallthrough)
|
// POST new review (first run, or state transition fallthrough)
|
||||||
log.Printf("Posting review (event=%s)...", event)
|
log.Printf("Posting review (event=%s)...", event)
|
||||||
@@ -322,29 +320,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login)
|
log.Printf("Review posted (id=%d, user=%s)", posted.ID, posted.User.Login)
|
||||||
|
|
||||||
// Post-posting escalation: if we just posted APPROVED but a sibling
|
|
||||||
// from the same user has REQUEST_CHANGES, mark ours as superseded and
|
|
||||||
// re-post as REQUEST_CHANGES. This handles the first-run case where
|
|
||||||
// we don't know our login until after posting.
|
|
||||||
if event == "APPROVED" && *updateExisting && *reviewerName != "" {
|
|
||||||
reviews, err := giteaClient.ListReviews(ctx, owner, repoName, prNumber)
|
|
||||||
if err == nil && shouldEscalate(reviews, posted.ID, posted.User.Login, sentinel) {
|
|
||||||
log.Printf("Post-posting escalation: sibling has REQUEST_CHANGES")
|
|
||||||
// Mark our just-posted review as superseded
|
|
||||||
commentID, err := giteaClient.GetTimelineReviewCommentID(ctx, owner, repoName, prNumber, sentinel)
|
|
||||||
if err == nil {
|
|
||||||
supersededBody := fmt.Sprintf("~~*This review has been superseded by a newer review below.*~~\n\n%s", sentinel)
|
|
||||||
giteaClient.EditComment(ctx, owner, repoName, commentID, supersededBody)
|
|
||||||
}
|
|
||||||
// Re-post as REQUEST_CHANGES
|
|
||||||
_, err = giteaClient.PostReview(ctx, owner, repoName, prNumber, "REQUEST_CHANGES", reviewBody, inlineComments)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Warning: could not re-post as REQUEST_CHANGES: %v", err)
|
|
||||||
} else {
|
|
||||||
log.Printf("Review escalated to REQUEST_CHANGES")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchFileContext fetches the full content of modified files from the PR branch.
|
// fetchFileContext fetches the full content of modified files from the PR branch.
|
||||||
@@ -501,26 +476,6 @@ func validateReviewerName(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// shouldEscalate checks if any sibling bot review from the same user
|
|
||||||
// (different sentinel, same token) has REQUEST_CHANGES.
|
|
||||||
// ownLogin is the bot user login; if empty, escalation check is skipped.
|
|
||||||
// postedID is excluded from consideration (0 means no exclusion needed).
|
|
||||||
func shouldEscalate(reviews []gitea.Review, postedID int64, ownLogin, ownSentinel string) bool {
|
|
||||||
if ownLogin == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range reviews {
|
|
||||||
if r.ID == postedID || r.Stale {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Sibling = same user, has a review-bot sentinel, but not OUR sentinel
|
|
||||||
if r.User.Login == ownLogin && r.State == "REQUEST_CHANGES" && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// reviewUnchanged checks if an existing review with the same sentinel
|
// reviewUnchanged checks if an existing review with the same sentinel
|
||||||
// already has identical body and state. Returns true if a re-post would
|
// already has identical body and state. Returns true if a re-post would
|
||||||
// produce the same result (skip to preserve conversation threads).
|
// produce the same result (skip to preserve conversation threads).
|
||||||
@@ -539,6 +494,46 @@ func reviewUnchanged(reviews []gitea.Review, newBody, newEvent, sentinel string)
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hasSharedToken detects if another review-bot role posted under the same
|
||||||
|
// Gitea user. This indicates misconfiguration where two roles share a token
|
||||||
|
// instead of having separate Gitea accounts. Returns true if shared token
|
||||||
|
// detected (caller should skip update-in-place logic to avoid clobbering).
|
||||||
|
func hasSharedToken(reviews []gitea.Review, ownSentinel string) bool {
|
||||||
|
ownLogin := ""
|
||||||
|
for _, r := range reviews {
|
||||||
|
if strings.Contains(r.Body, ownSentinel) {
|
||||||
|
ownLogin = r.User.Login
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ownLogin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range reviews {
|
||||||
|
if r.User.Login == ownLogin && strings.Contains(r.Body, "<!-- review-bot:") && !strings.Contains(r.Body, ownSentinel) {
|
||||||
|
log.Printf("WARNING: shared token detected — another review-bot role (%s) is using the same Gitea user %q. Each role should have its own token/user for proper multi-reviewer blocking.", extractSentinelName(r.Body), ownLogin)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractSentinelName pulls the reviewer name from a sentinel comment.
|
||||||
|
func extractSentinelName(body string) string {
|
||||||
|
const prefix = "<!-- review-bot:"
|
||||||
|
const suffix = " -->"
|
||||||
|
idx := strings.Index(body, prefix)
|
||||||
|
if idx < 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
rest := body[idx+len(prefix):]
|
||||||
|
end := strings.Index(rest, suffix)
|
||||||
|
if end < 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
return rest[:end]
|
||||||
|
}
|
||||||
|
|
||||||
// findOwnReview locates a review matching the given sentinel in its body.
|
// findOwnReview locates a review matching the given sentinel in its body.
|
||||||
func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
|
func findOwnReview(reviews []gitea.Review, sentinel string) *gitea.Review {
|
||||||
for i := range reviews {
|
for i := range reviews {
|
||||||
|
|||||||
+80
-100
@@ -50,106 +50,6 @@ func makeReview(id int64, login, state string, stale bool, body string) gitea.Re
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestShouldEscalate(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
reviews []gitea.Review
|
|
||||||
postedID int64
|
|
||||||
ownLogin string
|
|
||||||
|
|
||||||
ownSentinel string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "no reviews",
|
|
||||||
reviews: nil,
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sibling same user has REQUEST_CHANGES",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(101, "bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:security -->"),
|
|
||||||
},
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sibling different user has REQUEST_CHANGES (should NOT escalate)",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(101, "other-bot", "REQUEST_CHANGES", false, "bad\n<!-- review-bot:gpt -->"),
|
|
||||||
},
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same user REQUEST_CHANGES but stale (should NOT escalate)",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(101, "bot", "REQUEST_CHANGES", true, "old\n<!-- review-bot:security -->"),
|
|
||||||
},
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same user same sentinel (own stale review, should NOT escalate)",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(101, "bot", "REQUEST_CHANGES", false, "old\n<!-- review-bot:sonnet -->"),
|
|
||||||
},
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "same user APPROVED sibling (should NOT escalate)",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(101, "bot", "APPROVED", false, "good\n<!-- review-bot:security -->"),
|
|
||||||
},
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "human REQUEST_CHANGES no sentinel (should NOT escalate)",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(101, "bot", "REQUEST_CHANGES", false, "please fix this"),
|
|
||||||
},
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "skip own posted ID",
|
|
||||||
reviews: []gitea.Review{
|
|
||||||
makeReview(100, "bot", "REQUEST_CHANGES", false, "x\n<!-- review-bot:security -->"),
|
|
||||||
},
|
|
||||||
postedID: 100,
|
|
||||||
ownLogin: "bot",
|
|
||||||
ownSentinel: "<!-- review-bot:sonnet -->",
|
|
||||||
want: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
got := shouldEscalate(tc.reviews, tc.postedID, tc.ownLogin, tc.ownSentinel)
|
|
||||||
if got != tc.want {
|
|
||||||
t.Errorf("shouldEscalate() = %v, want %v", got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestReviewUnchanged(t *testing.T) {
|
func TestReviewUnchanged(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -288,3 +188,83 @@ func TestFindOwnReview(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasSharedToken(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
reviews []gitea.Review
|
||||||
|
sentinel string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no reviews",
|
||||||
|
reviews: nil,
|
||||||
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no own review yet - cannot detect",
|
||||||
|
reviews: []gitea.Review{
|
||||||
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "other"}, Body: "<!-- review-bot:gpt --> body"},
|
||||||
|
},
|
||||||
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "separate users - no shared token",
|
||||||
|
reviews: []gitea.Review{
|
||||||
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
||||||
|
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "security-review-bot"}, Body: "<!-- review-bot:security --> body"},
|
||||||
|
},
|
||||||
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shared token detected - same user different sentinels",
|
||||||
|
reviews: []gitea.Review{
|
||||||
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
||||||
|
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "sonnet-review-bot"}, Body: "<!-- review-bot:security --> body"},
|
||||||
|
},
|
||||||
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "three roles same user",
|
||||||
|
reviews: []gitea.Review{
|
||||||
|
{ID: 1, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:sonnet --> body"},
|
||||||
|
{ID: 2, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:security --> body"},
|
||||||
|
{ID: 3, User: struct{ Login string `json:"login"` }{Login: "bot"}, Body: "<!-- review-bot:gpt --> body"},
|
||||||
|
},
|
||||||
|
sentinel: "<!-- review-bot:sonnet -->",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
got := hasSharedToken(tc.reviews, tc.sentinel)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("hasSharedToken() = %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractSentinelName(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"<!-- review-bot:sonnet --> rest", "sonnet"},
|
||||||
|
{"<!-- review-bot:security --> rest", "security"},
|
||||||
|
{"no sentinel here", "unknown"},
|
||||||
|
{"<!-- review-bot:gpt-review --> rest", "gpt-review"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
got := extractSentinelName(tc.body)
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("extractSentinelName(%q) = %q, want %q", tc.body, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-12
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -16,6 +17,28 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// APIError represents an HTTP error response from the Gitea API.
|
||||||
|
// It carries the status code so callers can distinguish between
|
||||||
|
// different failure modes (e.g. 404 vs 500).
|
||||||
|
type APIError struct {
|
||||||
|
StatusCode int
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *APIError) Error() string {
|
||||||
|
body := e.Body
|
||||||
|
if len(body) > 200 {
|
||||||
|
body = body[:200] + "...(truncated)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("HTTP %d: %s", e.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsNotFound reports whether an error is an API 404 response.
|
||||||
|
func IsNotFound(err error) bool {
|
||||||
|
var apiErr *APIError
|
||||||
|
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound
|
||||||
|
}
|
||||||
|
|
||||||
// Client interacts with the Gitea API.
|
// Client interacts with the Gitea API.
|
||||||
// A Client is safe for concurrent use by multiple goroutines.
|
// A Client is safe for concurrent use by multiple goroutines.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -66,7 +89,7 @@ type ReviewComment struct {
|
|||||||
|
|
||||||
// GetPullRequest fetches PR metadata.
|
// GetPullRequest fetches PR metadata.
|
||||||
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
|
func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number int) (*PullRequest, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.baseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch PR: %w", err)
|
return nil, fmt.Errorf("fetch PR: %w", err)
|
||||||
@@ -80,7 +103,7 @@ func (c *Client) GetPullRequest(ctx context.Context, owner, repo string, number
|
|||||||
|
|
||||||
// GetPullRequestDiff fetches the unified diff for a PR.
|
// GetPullRequestDiff fetches the unified diff for a PR.
|
||||||
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, number int) (string, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d.diff", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch diff: %w", err)
|
return "", fmt.Errorf("fetch diff: %w", err)
|
||||||
@@ -90,7 +113,7 @@ func (c *Client) GetPullRequestDiff(ctx context.Context, owner, repo string, num
|
|||||||
|
|
||||||
// GetPullRequestFiles fetches the list of files changed in a PR.
|
// GetPullRequestFiles fetches the list of files changed in a PR.
|
||||||
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, number int) ([]ChangedFile, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/files", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch PR files: %w", err)
|
return nil, fmt.Errorf("fetch PR files: %w", err)
|
||||||
@@ -104,7 +127,7 @@ func (c *Client) GetPullRequestFiles(ctx context.Context, owner, repo string, nu
|
|||||||
|
|
||||||
// GetCommitStatuses fetches CI statuses for a commit SHA.
|
// GetCommitStatuses fetches CI statuses for a commit SHA.
|
||||||
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
|
func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string) ([]CommitStatus, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.baseURL, owner, repo, sha)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/commits/%s/statuses", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha))
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("fetch commit statuses: %w", err)
|
return nil, fmt.Errorf("fetch commit statuses: %w", err)
|
||||||
@@ -118,7 +141,7 @@ func (c *Client) GetCommitStatuses(ctx context.Context, owner, repo, sha string)
|
|||||||
|
|
||||||
// GetFileContent fetches a file from the default branch of a repo.
|
// GetFileContent fetches a file from the default branch of a repo.
|
||||||
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath string) (string, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.baseURL, owner, repo, escapePath(filepath))
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath))
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
|
return "", fmt.Errorf("fetch file %s: %w", filepath, err)
|
||||||
@@ -128,7 +151,7 @@ func (c *Client) GetFileContent(ctx context.Context, owner, repo, filepath strin
|
|||||||
|
|
||||||
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo.
|
// GetFileContentRef fetches a file from a specific ref (branch/tag/sha) in a repo.
|
||||||
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, ref string) (string, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.baseURL, owner, repo, escapePath(filepath), url.QueryEscape(ref))
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/raw/%s?ref=%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(filepath), url.QueryEscape(ref))
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err)
|
return "", fmt.Errorf("fetch file %s@%s: %w", filepath, ref, err)
|
||||||
@@ -140,7 +163,7 @@ func (c *Client) GetFileContentRef(ctx context.Context, owner, repo, filepath, r
|
|||||||
// event should be "APPROVED" or "REQUEST_CHANGES".
|
// event should be "APPROVED" or "REQUEST_CHANGES".
|
||||||
// comments are optional inline comments attached to specific lines.
|
// comments are optional inline comments attached to specific lines.
|
||||||
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error) {
|
func (c *Client) PostReview(ctx context.Context, owner, repo string, number int, event, body string, comments []ReviewComment) (*Review, error) {
|
||||||
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, owner, repo, number)
|
reqURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/pulls/%d/reviews", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
|
||||||
|
|
||||||
payload := struct {
|
payload := struct {
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
@@ -201,7 +224,7 @@ func (c *Client) doGet(ctx context.Context, reqURL string) ([]byte, error) {
|
|||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
body, _ := io.ReadAll(resp.Body)
|
body, _ := io.ReadAll(resp.Body)
|
||||||
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
|
return nil, &APIError{StatusCode: resp.StatusCode, Body: string(body)}
|
||||||
}
|
}
|
||||||
return io.ReadAll(resp.Body)
|
return io.ReadAll(resp.Body)
|
||||||
}
|
}
|
||||||
@@ -230,9 +253,9 @@ type ContentEntry struct {
|
|||||||
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
func (c *Client) ListContents(ctx context.Context, owner, repo, path string) ([]ContentEntry, error) {
|
||||||
var reqURL string
|
var reqURL string
|
||||||
if path == "" {
|
if path == "" {
|
||||||
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents", c.baseURL, owner, repo)
|
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents", c.baseURL, url.PathEscape(owner), url.PathEscape(repo))
|
||||||
} else {
|
} else {
|
||||||
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, owner, repo, escapePath(path))
|
reqURL = fmt.Sprintf("%s/api/v1/repos/%s/%s/contents/%s", c.baseURL, url.PathEscape(owner), url.PathEscape(repo), escapePath(path))
|
||||||
}
|
}
|
||||||
body, err := c.doGet(ctx, reqURL)
|
body, err := c.doGet(ctx, reqURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -254,10 +277,15 @@ func (c *Client) GetAllFilesInPath(ctx context.Context, owner, repo, path string
|
|||||||
// Try listing as directory first
|
// Try listing as directory first
|
||||||
entries, err := c.ListContents(ctx, owner, repo, path)
|
entries, err := c.ListContents(ctx, owner, repo, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Might be a file, try fetching directly
|
// Only fall back to single-file fetch on 404 (path is a file, not a dir).
|
||||||
|
// Propagate all other errors (auth failures, server errors, rate limits).
|
||||||
|
if !IsNotFound(err) {
|
||||||
|
return nil, fmt.Errorf("list contents %q: %w", path, err)
|
||||||
|
}
|
||||||
|
// 404 means the path might be a file — try fetching directly
|
||||||
content, fileErr := c.GetFileContent(ctx, owner, repo, path)
|
content, fileErr := c.GetFileContent(ctx, owner, repo, path)
|
||||||
if fileErr != nil {
|
if fileErr != nil {
|
||||||
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, err)
|
return nil, fmt.Errorf("path %q is neither a file nor directory: %w", path, fileErr)
|
||||||
}
|
}
|
||||||
results[path] = content
|
results[path] = content
|
||||||
return results, nil
|
return results, nil
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gitea
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -505,3 +506,99 @@ func TestGetTimelineReviewCommentID_NotFound(t *testing.T) {
|
|||||||
t.Fatal("expected error when sentinel not found")
|
t.Fatal("expected error when sentinel not found")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetAllFilesInPath_404FallsBackToFile(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/owner/repo/contents/README.md":
|
||||||
|
// Contents API returns 404 for files (not a directory)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
case "/api/v1/repos/owner/repo/raw/README.md":
|
||||||
|
w.Write([]byte("# Hello\n"))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
w.Write([]byte(`{"message":"not found"}`))
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
files, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "README.md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected fallback to file on 404, got error: %v", err)
|
||||||
|
}
|
||||||
|
if len(files) != 1 {
|
||||||
|
t.Fatalf("expected 1 file, got %d", len(files))
|
||||||
|
}
|
||||||
|
if files["README.md"] != "# Hello\n" {
|
||||||
|
t.Errorf("unexpected content: %q", files["README.md"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllFilesInPath_500Propagates(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Simulate a server error from ListContents
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(`{"message":"internal server error"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "somepath")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error to propagate for 500, got nil")
|
||||||
|
}
|
||||||
|
// Should NOT fall back to file fetch — error should propagate
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("expected APIError in chain, got: %v", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != http.StatusInternalServerError {
|
||||||
|
t.Errorf("expected status 500, got %d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAllFilesInPath_403Propagates(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte(`{"message":"token has insufficient scope"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewClient(server.URL, "test-token")
|
||||||
|
_, err := client.GetAllFilesInPath(context.Background(), "owner", "repo", "private/stuff")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error to propagate for 403, got nil")
|
||||||
|
}
|
||||||
|
var apiErr *APIError
|
||||||
|
if !errors.As(err, &apiErr) {
|
||||||
|
t.Fatalf("expected APIError in chain, got: %v", err)
|
||||||
|
}
|
||||||
|
if apiErr.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("expected status 403, got %d", apiErr.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsNotFound(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"nil error", nil, false},
|
||||||
|
{"non-API error", fmt.Errorf("network timeout"), false},
|
||||||
|
{"404 APIError", &APIError{StatusCode: 404, Body: "not found"}, true},
|
||||||
|
{"500 APIError", &APIError{StatusCode: 500, Body: "server error"}, false},
|
||||||
|
{"wrapped 404", fmt.Errorf("list contents: %w", &APIError{StatusCode: 404, Body: "not found"}), true},
|
||||||
|
{"wrapped 500", fmt.Errorf("list contents: %w", &APIError{StatusCode: 500, Body: "err"}), false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := IsNotFound(tt.err)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user