Compare commits

...

12 Commits

Author SHA1 Message Date
aweiker f79fb40bef Merge pull request 'fix(github): consolidate review.go and identity.go into reviews.go (#116)' (#119) from review-bot-issue-116 into feature/github-support
Reviewed-on: #119
Reviewed-by: Aaron Weiker <aaron@weiker.org>
Reviewed-by: security-review-bot <10+security-review-bot@noreply.gitea.weiker.me>
2026-05-14 01:49:57 +00:00
aweiker cb162c154b Merge pull request 'feat(vcs): add CommitID to ReviewRequest (#115)' (#118) from review-bot-issue-115 into feature/github-support
Reviewed-on: #118
Reviewed-by: security-review-bot <10+security-review-bot@noreply.gitea.weiker.me>
Reviewed-by: Aaron Weiker <aaron@weiker.org>
2026-05-14 01:49:33 +00:00
claw 437e318240 nit: clarify truncation detection comment in ListReviews
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 19s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 39s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 59s
Expand the inline comment at the page==maxPages check to more
explicitly explain why a full final page implies truncation.
2026-05-13 18:01:57 -07:00
claw 2e2fcbabfc style: fix import ordering and restore nil-body comment
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 19s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 38s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 40s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 56s
- Reorder stdlib imports in review_test.go to alphabetical (goimports convention)
- Restore explanatory comment for nil body in DeleteReview

Addresses review comments #20533, #20534 on PR #119
2026-05-13 17:53:20 -07:00
claw 8e26c26f5f fix(github): add pagination tests and fix truncation warning logic
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 20s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 24s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 56s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m31s
F1: Add comprehensive pagination tests for ListReviews covering:
- Multi-page behaviour (2 full + 1 partial page)
- Exact-multiple-of-pageSize (extra empty-page round-trip)
- maxReviewPages cutoff (cap hit, results still returned)
- Empty first page (PR with no reviews)

F2: Fix truncation warning logic by moving it outside the loop with
a 'truncated' flag. Previously, the warning fired inline at page==maxPages
which could miss the case where the short-page break fires first on the
cap page. Now it only fires when the loop exits because the cap was reached
and the last page was full (indicating more data likely exists).

Also adds SetReviewPagination to Client for test-time override of page
size and max pages, following the existing SetRetryBackoff pattern.
2026-05-13 17:22:51 -07:00
claw 22b3ce8fef fix(github): consolidate review.go and identity.go into reviews.go (#116)
Remove github/review.go and github/identity.go, replacing them with a
consolidated github/reviews.go that:

- Uses doJSONRequest for PostReview and DismissReview (cleaner than
  manual marshal + doRequestWithBody)
- Adds paginated ListReviews with per_page=100 and max 100 pages
- Consolidates GetAuthenticatedUser and userResponse type (previously
  duplicated in identity.go)
- Preserves all sentinel errors (ErrCannotDeleteSubmittedReview,
  ErrConflictingCommitIDs), state translation, commit ID validation,
  and SupersedeReviews

This prevents the redeclaration errors that occur when both review.go
and reviews.go exist in the same package, as described in issue #116.

Closes #116
2026-05-13 17:21:24 -07:00
claw 9a6298cc4f fix: address review NITs — readability, test dedup, consistent SHA var
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 30s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 22s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 34s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 1m6s
- vcs/types.go: restore blank line between CommitID and Comments fields
  for visual grouping (scalar vs slice fields)
- gitea/adapter_test.go: merge duplicate TestAdapter_PostReview_CommitID
  tests into one (Threading was a superset)
- cmd/review-bot/main.go: use evaluatedSHA instead of pr.Head.SHA for
  inline comment CommitID for consistency with review-level usage
2026-05-13 16:31:11 -07:00
claw be68e51898 fix(vcs): address self-review NITs - gofmt alignment and comment clarity
PR Ready Gate / clear-labels (pull_request) Successful in 2s
CI / test (pull_request) Successful in 19s
CI / review (anthropic--claude-4.6-sonnet, sonnet, SONNET_REVIEW_TOKEN) (pull_request) Successful in 29s
CI / review (gpt-5, security, ., rodin/security-patterns, SECURITY_REVIEW.md, SECURITY_REVIEW_TOKEN) (pull_request) Successful in 32s
CI / review (gpt-5, gpt, GPT_REVIEW_TOKEN) (pull_request) Successful in 57s
2026-05-13 16:24:49 -07:00
claw 49db84fb82 fix(test): add missing blank line between test functions in adapter_test.go 2026-05-13 16:24:49 -07:00
claw 08b5d4051b style: remove double blank lines in test files 2026-05-13 16:24:49 -07:00
claw d606d0a202 feat(vcs): add CommitID to ReviewRequest (#115)
Add CommitID string field to vcs.ReviewRequest so both platform
adapters can thread the commit anchor through the abstraction layer.

Changes:
- vcs/types.go: Add CommitID field with json tag commit_id,omitempty
- gitea/client.go: Re-add commitID parameter to PostReview (was
  removed during PR #112 refactoring)
- gitea/adapter.go: Forward req.CommitID to underlying client
- github/review.go: Use req.CommitID as primary anchor, fall back to
  comment-derived CommitID when empty, reject on conflict
- cmd/review-bot/main.go: Set ReviewRequest.CommitID = evaluatedSHA

Fixes #115
2026-05-13 16:24:40 -07:00
aweiker b2c83c00bc Merge pull request 'fix(vcs): thread CommitID through abstraction layer (#114)' (#117) from review-bot-issue-114 into feature/github-support
Reviewed-on: #117
Reviewed-by: security-review-bot <10+security-review-bot@noreply.gitea.weiker.me>
Reviewed-by: Aaron Weiker <aaron@weiker.org>
2026-05-13 23:13:21 +00:00
7 changed files with 335 additions and 65 deletions
+2 -2
View File
@@ -439,7 +439,7 @@ func main() {
inlineComments = append(inlineComments, vcs.ReviewComment{
Path: f.File,
Position: pos,
CommitID: pr.Head.SHA,
CommitID: evaluatedSHA,
Body: fmt.Sprintf("**[%s]** %s", f.Severity, f.Finding),
})
}
@@ -485,7 +485,7 @@ func main() {
reviewReq := vcs.ReviewRequest{
Body: reviewBody,
Event: event,
CommitID: pr.Head.SHA,
CommitID: evaluatedSHA,
Comments: inlineComments,
}
posted, err := client.PostReview(ctx, owner, repoName, prNumber, reviewReq)
+1 -1
View File
@@ -409,7 +409,7 @@ func TestAdapter_RequestReviewerSelf(t *testing.T) {
}
}
func TestAdapter_PostReview_CommitID_Threading(t *testing.T) {
func TestAdapter_PostReview_CommitID(t *testing.T) {
var gotPayload struct {
Body string `json:"body"`
Event string `json:"event"`
+40
View File
@@ -147,6 +147,46 @@ func TestPostReview(t *testing.T) {
}
}
func TestPostReview_CommitID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
t.Fatalf("expected POST, got %s", r.Method)
}
body, _ := io.ReadAll(r.Body)
var payload struct {
Body string `json:"body"`
Event string `json:"event"`
CommitID string `json:"commit_id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("unmarshal payload: %v", err)
}
if payload.CommitID != "deadbeef123" {
t.Errorf("expected commit_id %q, got %q", "deadbeef123", payload.CommitID)
}
if payload.Event != "APPROVED" {
t.Errorf("expected event APPROVED, got %q", payload.Event)
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"id":101,"user":{"login":"review-bot"},"state":"APPROVED","stale":false,"commit_id":"deadbeef123"}`))
}))
defer server.Close()
client := NewClient(server.URL, "test-token")
review, err := client.PostReview(context.Background(), "owner", "repo", 3, "APPROVED", "LGTM", "deadbeef123", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if review.ID != 101 {
t.Errorf("expected review ID 101, got %d", review.ID)
}
if review.CommitID != "deadbeef123" {
t.Errorf("expected commit_id %q, got %q", "deadbeef123", review.CommitID)
}
}
func TestGetPullRequest_Non200(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
+12
View File
@@ -110,6 +110,11 @@ type Client struct {
// retryBackoff[i] is the delay before attempt i+1 (after attempt i fails).
// If nil, defaults to {1s, 2s}. Set to shorter durations in tests via SetRetryBackoff.
retryBackoff []time.Duration
// reviewPageSize overrides reviewsPerPage for testing. Zero means use default.
reviewPageSize int
// reviewMaxPages overrides maxReviewPages for testing. Zero means use default.
reviewMaxPages int
}
// defaultCheckRedirect is the redirect policy used by NewClient and SetHTTPClient(nil).
@@ -194,6 +199,13 @@ func (c *Client) SetRetryBackoff(d []time.Duration) error {
return nil
}
// SetReviewPagination overrides the page size and max pages for ListReviews.
// Intended for testing only; must be called before any goroutines issue requests.
func (c *Client) SetReviewPagination(pageSize, maxPages int) {
c.reviewPageSize = pageSize
c.reviewMaxPages = maxPages
}
// requestOptions holds per-request configuration for doRequestCore.
type requestOptions struct {
// bodyFn returns a fresh io.Reader for the request body on each attempt.
-29
View File
@@ -1,29 +0,0 @@
package github
import (
"context"
"encoding/json"
"fmt"
)
// userResponse is the GitHub API response for the authenticated user.
type userResponse struct {
Login string `json:"login"`
}
// GetAuthenticatedUser returns the login of the currently authenticated user.
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
reqURL := fmt.Sprintf("%s/user", c.baseURL)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("get authenticated user: %w", err)
}
var resp userResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("parse user response: %w", err)
}
return resp.Login, nil
}
+193
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
@@ -482,3 +483,195 @@ func TestPostReview_RequestCommitID_FallbackToComment(t *testing.T) {
t.Errorf("sent commit_id = %q, want %q (fallback from comment)", gotPayload.CommitID, "comment-sha")
}
}
// --- ListReviews pagination tests ---
func TestListReviews_MultiPage(t *testing.T) {
// Test multi-page pagination: 2 full pages + 1 partial page.
// pageSize=3, so pages return [3, 3, 2] reviews = 8 total.
const pageSize = 3
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Fatalf("expected GET, got %s", r.Method)
}
callCount++
page := r.URL.Query().Get("page")
var reviews []map[string]interface{}
switch page {
case "1":
for i := 1; i <= pageSize; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "APPROVED", "commit_id": "sha1",
"user": map[string]string{"login": "user1"},
})
}
case "2":
for i := pageSize + 1; i <= pageSize*2; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "COMMENTED", "commit_id": "sha1",
"user": map[string]string{"login": "user2"},
})
}
case "3":
// Partial page: only 2 reviews (less than pageSize)
for i := pageSize*2 + 1; i <= pageSize*2+2; i++ {
reviews = append(reviews, map[string]interface{}{
"id": i, "body": fmt.Sprintf("review %d", i),
"state": "CHANGES_REQUESTED", "commit_id": "sha1",
"user": map[string]string{"login": "user3"},
})
}
default:
t.Fatalf("unexpected page: %s", page)
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, 10)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 8 {
t.Fatalf("expected 8 reviews, got %d", len(reviews))
}
if callCount != 3 {
t.Errorf("expected 3 API calls, got %d", callCount)
}
// Verify reviews are correctly concatenated in order
for i, r := range reviews {
expectedID := int64(i + 1)
if r.ID != expectedID {
t.Errorf("review[%d]: expected ID %d, got %d", i, expectedID, r.ID)
}
}
}
func TestListReviews_ExactMultipleOfPageSize(t *testing.T) {
// When total reviews is an exact multiple of pageSize, an extra request
// returning 0 results terminates the loop. No truncation warning.
const pageSize = 2
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
page := r.URL.Query().Get("page")
var reviews []map[string]interface{}
switch page {
case "1":
reviews = []map[string]interface{}{
{"id": 1, "body": "r1", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u1"}},
{"id": 2, "body": "r2", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u2"}},
}
case "2":
reviews = []map[string]interface{}{
{"id": 3, "body": "r3", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u3"}},
{"id": 4, "body": "r4", "state": "APPROVED", "commit_id": "s1", "user": map[string]string{"login": "u4"}},
}
case "3":
// Empty page — signals end of data
reviews = []map[string]interface{}{}
default:
t.Fatalf("unexpected page: %s", page)
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, 10)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 4 {
t.Fatalf("expected 4 reviews, got %d", len(reviews))
}
// 3 calls: page 1 (full), page 2 (full), page 3 (empty)
if callCount != 3 {
t.Errorf("expected 3 API calls, got %d", callCount)
}
}
func TestListReviews_MaxPagesCutoff(t *testing.T) {
// When maxPages is hit and the last page is full, results are truncated
// and a warning would fire (we verify the reviews are still returned).
const pageSize = 2
const maxPages = 2
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
page := r.URL.Query().Get("page")
// Always return a full page (simulating more data exists)
var reviews []map[string]interface{}
var baseID int
switch page {
case "1":
baseID = 0
case "2":
baseID = pageSize
default:
t.Fatalf("unexpected page %s (should not exceed maxPages)", page)
}
for i := 1; i <= pageSize; i++ {
reviews = append(reviews, map[string]interface{}{
"id": baseID + i, "body": fmt.Sprintf("r%d", baseID+i),
"state": "APPROVED", "commit_id": "sha1",
"user": map[string]string{"login": "user"},
})
}
json.NewEncoder(w).Encode(reviews)
})
c.SetReviewPagination(pageSize, maxPages)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should return all reviews fetched within the cap
expectedCount := pageSize * maxPages
if len(reviews) != expectedCount {
t.Fatalf("expected %d reviews, got %d", expectedCount, len(reviews))
}
if callCount != maxPages {
t.Errorf("expected %d API calls, got %d", maxPages, callCount)
}
// Verify concatenation order
for i, r := range reviews {
if r.ID != int64(i+1) {
t.Errorf("review[%d]: expected ID %d, got %d", i, i+1, r.ID)
}
}
}
func TestListReviews_EmptyFirstPage(t *testing.T) {
// PR with no reviews: first page returns empty array.
callCount := 0
c := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
callCount++
json.NewEncoder(w).Encode([]map[string]interface{}{})
})
c.SetReviewPagination(10, 5)
reviews, err := c.ListReviews(context.Background(), "owner", "repo", 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(reviews) != 0 {
t.Fatalf("expected 0 reviews, got %d", len(reviews))
}
if callCount != 1 {
t.Errorf("expected 1 API call, got %d", callCount)
}
}
+87 -33
View File
@@ -5,12 +5,21 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"gitea.weiker.me/rodin/review-bot/vcs"
)
const (
// reviewsPerPage is the number of reviews to fetch per API page.
reviewsPerPage = 100
// maxReviewPages is the maximum number of pages to paginate through
// when listing reviews. Acts as a safeguard against infinite pagination.
maxReviewPages = 100
)
// ErrCannotDeleteSubmittedReview is returned when DeleteReview is called on
// a review that has already been submitted (APPROVED, REQUEST_CHANGES, COMMENT).
// GitHub only allows deletion of PENDING reviews. Callers that need to replace
@@ -54,6 +63,11 @@ type dismissReviewRequest struct {
Event string `json:"event"`
}
// userResponse is the GitHub API response for the authenticated user.
type userResponse struct {
Login string `json:"login"`
}
// translateGitHubReviewState translates a GitHub API review state to the
// canonical vcs.Review.State value.
func translateGitHubReviewState(state string) string {
@@ -103,7 +117,7 @@ func (c *Client) PostReview(ctx context.Context, owner, repo string, number int,
// the resolved commit_id.
for _, comment := range req.Comments {
if comment.CommitID != "" {
if payload.CommitID == "" {
if payload.CommitID == "" { // only reachable when req.CommitID is empty
payload.CommitID = comment.CommitID
} else if payload.CommitID != comment.CommitID {
return nil, ErrConflictingCommitIDs
@@ -117,12 +131,7 @@ func (c *Client) PostReview(ctx context.Context, owner, repo string, number int,
})
}
data, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal review request: %w", err)
}
body, err := c.doRequestWithBody(ctx, http.MethodPost, reqURL, data)
body, err := c.doJSONRequest(ctx, http.MethodPost, reqURL, payload)
if err != nil {
return nil, fmt.Errorf("post review: %w", err)
}
@@ -141,33 +150,69 @@ func (c *Client) PostReview(ctx context.Context, owner, repo string, number int,
}, nil
}
// ListReviews retrieves all reviews for a pull request.
// ListReviews retrieves all reviews for a pull request with pagination.
// GitHub review states are translated to canonical vcs values.
func (c *Client) ListReviews(ctx context.Context, owner, repo string, number int) ([]vcs.Review, error) {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list reviews: %w", err)
perPage := reviewsPerPage
if c.reviewPageSize > 0 {
perPage = c.reviewPageSize
}
maxPages := maxReviewPages
if c.reviewMaxPages > 0 {
maxPages = c.reviewMaxPages
}
var responses []reviewResponse
if err := json.Unmarshal(body, &responses); err != nil {
return nil, fmt.Errorf("parse reviews response: %w", err)
}
var allReviews []vcs.Review
truncated := false
reviews := make([]vcs.Review, len(responses))
for i, r := range responses {
reviews[i] = vcs.Review{
ID: r.ID,
Body: r.Body,
User: vcs.UserInfo{Login: r.User.Login},
State: translateGitHubReviewState(r.State),
CommitID: r.CommitID,
for page := 1; page <= maxPages; page++ {
reqURL := fmt.Sprintf("%s/repos/%s/%s/pulls/%d/reviews?per_page=%d&page=%d",
c.baseURL, url.PathEscape(owner), url.PathEscape(repo), number, perPage, page)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return nil, fmt.Errorf("list reviews page %d: %w", page, err)
}
var responses []reviewResponse
if err := json.Unmarshal(body, &responses); err != nil {
return nil, fmt.Errorf("parse reviews response: %w", err)
}
if len(responses) == 0 {
break
}
for _, r := range responses {
allReviews = append(allReviews, vcs.Review{
ID: r.ID,
Body: r.Body,
User: vcs.UserInfo{Login: r.User.Login},
State: translateGitHubReviewState(r.State),
CommitID: r.CommitID,
})
}
if len(responses) < perPage {
break
}
// Truncation detection: this runs on the final allowed iteration
// (page == maxPages) only when the page was full (the len < perPage
// early-break above didn't fire). A full final page means additional
// reviews likely exist beyond our pagination limit.
if page == maxPages {
truncated = true
}
}
return reviews, nil
if truncated {
slog.Warn("ListReviews hit page limit; results may be truncated",
"owner", owner, "repo", repo, "pr", number,
"maxPages", maxPages, "reviewsFetched", len(allReviews))
}
return allReviews, nil
}
// DeleteReview deletes a pull request review.
@@ -204,12 +249,7 @@ func (c *Client) DismissReview(ctx context.Context, owner, repo string, number i
Event: "DISMISS",
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal dismiss request: %w", err)
}
_, err = c.doRequestWithBody(ctx, http.MethodPut, reqURL, data)
_, err := c.doJSONRequest(ctx, http.MethodPut, reqURL, payload)
if err != nil {
return fmt.Errorf("dismiss review: %w", err)
}
@@ -228,3 +268,17 @@ func (c *Client) SupersedeReviews(ctx context.Context, owner, repo string, prNum
}
return errors.Join(errs...)
}
// GetAuthenticatedUser returns the login name of the authenticated user.
func (c *Client) GetAuthenticatedUser(ctx context.Context) (string, error) {
reqURL := fmt.Sprintf("%s/user", c.baseURL)
body, err := c.doGet(ctx, reqURL)
if err != nil {
return "", fmt.Errorf("get authenticated user: %w", err)
}
var resp userResponse
if err := json.Unmarshal(body, &resp); err != nil {
return "", fmt.Errorf("parse user response: %w", err)
}
return resp.Login, nil
}