// Copyright (c) 2026 Probo Inc . // // Permission to use, copy, modify, and/or distribute this software for any // purpose with or without fee is hereby granted, provided that the above // copyright notice and this permission notice appear in all copies. // // THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH // REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY // AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, // INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM // LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR // OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR // PERFORMANCE OF THIS SOFTWARE. package drivers import ( "bytes" "context" "encoding/json" "fmt" "net/http" "strings" "time" "go.probo.inc/probo/pkg/coredata" ) // LinearDriver fetches workspace users from Linear via OAuth2-authenticated // GraphQL requests. type LinearDriver struct { httpClient *http.Client } var _ Driver = (*LinearDriver)(nil) type linearUsersRequest struct { Query string `json:"query"` Variables linearUsersVariables `json:"variables"` } type linearUsersVariables struct { After *string `json:"after"` } type linearUsersResponse struct { Data struct { Users struct { Nodes []struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Active bool `json:"active"` Admin bool `json:"admin"` Guest bool `json:"guest"` LastSeen string `json:"lastSeen"` CreatedAt string `json:"createdAt"` } `json:"nodes"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` EndCursor string `json:"endCursor"` } `json:"pageInfo"` } `json:"users"` } `json:"data"` Errors []struct { Message string `json:"message"` } `json:"errors"` } const linearGraphQLEndpoint = "https://api.linear.app/graphql" func NewLinearDriver(httpClient *http.Client) *LinearDriver { return &LinearDriver{ httpClient: httpClient, } } func (d *LinearDriver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { var ( records []AccountRecord after *string ) for range maxPaginationPages { resp, err := d.queryUsers(ctx, after) if err != nil { return nil, err } for _, u := range resp.Data.Users.Nodes { accountType := coredata.AccessEntryAccountTypeUser if strings.HasSuffix(u.Email, ".linear.app") { accountType = coredata.AccessEntryAccountTypeServiceAccount } record := AccountRecord{ Email: u.Email, FullName: u.Name, Role: linearRole(u.Admin, u.Guest), Active: new(u.Active), IsAdmin: u.Admin, ExternalID: u.ID, MFAStatus: coredata.MFAStatusUnknown, AuthMethod: coredata.AccessEntryAuthMethodUnknown, AccountType: accountType, } if u.LastSeen != "" { if t, err := time.Parse(time.RFC3339, u.LastSeen); err == nil { record.LastLogin = &t } } if u.CreatedAt != "" { if t, err := time.Parse(time.RFC3339, u.CreatedAt); err == nil { record.CreatedAt = &t } } if record.Email != "" { records = append(records, record) } } if !resp.Data.Users.PageInfo.HasNextPage || resp.Data.Users.PageInfo.EndCursor == "" { return records, nil } nextCursor := resp.Data.Users.PageInfo.EndCursor after = &nextCursor } return nil, fmt.Errorf("cannot list all linear accounts: %w", ErrPaginationLimitReached) } func (d *LinearDriver) queryUsers(ctx context.Context, after *string) (*linearUsersResponse, error) { const query = ` query AccessReviewLinearUsers($after: String) { users(first: 100, after: $after) { nodes { id email name active admin guest lastSeen createdAt } pageInfo { hasNextPage endCursor } } } ` body := linearUsersRequest{ Query: query, Variables: linearUsersVariables{ After: after, }, } payload, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("cannot marshal linear users query: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, linearGraphQLEndpoint, bytes.NewReader(payload)) if err != nil { return nil, fmt.Errorf("cannot create linear users request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") httpResp, err := d.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("cannot execute linear users request: %w", err) } defer func() { _ = httpResp.Body.Close() }() if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { return nil, fmt.Errorf("cannot fetch linear users: unexpected status %d", httpResp.StatusCode) } var resp linearUsersResponse if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { return nil, fmt.Errorf("cannot decode linear users response: %w", err) } if len(resp.Errors) > 0 { return nil, fmt.Errorf("linear graphql error: %s", resp.Errors[0].Message) } return &resp, nil } func linearRole(admin, guest bool) string { switch { case admin: return "Admin" case guest: return "Guest" default: return "Member" } }