@@ -10,16 +10,14 @@ import (
1010 "log"
1111 "net/http"
1212 "os"
13- "strings"
1413 "time"
1514)
1615
17- const defaultGithubAPIRoute = "https://api.github.com/"
16+ const defaultGithubAPIBaseRoute = "https://api.github.com/"
1817
1918const (
20- actionsActorKey = "ACTOR"
21- actionsBaseRefKey = "BASE_REF"
22- actionsHeadRefKey = "HEAD_REF"
19+ actionsActorKey = "CI_ACTOR"
20+ actionsBaseRefKey = "CI_BASE_REF"
2321)
2422
2523const (
@@ -38,119 +36,157 @@ func ActionsActor() (string, error) {
3836 return username , nil
3937}
4038
41- // ActionsRefs returns the name of the head ref and the base ref for current CI
42- // run, in that order. Both values must be loaded into the env as part of the
43- // GitHub Actions YAML file, or else the function fails.
44- func ActionsRefs () (string , string , error ) {
39+ // BaseRef returns the name of the base ref for the Git branch that will be
40+ // merged into the main branch.
41+ func BaseRef () (string , error ) {
4542 baseRef := os .Getenv (actionsBaseRefKey )
46- headRef := os .Getenv (actionsHeadRefKey )
47-
48- if baseRef == "" && headRef == "" {
49- return "" , "" , fmt .Errorf ("values for %q and %q are not in env. If running from CI, please add values via ci.yaml file" , actionsHeadRefKey , actionsBaseRefKey )
50- } else if headRef == "" {
51- return "" , "" , fmt .Errorf ("value for %q is not in env. If running from CI, please add value via ci.yaml file" , actionsHeadRefKey )
52- } else if baseRef == "" {
53- return "" , "" , fmt .Errorf ("value for %q is not in env. If running from CI, please add value via ci.yaml file" , actionsBaseRefKey )
43+ if baseRef == "" {
44+ return "" , fmt .Errorf ("value for %q is not in env. If running from CI, please add value via ci.yaml file" , actionsBaseRefKey )
5445 }
5546
56- return headRef , baseRef , nil
47+ return baseRef , nil
5748}
5849
59- // CoderEmployees represents all members of the Coder GitHub organization. This
60- // value should not be instantiated from outside the package, and should instead
61- // be created via one of the package's exported functions.
62- type CoderEmployees struct {
63- // Have map defined as private field to make sure that it can't ever be
64- // mutated from an outside package
65- _employees map [string ]struct {}
50+ // Client is a reusable REST client for making requests to the GitHub API.
51+ // It should be instantiated via NewGithubClient
52+ type Client struct {
53+ baseURL string
54+ token string
55+ httpClient http.Client
6656}
6757
68- // IsEmployee takes a GitHub username and indicates whether the matching user is
69- // a member of the Coder organization
70- func (ce * CoderEmployees ) IsEmployee (username string ) bool {
71- if ce ._employees == nil {
72- return false
58+ // NewClient instantiates a GitHub client
59+ func NewClient () (* Client , error ) {
60+ // Considered letting the user continue on with no token and more aggressive
61+ // rate-limiting, but from experimentation, the non-authenticated experience
62+ // hit the rate limits really quickly, and had a lot of restrictions
63+ apiToken := os .Getenv (githubAPITokenKey )
64+ if apiToken == "" {
65+ return nil , fmt .Errorf ("missing env variable %q" , githubAPITokenKey )
7366 }
7467
75- _ , ok := ce ._employees [username ]
76- return ok
77- }
68+ baseURL := os .Getenv (githubAPIURLKey )
69+ if baseURL == "" {
70+ log .Printf ("env variable %q is not defined. Falling back to %q\n " , githubAPIURLKey , defaultGithubAPIBaseRoute )
71+ baseURL = defaultGithubAPIBaseRoute
72+ }
7873
79- // TotalEmployees returns the number of members in the Coder organization
80- func (ce * CoderEmployees ) TotalEmployees () int {
81- return len (ce ._employees )
74+ return & Client {
75+ baseURL : baseURL ,
76+ token : apiToken ,
77+ httpClient : http.Client {Timeout : 10 * time .Second },
78+ }, nil
8279}
8380
84- type ghOrganizationMember struct {
81+ // User represents a truncated version of the API response from Github's /user
82+ // endpoint.
83+ type User struct {
8584 Login string `json:"login"`
8685}
8786
88- type ghRateLimitedRes struct {
89- Message string `json:"message"`
90- }
91-
92- func parseResponse [V any ](b []byte ) (V , error ) {
93- var want V
94- var rateLimitedRes ghRateLimitedRes
95-
96- if err := json .Unmarshal (b , & rateLimitedRes ); err != nil {
97- return want , err
98- }
99- if isRateLimited := strings .Contains (rateLimitedRes .Message , "API rate limit exceeded for " ); isRateLimited {
100- return want , errors .New ("request was rate-limited" )
87+ // GetUserFromToken returns the user associated with the loaded API token
88+ func (gc * Client ) GetUserFromToken () (User , error ) {
89+ req , err := http .NewRequest ("GET" , gc .baseURL + "user" , nil )
90+ if err != nil {
91+ return User {}, err
10192 }
102- if err := json . Unmarshal ( b , & want ); err != nil {
103- return want , err
93+ if gc . token != "" {
94+ req . Header . Add ( "Authorization" , "Bearer " + gc . token )
10495 }
10596
106- return want , nil
107- }
97+ res , err := gc .httpClient .Do (req )
98+ if err != nil {
99+ return User {}, err
100+ }
101+ defer res .Body .Close ()
108102
109- // CoderEmployeeUsernames requests from the GitHub API the list of all usernames
110- // of people who are employees of Coder.
111- func CoderEmployeeUsernames () (CoderEmployees , error ) {
112- apiURL := os .Getenv (githubAPIURLKey )
113- if apiURL == "" {
114- log .Printf ("API URL not set via env key %q. Defaulting to %q\n " , githubAPIURLKey , defaultGithubAPIRoute )
115- apiURL = defaultGithubAPIRoute
103+ if res .StatusCode == http .StatusUnauthorized {
104+ return User {}, errors .New ("request is not authorized" )
116105 }
117- token := os .Getenv (githubAPITokenKey )
118- if token == "" {
119- log .Printf ("API token not set via env key %q. All requests will be non-authenticated and subject to more aggressive rate limiting" , githubAPITokenKey )
106+ if res .StatusCode == http .StatusForbidden {
107+ return User {}, errors .New ("request is forbidden" )
120108 }
121109
122- req , err := http . NewRequest ( "GET" , apiURL + "/orgs/coder/members" , nil )
110+ b , err := io . ReadAll ( res . Body )
123111 if err != nil {
124- return CoderEmployees {}, fmt .Errorf ("coder employee names: %v" , err )
125- }
126- if token != "" {
127- req .Header .Add ("Authorization" , "Bearer " + token )
112+ return User {}, err
128113 }
129114
130- client := http.Client {Timeout : 5 * time .Second }
131- res , err := client .Do (req )
132- if err != nil {
133- return CoderEmployees {}, fmt .Errorf ("coder employee names: %v" , err )
115+ user := User {}
116+ if err := json .Unmarshal (b , & user ); err != nil {
117+ return User {}, err
134118 }
135- defer res .Body .Close ()
136- if res .StatusCode != http .StatusOK {
137- return CoderEmployees {}, fmt .Errorf ("coder employee names: got back status code %d" , res .StatusCode )
119+ return user , nil
120+ }
121+
122+ // OrgStatus indicates whether a GitHub user is a member of a given organization
123+ type OrgStatus int
124+
125+ var _ fmt.Stringer = OrgStatus (0 )
126+
127+ const (
128+ // OrgStatusIndeterminate indicates when a user's organization status
129+ // could not be determined. It is the zero value of the OrgStatus type, and
130+ // any users with this value should be treated as completely untrusted
131+ OrgStatusIndeterminate = iota
132+ // OrgStatusNonMember indicates when a user is definitely NOT part of an
133+ // organization
134+ OrgStatusNonMember
135+ // OrgStatusMember indicates when a user is a member of a Github
136+ // organization
137+ OrgStatusMember
138+ )
139+
140+ func (s OrgStatus ) String () string {
141+ switch s {
142+ case OrgStatusMember :
143+ return "Member"
144+ case OrgStatusNonMember :
145+ return "Non-member"
146+ default :
147+ return "Indeterminate"
138148 }
149+ }
139150
140- b , err := io .ReadAll (res .Body )
151+ // GetUserOrgStatus takes a GitHub username, and checks the GitHub API to see
152+ // whether that member is part of the Coder organization
153+ func (gc * Client ) GetUserOrgStatus (org string , username string ) (OrgStatus , error ) {
154+ // This API endpoint is really annoying, because it's able to produce false
155+ // negatives. Any user can be a public member of Coder, a private member of
156+ // Coder, or a non-member.
157+ //
158+ // So if the function returns status 200, you can always trust that. But if
159+ // it returns any 400 code, that could indicate a few things:
160+ // 1. The user being checked is not part of the organization, but the user
161+ // associated with the token is.
162+ // 2. The user being checked is a member of the organization, but their
163+ // status is private, and the token being used to check belongs to a user
164+ // who is not part of the Coder organization.
165+ // 3. Neither the user being checked nor the user associated with the token
166+ // are members of the organization
167+ //
168+ // The best option is to make sure that the token being used belongs to a
169+ // member of the Coder organization
170+ req , err := http .NewRequest ("GET" , fmt .Sprintf ("%sorgs/%s/%s" , gc .baseURL , org , username ), nil )
141171 if err != nil {
142- return CoderEmployees {}, fmt .Errorf ("coder employee names: %v" , err )
172+ return OrgStatusIndeterminate , err
173+ }
174+ if gc .token != "" {
175+ req .Header .Add ("Authorization" , "Bearer " + gc .token )
143176 }
144- rawMembers , err := parseResponse [[]ghOrganizationMember ](b )
177+
178+ res , err := gc .httpClient .Do (req )
145179 if err != nil {
146- return CoderEmployees {}, fmt . Errorf ( "coder employee names: %v" , err )
180+ return OrgStatusIndeterminate , err
147181 }
182+ defer res .Body .Close ()
148183
149- employeesSet := map [string ]struct {}{}
150- for _ , m := range rawMembers {
151- employeesSet [m .Login ] = struct {}{}
184+ switch res .StatusCode {
185+ case http .StatusNoContent :
186+ return OrgStatusMember , nil
187+ case http .StatusNotFound :
188+ return OrgStatusNonMember , nil
189+ default :
190+ return OrgStatusIndeterminate , nil
152191 }
153- return CoderEmployees {
154- _employees : employeesSet ,
155- }, nil
156192}
0 commit comments