diff --git a/pkg/connector/provisioning_test.go b/pkg/connector/provisioning_test.go new file mode 100644 index 0000000..6493ffb --- /dev/null +++ b/pkg/connector/provisioning_test.go @@ -0,0 +1,408 @@ +package connector + +import ( + "context" + "testing" + + "github.com/conductorone/baton-linear/pkg/linear" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/types/resource" +) + +func TestUserCreate(t *testing.T) { + // Note: These tests validate the code structure and error handling paths. + // Full integration tests would require mocking the Linear API client. + tests := []struct { + name string + email string + }{ + { + name: "user creation with valid email", + email: "newuser@example.com", + }, + { + name: "user creation with different email", + email: "another@example.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := linear.NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + // Create user resource type + userType := userBuilder(client) + + // Create a test user resource with email + userResource, err := resource.NewUserResource( + "Test User", + resourceTypeUser, + "test-user-id", + []resource.UserTraitOption{ + resource.WithEmail(tt.email, true), + }, + ) + if err != nil { + t.Fatalf("failed to create user resource: %v", err) + } + + // Call Create - will fail at API level but validates code path + _, _, err = userType.Create(context.Background(), userResource) + + // We expect an error because we're not mocking the API + // This test validates the code runs without panics and handles the resource correctly + if err == nil { + t.Log("Note: API call succeeded (unexpected in unit test)") + } + }) + } +} + +func TestUserCreate_MissingEmail(t *testing.T) { + client, err := linear.NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + userType := userBuilder(client) + + // Create a user resource without email + userResource, err := resource.NewUserResource( + "Test User", + resourceTypeUser, + "test-user-id", + []resource.UserTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create user resource: %v", err) + } + + _, _, err = userType.Create(context.Background(), userResource) + if err == nil { + t.Error("expected error for missing email, got nil") + } +} + +func TestRoleGrant(t *testing.T) { + tests := []struct { + name string + roleID string + serverResponse string + wantErr bool + errContains string + }{ + { + name: "grant admin role", + roleID: roleAdmin, + serverResponse: `{ + "data": { + "userUpdate": { + "success": true + } + } + }`, + wantErr: false, + }, + { + name: "grant user role (demote from admin)", + roleID: roleUser, + serverResponse: `{ + "data": { + "userUpdate": { + "success": true + } + } + }`, + wantErr: false, + }, + { + name: "grant guest role fails", + roleID: roleGuest, + wantErr: true, + errContains: "cannot be granted via API", + }, + { + name: "grant owner role fails", + roleID: roleOwner, + wantErr: true, + errContains: "cannot be granted via API", + }, + { + name: "grant unknown role fails", + roleID: "unknown-role", + wantErr: true, + errContains: "unknown role", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := linear.NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + roleType := roleBuilder(client) + + // Create principal (user) resource + principal, err := resource.NewUserResource( + "Test User", + resourceTypeUser, + "user-123", + []resource.UserTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create principal resource: %v", err) + } + + // Create role resource for entitlement + roleRes, err := resource.NewRoleResource( + tt.roleID, + resourceTypeRole, + tt.roleID, + []resource.RoleTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create role resource: %v", err) + } + + // Create entitlement + entitlement := &v2.Entitlement{ + Resource: roleRes, + Slug: membership, + } + + _, err = roleType.Grant(context.Background(), principal, entitlement) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + return + } + if tt.errContains != "" && !containsString(err.Error(), tt.errContains) { + t.Errorf("expected error to contain %q, got %q", tt.errContains, err.Error()) + } + return + } + + // Note: For non-error cases, the actual API call will fail + // since we can't redirect it to a test server from here. + // This test validates the validation logic works correctly. + }) + } +} + +func TestRoleGrant_NonUserPrincipal(t *testing.T) { + client, err := linear.NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + roleType := roleBuilder(client) + + // Create a team resource as principal (not a user) + principal, err := resource.NewGroupResource( + "Test Team", + resourceTypeTeam, + "team-123", + []resource.GroupTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create team resource: %v", err) + } + + roleRes, err := resource.NewRoleResource( + roleAdmin, + resourceTypeRole, + roleAdmin, + []resource.RoleTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create role resource: %v", err) + } + + entitlement := &v2.Entitlement{ + Resource: roleRes, + Slug: membership, + } + + _, err = roleType.Grant(context.Background(), principal, entitlement) + if err == nil { + t.Error("expected error for non-user principal, got nil") + } + if !containsString(err.Error(), "only users can be granted roles") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestRoleRevoke(t *testing.T) { + tests := []struct { + name string + roleID string + wantErr bool + errContains string + }{ + { + name: "revoke admin role (demote to user)", + roleID: roleAdmin, + wantErr: false, + }, + { + name: "revoke user role (suspend)", + roleID: roleUser, + wantErr: false, + }, + { + name: "revoke guest role fails", + roleID: roleGuest, + wantErr: true, + errContains: "cannot be revoked via API", + }, + { + name: "revoke owner role fails", + roleID: roleOwner, + wantErr: true, + errContains: "cannot be revoked via API", + }, + { + name: "revoke unknown role fails", + roleID: "unknown-role", + wantErr: true, + errContains: "unknown role", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client, err := linear.NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + roleType := roleBuilder(client) + + // Create principal (user) resource + principal, err := resource.NewUserResource( + "Test User", + resourceTypeUser, + "user-123", + []resource.UserTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create principal resource: %v", err) + } + + // Create role resource for entitlement + roleRes, err := resource.NewRoleResource( + tt.roleID, + resourceTypeRole, + tt.roleID, + []resource.RoleTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create role resource: %v", err) + } + + // Create grant to revoke + grant := &v2.Grant{ + Principal: principal, + Entitlement: &v2.Entitlement{ + Resource: roleRes, + Slug: membership, + }, + } + + _, err = roleType.Revoke(context.Background(), grant) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + return + } + if tt.errContains != "" && !containsString(err.Error(), tt.errContains) { + t.Errorf("expected error to contain %q, got %q", tt.errContains, err.Error()) + } + return + } + + // Note: For non-error cases, the actual API call will fail + // since we can't redirect it to a test server from here. + }) + } +} + +func TestRoleRevoke_NonUserPrincipal(t *testing.T) { + client, err := linear.NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + roleType := roleBuilder(client) + + // Create a team resource as principal (not a user) + principal, err := resource.NewGroupResource( + "Test Team", + resourceTypeTeam, + "team-123", + []resource.GroupTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create team resource: %v", err) + } + + roleRes, err := resource.NewRoleResource( + roleAdmin, + resourceTypeRole, + roleAdmin, + []resource.RoleTraitOption{}, + ) + if err != nil { + t.Fatalf("failed to create role resource: %v", err) + } + + grant := &v2.Grant{ + Principal: principal, + Entitlement: &v2.Entitlement{ + Resource: roleRes, + Slug: membership, + }, + } + + _, err = roleType.Revoke(context.Background(), grant) + if err == nil { + t.Error("expected error for non-user principal, got nil") + } + if !containsString(err.Error(), "only users can have roles revoked") { + t.Errorf("unexpected error message: %v", err) + } +} + +// Helper function to check if a string contains a substring. +func containsString(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStringHelper(s, substr)) +} + +func containsStringHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} + +// TestLinearRoleConstants verifies the role constants are correctly defined. +func TestLinearRoleConstants(t *testing.T) { + if linear.LinearRoleAdmin != "ADMIN" { + t.Errorf("expected LinearRoleAdmin to be ADMIN, got %s", linear.LinearRoleAdmin) + } + if linear.LinearRoleMember != "MEMBER" { + t.Errorf("expected LinearRoleMember to be MEMBER, got %s", linear.LinearRoleMember) + } + if linear.LinearRoleGuest != "GUEST" { + t.Errorf("expected LinearRoleGuest to be GUEST, got %s", linear.LinearRoleGuest) + } +} diff --git a/pkg/connector/role.go b/pkg/connector/role.go index ed245e3..302a91d 100644 --- a/pkg/connector/role.go +++ b/pkg/connector/role.go @@ -96,6 +96,74 @@ func (o *roleResourceType) Grants(ctx context.Context, resource *v2.Resource, to return nil, "", nil, nil } +// Grant assigns a role to a user. +func (o *roleResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { + if principal.Id.ResourceType != resourceTypeUser.Id { + return nil, fmt.Errorf("linear-connector: only users can be granted roles") + } + + roleID := entitlement.Resource.Id.Resource + userID := principal.Id.Resource + + switch roleID { + case roleAdmin: + // Promote to admin + admin := true + err := o.client.UpdateUser(ctx, userID, &admin, nil) + if err != nil { + return nil, fmt.Errorf("linear-connector: failed to grant admin role: %w", err) + } + case roleUser: + // Demote from admin to regular user + admin := false + err := o.client.UpdateUser(ctx, userID, &admin, nil) + if err != nil { + return nil, fmt.Errorf("linear-connector: failed to grant user role: %w", err) + } + case roleGuest, roleOwner: + // Guest is set at invite time, Owner requires enterprise UI + return nil, fmt.Errorf("linear-connector: %s role cannot be granted via API", roleID) + default: + return nil, fmt.Errorf("linear-connector: unknown role: %s", roleID) + } + + return nil, nil +} + +// Revoke removes a role from a user. +func (o *roleResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { + principal := grant.Principal + if principal.Id.ResourceType != resourceTypeUser.Id { + return nil, fmt.Errorf("linear-connector: only users can have roles revoked") + } + + roleID := grant.Entitlement.Resource.Id.Resource + userID := principal.Id.Resource + + switch roleID { + case roleAdmin: + // Demote admin to regular user + admin := false + err := o.client.UpdateUser(ctx, userID, &admin, nil) + if err != nil { + return nil, fmt.Errorf("linear-connector: failed to revoke admin role: %w", err) + } + case roleUser: + // Deactivate user (suspend) + active := false + err := o.client.UpdateUser(ctx, userID, nil, &active) + if err != nil { + return nil, fmt.Errorf("linear-connector: failed to suspend user: %w", err) + } + case roleGuest, roleOwner: + return nil, fmt.Errorf("linear-connector: %s role cannot be revoked via API", roleID) + default: + return nil, fmt.Errorf("linear-connector: unknown role: %s", roleID) + } + + return nil, nil +} + func roleBuilder(client *linear.Client) *roleResourceType { return &roleResourceType{ resourceType: resourceTypeRole, diff --git a/pkg/connector/user.go b/pkg/connector/user.go index cf0ed06..8c09336 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -136,6 +136,30 @@ func (o *userResourceType) Grants(ctx context.Context, resource *v2.Resource, pt return rv, "", nil, nil } +// Create provisions a new user by sending an organization invite email. +func (o *userResourceType) Create(ctx context.Context, resource *v2.Resource) (*v2.Resource, annotations.Annotations, error) { + userTrait, err := sdkResource.GetUserTrait(resource) + if err != nil { + return nil, nil, fmt.Errorf("linear-connector: failed to get user trait: %w", err) + } + + emails := userTrait.GetEmails() + if len(emails) == 0 { + return nil, nil, fmt.Errorf("linear-connector: user email is required for provisioning") + } + email := emails[0].GetAddress() + + // Default to MEMBER role for new invites + role := linear.LinearRoleMember + + _, err = o.client.CreateOrganizationInvite(ctx, email, role) + if err != nil { + return nil, nil, fmt.Errorf("linear-connector: failed to create organization invite: %w", err) + } + + return resource, nil, nil +} + func userBuilder(client *linear.Client) *userResourceType { return &userResourceType{ resourceType: resourceTypeUser, diff --git a/pkg/linear/client.go b/pkg/linear/client.go index eba40ae..30ec230 100644 --- a/pkg/linear/client.go +++ b/pkg/linear/client.go @@ -915,7 +915,7 @@ func (c *Client) CreateIssueLabel(ctx context.Context, labelName string) (*Issue success issueLabel { id - name + name } } }` @@ -953,6 +953,100 @@ func (c *Client) CreateIssueLabel(ctx context.Context, labelName string) (*Issue return &res.Data.IssueLabelCreate.IssueLabel, resp, rlData, nil } +// CreateOrganizationInvite sends an email invite to provision a new user. +// Role should be one of: LinearRoleAdmin, LinearRoleMember, or LinearRoleGuest. +func (c *Client) CreateOrganizationInvite(ctx context.Context, email string, role string) (*OrganizationInvite, error) { + mutation := `mutation OrganizationInviteCreate($input: OrganizationInviteCreateInput!) { + organizationInviteCreate(input: $input) { + success + organizationInvite { + id + email + } + } + }` + + vars := map[string]interface{}{ + "input": map[string]interface{}{ + "email": email, + "role": role, + }, + } + + b := map[string]interface{}{ + "query": mutation, + "variables": vars, + } + + var res struct { + Data struct { + OrganizationInviteCreate struct { + Success bool `json:"success"` + OrganizationInvite OrganizationInvite `json:"organizationInvite"` + } `json:"organizationInviteCreate"` + } `json:"data"` + } + resp, _, err := c.doRequest(ctx, b, &res) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + + if !res.Data.OrganizationInviteCreate.Success { + return nil, fmt.Errorf("failed to create organization invite") + } + + return &res.Data.OrganizationInviteCreate.OrganizationInvite, nil +} + +// UpdateUser updates user properties such as admin status and active status. +func (c *Client) UpdateUser(ctx context.Context, userID string, admin *bool, active *bool) error { + mutation := `mutation UserUpdate($id: String!, $input: UserUpdateInput!) { + userUpdate(id: $id, input: $input) { + success + } + }` + + input := make(map[string]interface{}) + if admin != nil { + input["admin"] = *admin + } + if active != nil { + input["active"] = *active + } + + vars := map[string]interface{}{ + "id": userID, + "input": input, + } + + b := map[string]interface{}{ + "query": mutation, + "variables": vars, + } + + var res struct { + Data struct { + UserUpdate struct { + Success bool `json:"success"` + } `json:"userUpdate"` + } `json:"data"` + } + resp, _, err := c.doRequest(ctx, b, &res) + if err != nil { + return err + } + + defer resp.Body.Close() + + if !res.Data.UserUpdate.Success { + return fmt.Errorf("failed to update user") + } + + return nil +} + func (c *Client) doRequest(ctx context.Context, body interface{}, res interface{}) (*http.Response, *v2.RateLimitDescription, error) { rlData := &v2.RateLimitDescription{} options := []uhttp.RequestOption{ diff --git a/pkg/linear/client_test.go b/pkg/linear/client_test.go new file mode 100644 index 0000000..d9079e7 --- /dev/null +++ b/pkg/linear/client_test.go @@ -0,0 +1,375 @@ +package linear + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestCreateOrganizationInvite(t *testing.T) { + tests := []struct { + name string + email string + role string + serverResponse string + statusCode int + wantErr bool + wantInviteID string + }{ + { + name: "successful invite creation", + email: "test@example.com", + role: LinearRoleMember, + serverResponse: `{ + "data": { + "organizationInviteCreate": { + "success": true, + "organizationInvite": { + "id": "invite-123", + "email": "test@example.com" + } + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + wantInviteID: "invite-123", + }, + { + name: "invite creation with admin role", + email: "admin@example.com", + role: LinearRoleAdmin, + serverResponse: `{ + "data": { + "organizationInviteCreate": { + "success": true, + "organizationInvite": { + "id": "invite-456", + "email": "admin@example.com" + } + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + wantInviteID: "invite-456", + }, + { + name: "invite creation with guest role", + email: "guest@example.com", + role: LinearRoleGuest, + serverResponse: `{ + "data": { + "organizationInviteCreate": { + "success": true, + "organizationInvite": { + "id": "invite-789", + "email": "guest@example.com" + } + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + wantInviteID: "invite-789", + }, + { + name: "invite creation fails - success false", + email: "test@example.com", + role: LinearRoleMember, + serverResponse: `{ + "data": { + "organizationInviteCreate": { + "success": false, + "organizationInvite": null + } + } + }`, + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "server error", + email: "test@example.com", + role: LinearRoleMember, + serverResponse: `{"errors": [{"message": "Internal server error"}]}`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + // Verify request body contains expected data + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + + variables, ok := reqBody["variables"].(map[string]interface{}) + if !ok { + t.Error("request body missing variables") + } + + input, ok := variables["input"].(map[string]interface{}) + if !ok { + t.Error("variables missing input") + } + + if input["email"] != tt.email { + t.Errorf("expected email %s, got %s", tt.email, input["email"]) + } + + if input["role"] != tt.role { + t.Errorf("expected role %s, got %s", tt.role, input["role"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + client, err := NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + // Override the API URL to use the test server + client.apiUrl = mustParseURL(server.URL) + + invite, err := client.CreateOrganizationInvite(context.Background(), tt.email, tt.role) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if invite.ID != tt.wantInviteID { + t.Errorf("expected invite ID %s, got %s", tt.wantInviteID, invite.ID) + } + + if invite.Email != tt.email { + t.Errorf("expected email %s, got %s", tt.email, invite.Email) + } + }) + } +} + +func TestUpdateUser(t *testing.T) { + tests := []struct { + name string + userID string + admin *bool + active *bool + serverResponse string + statusCode int + wantErr bool + }{ + { + name: "promote to admin", + userID: "user-123", + admin: boolPtr(true), + active: nil, + serverResponse: `{ + "data": { + "userUpdate": { + "success": true + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "demote from admin", + userID: "user-123", + admin: boolPtr(false), + active: nil, + serverResponse: `{ + "data": { + "userUpdate": { + "success": true + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "suspend user", + userID: "user-456", + admin: nil, + active: boolPtr(false), + serverResponse: `{ + "data": { + "userUpdate": { + "success": true + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "reactivate user", + userID: "user-456", + admin: nil, + active: boolPtr(true), + serverResponse: `{ + "data": { + "userUpdate": { + "success": true + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "update both admin and active", + userID: "user-789", + admin: boolPtr(true), + active: boolPtr(true), + serverResponse: `{ + "data": { + "userUpdate": { + "success": true + } + } + }`, + statusCode: http.StatusOK, + wantErr: false, + }, + { + name: "update fails - success false", + userID: "user-123", + admin: boolPtr(true), + active: nil, + serverResponse: `{ + "data": { + "userUpdate": { + "success": false + } + } + }`, + statusCode: http.StatusOK, + wantErr: true, + }, + { + name: "server error", + userID: "user-123", + admin: boolPtr(true), + active: nil, + serverResponse: `{"errors": [{"message": "Internal server error"}]}`, + statusCode: http.StatusInternalServerError, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify request method + if r.Method != http.MethodPost { + t.Errorf("expected POST request, got %s", r.Method) + } + + // Verify request body contains expected data + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + t.Errorf("failed to decode request body: %v", err) + } + + variables, ok := reqBody["variables"].(map[string]interface{}) + if !ok { + t.Error("request body missing variables") + } + + if variables["id"] != tt.userID { + t.Errorf("expected user ID %s, got %s", tt.userID, variables["id"]) + } + + input, ok := variables["input"].(map[string]interface{}) + if !ok { + t.Error("variables missing input") + } + + // Verify admin field if set + if tt.admin != nil { + adminVal, exists := input["admin"] + if !exists { + t.Error("expected admin field in input") + } else if adminVal != *tt.admin { + t.Errorf("expected admin %v, got %v", *tt.admin, adminVal) + } + } + + // Verify active field if set + if tt.active != nil { + activeVal, exists := input["active"] + if !exists { + t.Error("expected active field in input") + } else if activeVal != *tt.active { + t.Errorf("expected active %v, got %v", *tt.active, activeVal) + } + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.serverResponse)) + })) + defer server.Close() + + client, err := NewClient(context.Background(), "test-api-key") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + // Override the API URL to use the test server + client.apiUrl = mustParseURL(server.URL) + + err = client.UpdateUser(context.Background(), tt.userID, tt.admin, tt.active) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + } + }) + } +} + +// Helper functions +func boolPtr(b bool) *bool { + return &b +} + +func mustParseURL(rawURL string) *url.URL { + u, err := url.Parse(rawURL) + if err != nil { + panic(err) + } + return u +} diff --git a/pkg/linear/models.go b/pkg/linear/models.go index 0f37d0c..abc76d5 100644 --- a/pkg/linear/models.go +++ b/pkg/linear/models.go @@ -196,3 +196,16 @@ type IssueLabel struct { ID string `json:"id"` Name string `json:"name"` } + +// OrganizationInvite represents an invitation to join the organization. +type OrganizationInvite struct { + ID string `json:"id"` + Email string `json:"email"` +} + +// Linear API role constants for organization invites. +const ( + LinearRoleAdmin = "ADMIN" + LinearRoleMember = "MEMBER" + LinearRoleGuest = "GUEST" +)