From 3ccdc858ecdce68d07750bd22fd60015a0dcdd20 Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sat, 31 Oct 2026 16:06:11 +0530 Subject: [PATCH 1/2] Add GetAuthenticatedUser to the Forge interface --- bitbucket/user.go | 37 ++++++++++++++++++ bitbucket/user_test.go | 65 ++++++++++++++++++++++++++++++++ forge.go | 3 ++ forges_test.go | 4 ++ gerrit/user.go | 18 +++++++++ gerrit/user_test.go | 36 ++++++++++++++++++ gitea/user.go | 22 +++++++++++ gitea/user_test.go | 40 ++++++++++++++++++++ github/user.go | 23 +++++++++++ github/user_test.go | 63 +++++++++++++++++++++++++++++++ gitlab/gitlab_tinygo.go | 4 ++ gitlab/gitlab_tinygo_test.go | 1 + gitlab/user.go | 25 ++++++++++++ gitlab/user_test.go | 41 ++++++++++++++++++++ internal/cli/pr_checkout_test.go | 3 ++ tangled/tangled.go | 4 ++ tangled/tangled_test.go | 8 ++++ 17 files changed, 397 insertions(+) create mode 100644 bitbucket/user.go create mode 100644 bitbucket/user_test.go create mode 100644 gerrit/user.go create mode 100644 gerrit/user_test.go create mode 100644 gitea/user.go create mode 100644 gitea/user_test.go create mode 100644 github/user.go create mode 100644 github/user_test.go create mode 100644 gitlab/user.go create mode 100644 gitlab/user_test.go diff --git a/bitbucket/user.go b/bitbucket/user.go new file mode 100644 index 0000000..5acc8bb --- /dev/null +++ b/bitbucket/user.go @@ -0,0 +1,37 @@ +package bitbucket + +import ( + "context" + + forge "github.com/git-pkgs/forge" +) + +type bbUser struct { + Username string `json:"username"` + DisplayName string `json:"display_name"` + Links struct { + HTML struct { + Href string `json:"href"` + } `json:"html"` + Avatar struct { + Href string `json:"href"` + } `json:"avatar"` + } `json:"links"` +} + +func (f *bitbucketForge) GetAuthenticatedUser(ctx context.Context) (*forge.User, error) { + rs := &bitbucketRepoService{token: f.token, httpClient: f.httpClient} + var bb bbUser + if err := rs.getJSON(ctx, bitbucketAPI+"/user", &bb); err != nil { + return nil, err + } + + // Bitbucket only returns email addresses from the separate /user/emails + // endpoint, so Email is left empty. + return &forge.User{ + Login: bb.Username, + Name: bb.DisplayName, + AvatarURL: bb.Links.Avatar.Href, + HTMLURL: bb.Links.HTML.Href, + }, nil +} diff --git a/bitbucket/user_test.go b/bitbucket/user_test.go new file mode 100644 index 0000000..b15a542 --- /dev/null +++ b/bitbucket/user_test.go @@ -0,0 +1,65 @@ +package bitbucket + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + forge "github.com/git-pkgs/forge" +) + +func TestBitbucketGetAuthenticatedUser(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /2.0/user", func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "Authorization", "Bearer test-token", r.Header.Get("Authorization")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "username": "alice", + "display_name": "Alice Example", + "links": map[string]any{ + "html": map[string]string{"href": "https://bitbucket.org/alice/"}, + "avatar": map[string]string{"href": "https://bitbucket.org/account/alice/avatar/"}, + }, + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + origAPI := bitbucketAPI + setBitbucketAPI(srv.URL + "/2.0") + defer setBitbucketAPI(origAPI) + + f := New("test-token", srv.Client()) + u, err := f.GetAuthenticatedUser(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Login", "alice", u.Login) + assertEqual(t, "Name", "Alice Example", u.Name) + assertEqual(t, "Email", "", u.Email) + assertEqual(t, "AvatarURL", "https://bitbucket.org/account/alice/avatar/", u.AvatarURL) + assertEqual(t, "HTMLURL", "https://bitbucket.org/alice/", u.HTMLURL) +} + +func TestBitbucketGetAuthenticatedUserUnauthorized(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /2.0/user", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + origAPI := bitbucketAPI + setBitbucketAPI(srv.URL + "/2.0") + defer setBitbucketAPI(origAPI) + + f := New("", srv.Client()) + _, err := f.GetAuthenticatedUser(context.Background()) + var httpErr *forge.HTTPError + if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusUnauthorized { + t.Fatalf("expected HTTP 401 error, got %v", err) + } +} diff --git a/forge.go b/forge.go index b96ef7a..254b853 100644 --- a/forge.go +++ b/forge.go @@ -82,6 +82,9 @@ type Forge interface { CheckRuns() CheckRunService Commits() CommitService GetRateLimit(ctx context.Context) (*RateLimit, error) + // GetAuthenticatedUser returns the profile of the user the client's + // token belongs to. + GetAuthenticatedUser(ctx context.Context) (*User, error) // ParsePath parses URL path segments into a resource reference. ParsePath(pathParts []string) (*ResourceRef, error) } diff --git a/forges_test.go b/forges_test.go index 3bd4209..b760c85 100644 --- a/forges_test.go +++ b/forges_test.go @@ -782,6 +782,10 @@ func (m *mockForge) GetRateLimit(_ context.Context) (*RateLimit, error) { return nil, ErrNotSupported } +func (m *mockForge) GetAuthenticatedUser(_ context.Context) (*User, error) { + return nil, ErrNotSupported +} + func (m *mockForge) ParsePath(_ []string) (*ResourceRef, error) { return &ResourceRef{}, nil } diff --git a/gerrit/user.go b/gerrit/user.go new file mode 100644 index 0000000..b23aeab --- /dev/null +++ b/gerrit/user.go @@ -0,0 +1,18 @@ +package gerrit + +import ( + "context" + "net/http" + + forge "github.com/git-pkgs/forge" +) + +func (f *gerritForge) GetAuthenticatedUser(ctx context.Context) (*forge.User, error) { + var account gerritAccountInfo + if err := f.doJSON(ctx, http.MethodGet, "/accounts/self", nil, nil, &account); err != nil { + return nil, err + } + + user := (&gerritPRService{forge: f}).convertAccount(&account) + return &user, nil +} diff --git a/gerrit/user_test.go b/gerrit/user_test.go new file mode 100644 index 0000000..04536d1 --- /dev/null +++ b/gerrit/user_test.go @@ -0,0 +1,36 @@ +package gerrit + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGetAuthenticatedUser(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /a/accounts/self", func(w http.ResponseWriter, r *http.Request) { + user, password, ok := r.BasicAuth() + if !ok || user != "alice" || password != "secret" { + t.Errorf("basic auth = %q, %q, %v; want alice, secret, true", user, password, ok) + } + _, _ = fmt.Fprint(w, xssi+`{ + "_account_id": 1000096, + "name": "Alice Example", + "email": "alice@example.com", + "username": "alice" + }`) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "alice:secret", srv.Client()) + u, err := f.GetAuthenticatedUser(context.Background()) + if err != nil { + t.Fatalf("GetAuthenticatedUser: %v", err) + } + if u.Login != "alice" || u.Name != "Alice Example" || u.Email != "alice@example.com" { + t.Fatalf("unexpected user: %+v", u) + } +} diff --git a/gitea/user.go b/gitea/user.go new file mode 100644 index 0000000..273aa41 --- /dev/null +++ b/gitea/user.go @@ -0,0 +1,22 @@ +package gitea + +import ( + "context" + + forge "github.com/git-pkgs/forge" +) + +func (f *giteaForge) GetAuthenticatedUser(ctx context.Context) (*forge.User, error) { + u, _, err := f.client.GetMyUserInfo() + if err != nil { + return nil, err + } + + return &forge.User{ + Login: u.UserName, + Name: u.FullName, + Email: u.Email, + AvatarURL: u.AvatarURL, + HTMLURL: u.HTMLURL, + }, nil +} diff --git a/gitea/user_test.go b/gitea/user_test.go new file mode 100644 index 0000000..f5db5c8 --- /dev/null +++ b/gitea/user_test.go @@ -0,0 +1,40 @@ +package gitea + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGiteaGetAuthenticatedUser(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/user", func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "Authorization", "token test-token", r.Header.Get("Authorization")) + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": 1, + "login": "alice", + "full_name": "Alice Example", + "email": "alice@example.com", + "avatar_url": "https://codeberg.org/avatars/alice", + "html_url": "https://codeberg.org/alice", + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + u, err := f.GetAuthenticatedUser(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Login", "alice", u.Login) + assertEqual(t, "Name", "Alice Example", u.Name) + assertEqual(t, "Email", "alice@example.com", u.Email) + assertEqual(t, "AvatarURL", "https://codeberg.org/avatars/alice", u.AvatarURL) + assertEqual(t, "HTMLURL", "https://codeberg.org/alice", u.HTMLURL) +} diff --git a/github/user.go b/github/user.go new file mode 100644 index 0000000..47ccd2c --- /dev/null +++ b/github/user.go @@ -0,0 +1,23 @@ +package github + +import ( + "context" + + forge "github.com/git-pkgs/forge" +) + +func (f *gitHubForge) GetAuthenticatedUser(ctx context.Context) (*forge.User, error) { + // An empty username asks GitHub for the authenticated user (GET /user). + u, _, err := f.client.Users.Get(ctx, "") + if err != nil { + return nil, err + } + + return &forge.User{ + Login: u.GetLogin(), + Name: u.GetName(), + Email: u.GetEmail(), + AvatarURL: u.GetAvatarURL(), + HTMLURL: u.GetHTMLURL(), + }, nil +} diff --git a/github/user_test.go b/github/user_test.go new file mode 100644 index 0000000..94916ae --- /dev/null +++ b/github/user_test.go @@ -0,0 +1,63 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/go-github/v82/github" +) + +func TestGitHubGetAuthenticatedUser(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/user", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "login": "octocat", + "name": "The Octocat", + "email": "octocat@github.com", + "avatar_url": "https://avatars.githubusercontent.com/u/583231", + "html_url": "https://github.com/octocat", + "type": "User", + }) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + c := github.NewClient(nil) + c, _ = c.WithEnterpriseURLs(srv.URL+"/api/v3", srv.URL+"/api/v3") + f := &gitHubForge{client: c} + + u, err := f.GetAuthenticatedUser(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Login", "octocat", u.Login) + assertEqual(t, "Name", "The Octocat", u.Name) + assertEqual(t, "Email", "octocat@github.com", u.Email) + assertEqual(t, "AvatarURL", "https://avatars.githubusercontent.com/u/583231", u.AvatarURL) + assertEqual(t, "HTMLURL", "https://github.com/octocat", u.HTMLURL) + assertEqualBool(t, "IsOrg", false, u.IsOrg) +} + +func TestGitHubGetAuthenticatedUserUnauthorized(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v3/user", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"Requires authentication"}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + c := github.NewClient(nil) + c, _ = c.WithEnterpriseURLs(srv.URL+"/api/v3", srv.URL+"/api/v3") + f := &gitHubForge{client: c} + + if _, err := f.GetAuthenticatedUser(context.Background()); err == nil { + t.Fatal("expected error for unauthenticated request") + } +} diff --git a/gitlab/gitlab_tinygo.go b/gitlab/gitlab_tinygo.go index 7823fbd..f37ceeb 100644 --- a/gitlab/gitlab_tinygo.go +++ b/gitlab/gitlab_tinygo.go @@ -28,6 +28,10 @@ func (f *gitLabForge) GetRateLimit(context.Context) (*forge.RateLimit, error) { return nil, errTinyGo } +func (f *gitLabForge) GetAuthenticatedUser(context.Context) (*forge.User, error) { + return nil, errTinyGo +} + type gitLabBranchService struct{} func (f *gitLabForge) Branches() forge.BranchService { return &gitLabBranchService{} } diff --git a/gitlab/gitlab_tinygo_test.go b/gitlab/gitlab_tinygo_test.go index 2ead3b2..55b30aa 100644 --- a/gitlab/gitlab_tinygo_test.go +++ b/gitlab/gitlab_tinygo_test.go @@ -69,6 +69,7 @@ func TestTinyGoServicesReturnUnsupported(t *testing.T) { {"commit statuses", func() error { _, err := f.CommitStatuses().List(ctx, "group", "project", "main"); return err }}, {"commits", func() error { _, err := f.Commits().ResolveCommit(ctx, "group", "project", "main"); return err }}, {"rate limit", func() error { _, err := f.GetRateLimit(ctx); return err }}, + {"authenticated user", func() error { _, err := f.GetAuthenticatedUser(ctx); return err }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/gitlab/user.go b/gitlab/user.go new file mode 100644 index 0000000..22decee --- /dev/null +++ b/gitlab/user.go @@ -0,0 +1,25 @@ +//go:build !tinygo + +package gitlab + +import ( + "context" + + forge "github.com/git-pkgs/forge" + gitlab "gitlab.com/gitlab-org/api/client-go" +) + +func (f *gitLabForge) GetAuthenticatedUser(ctx context.Context) (*forge.User, error) { + u, _, err := f.client.Users.CurrentUser(gitlab.WithContext(ctx)) + if err != nil { + return nil, err + } + + return &forge.User{ + Login: u.Username, + Name: u.Name, + Email: u.Email, + AvatarURL: u.AvatarURL, + HTMLURL: u.WebURL, + }, nil +} diff --git a/gitlab/user_test.go b/gitlab/user_test.go new file mode 100644 index 0000000..0da0ad5 --- /dev/null +++ b/gitlab/user_test.go @@ -0,0 +1,41 @@ +//go:build !tinygo + +package gitlab + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGitLabGetAuthenticatedUser(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v4/user", func(w http.ResponseWriter, r *http.Request) { + assertEqual(t, "PRIVATE-TOKEN", "test-token", r.Header.Get("PRIVATE-TOKEN")) + _, _ = fmt.Fprint(w, `{ + "id": 1, + "username": "jdoe", + "name": "Jane Doe", + "email": "jdoe@example.com", + "avatar_url": "https://gitlab.com/uploads/jdoe.png", + "web_url": "https://gitlab.com/jdoe" + }`) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "test-token", nil) + u, err := f.GetAuthenticatedUser(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + assertEqual(t, "Login", "jdoe", u.Login) + assertEqual(t, "Name", "Jane Doe", u.Name) + assertEqual(t, "Email", "jdoe@example.com", u.Email) + assertEqual(t, "AvatarURL", "https://gitlab.com/uploads/jdoe.png", u.AvatarURL) + assertEqual(t, "HTMLURL", "https://gitlab.com/jdoe", u.HTMLURL) +} diff --git a/internal/cli/pr_checkout_test.go b/internal/cli/pr_checkout_test.go index bd9da2c..87828f0 100644 --- a/internal/cli/pr_checkout_test.go +++ b/internal/cli/pr_checkout_test.go @@ -114,6 +114,9 @@ func (m *mockForge) Commits() forges.CommitService { return nil } func (m *mockForge) GetRateLimit(_ context.Context) (*forges.RateLimit, error) { return nil, forges.ErrNotSupported } +func (m *mockForge) GetAuthenticatedUser(_ context.Context) (*forges.User, error) { + return nil, forges.ErrNotSupported +} func (m *mockForge) ParsePath(_ []string) (*forges.ResourceRef, error) { return &forges.ResourceRef{ diff --git a/tangled/tangled.go b/tangled/tangled.go index 1ac272e..1a5fefb 100644 --- a/tangled/tangled.go +++ b/tangled/tangled.go @@ -71,6 +71,10 @@ func (f *tangledForge) GetRateLimit(context.Context) (*forges.RateLimit, error) return nil, forges.ErrNotSupported } +func (f *tangledForge) GetAuthenticatedUser(context.Context) (*forges.User, error) { + return nil, forges.ErrNotSupported +} + func (f *tangledForge) APIBaseURL() string { return f.baseURL + "/xrpc" } diff --git a/tangled/tangled_test.go b/tangled/tangled_test.go index beee672..f375bee 100644 --- a/tangled/tangled_test.go +++ b/tangled/tangled_test.go @@ -2,6 +2,7 @@ package tangled import ( "context" + "errors" "fmt" "net/http" "net/http/httptest" @@ -178,3 +179,10 @@ func tangledTestServer(t *testing.T) *httptest.Server { }) return httptest.NewServer(mux) } + +func TestGetAuthenticatedUserUnsupported(t *testing.T) { + f := New("https://tangled.org", "", nil) + if _, err := f.GetAuthenticatedUser(context.Background()); !errors.Is(err, forges.ErrNotSupported) { + t.Fatalf("expected ErrNotSupported, got %v", err) + } +} From 9e49089d3ac3c756f60056220df00ee201ffbbbb Mon Sep 17 00:00:00 2001 From: abhinavgautam01 Date: Sun, 1 Nov 2026 16:03:09 +0530 Subject: [PATCH 2/2] Wrap Gitea authenticated user errors and make Gerrit convertAccount a function --- gerrit/changes.go | 6 +++--- gerrit/user.go | 2 +- gitea/user.go | 4 ++-- gitea/user_test.go | 25 +++++++++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/gerrit/changes.go b/gerrit/changes.go index 4c0aad8..a62a291 100644 --- a/gerrit/changes.go +++ b/gerrit/changes.go @@ -60,7 +60,7 @@ func (f *gerritForge) PullRequests() forge.PullRequestService { return &gerritPRService{forge: f} } -func (s *gerritPRService) convertAccount(a *gerritAccountInfo) forge.User { +func convertAccount(a *gerritAccountInfo) forge.User { if a == nil { return forge.User{} } @@ -100,7 +100,7 @@ func (s *gerritPRService) convertChange(c gerritChangeInfo) forge.PullRequest { Title: c.Subject, State: state, Draft: c.WorkInProgress, - Author: s.convertAccount(c.Owner), + Author: convertAccount(c.Owner), Head: head, Base: forge.PRBranch{Ref: c.Branch}, Merged: merged, @@ -261,7 +261,7 @@ func (s *gerritPRService) ListComments(ctx context.Context, owner, repo string, for _, msg := range messages { comments = append(comments, forge.Comment{ Body: msg.Message, - Author: s.convertAccount(msg.Author), + Author: convertAccount(msg.Author), HTMLURL: s.changeURL(projectName(owner, repo), number), CreatedAt: parseGerritTime(msg.Date), UpdatedAt: parseGerritTime(msg.Date), diff --git a/gerrit/user.go b/gerrit/user.go index b23aeab..688a6d9 100644 --- a/gerrit/user.go +++ b/gerrit/user.go @@ -13,6 +13,6 @@ func (f *gerritForge) GetAuthenticatedUser(ctx context.Context) (*forge.User, er return nil, err } - user := (&gerritPRService{forge: f}).convertAccount(&account) + user := convertAccount(&account) return &user, nil } diff --git a/gitea/user.go b/gitea/user.go index 273aa41..2c436e9 100644 --- a/gitea/user.go +++ b/gitea/user.go @@ -7,9 +7,9 @@ import ( ) func (f *giteaForge) GetAuthenticatedUser(ctx context.Context) (*forge.User, error) { - u, _, err := f.client.GetMyUserInfo() + u, resp, err := f.client.GetMyUserInfo() if err != nil { - return nil, err + return nil, wrapErr("get authenticated user", resp, err) } return &forge.User{ diff --git a/gitea/user_test.go b/gitea/user_test.go index f5db5c8..11afe11 100644 --- a/gitea/user_test.go +++ b/gitea/user_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" ) @@ -38,3 +39,27 @@ func TestGiteaGetAuthenticatedUser(t *testing.T) { assertEqual(t, "AvatarURL", "https://codeberg.org/avatars/alice", u.AvatarURL) assertEqual(t, "HTMLURL", "https://codeberg.org/alice", u.HTMLURL) } + +func TestGiteaGetAuthenticatedUserUnauthorized(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/v1/version", giteaVersionHandler) + mux.HandleFunc("GET /api/v1/user", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":""}`)) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + + f := New(srv.URL, "expired-token", nil) + _, err := f.GetAuthenticatedUser(context.Background()) + if err == nil { + t.Fatal("expected error for expired token") + } + for _, want := range []string{"get authenticated user", "401"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not contain %q", err, want) + } + } +}