This is page 6 of 6. Use http://codebase.md/geropl/linear-mcp-go?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .clinerules │ └── memory-bank.md ├── .devcontainer │ ├── devcontainer.json │ └── Dockerfile ├── .github │ └── workflows │ └── release.yml ├── .gitignore ├── .gitpod.yml ├── cmd │ ├── root.go │ ├── serve.go │ ├── setup_test.go │ ├── setup.go │ └── version.go ├── docs │ ├── design │ │ ├── 001-mcp-go-upgrade.md │ │ └── 002-project-milestone-initiative.md │ └── prd │ ├── 000-tool-standardization-overview.md │ ├── 001-api-refresher.md │ ├── 002-tool-standardization.md │ ├── 003-tool-standardization-implementation.md │ ├── 004-tool-standardization-tracking.md │ ├── 005-sample-implementation.md │ ├── 006-issue-comments-pagination.md │ └── README.md ├── go.mod ├── go.sum ├── main.go ├── memory-bank │ ├── activeContext.md │ ├── developmentWorkflows.md │ ├── productContext.md │ ├── progress.md │ ├── projectbrief.md │ ├── systemPatterns.md │ └── techContext.md ├── pkg │ ├── linear │ │ ├── client.go │ │ ├── models.go │ │ ├── rate_limiter.go │ │ └── test_helpers.go │ ├── server │ │ ├── resources_test.go │ │ ├── resources.go │ │ ├── server.go │ │ ├── test_helpers.go │ │ └── tools_test.go │ └── tools │ ├── add_comment.go │ ├── common.go │ ├── create_issue.go │ ├── get_issue_comments.go │ ├── get_issue.go │ ├── get_teams.go │ ├── get_user_issues.go │ ├── initiative_tools.go │ ├── milestone_tools.go │ ├── priority_test.go │ ├── priority.go │ ├── project_tools.go │ ├── rendering.go │ ├── reply_to_comment.go │ ├── search_issues.go │ ├── update_issue_comment.go │ └── update_issue.go ├── README.md ├── scripts │ └── register-cline.sh └── testdata ├── fixtures │ ├── add_comment_handler_Missing body.yaml │ ├── add_comment_handler_Missing issue.yaml │ ├── add_comment_handler_Missing issueId.yaml │ ├── add_comment_handler_Reply with shorthand.yaml │ ├── add_comment_handler_Reply with URL.yaml │ ├── add_comment_handler_Reply_to_comment.yaml │ ├── add_comment_handler_Valid comment.yaml │ ├── create_initiative_handler_Missing name.yaml │ ├── create_initiative_handler_Valid initiative.yaml │ ├── create_initiative_handler_With description.yaml │ ├── create_issue_handler_Create issue with invalid project.yaml │ ├── create_issue_handler_Create issue with labels.yaml │ ├── create_issue_handler_Create issue with project ID.yaml │ ├── create_issue_handler_Create issue with project name.yaml │ ├── create_issue_handler_Create issue with project slug.yaml │ ├── create_issue_handler_Create sub issue from identifier.yaml │ ├── create_issue_handler_Create sub issue with labels.yaml │ ├── create_issue_handler_Create sub issue.yaml │ ├── create_issue_handler_Invalid team.yaml │ ├── create_issue_handler_Missing team.yaml │ ├── create_issue_handler_Missing teamId.yaml │ ├── create_issue_handler_Missing title.yaml │ ├── create_issue_handler_Valid issue with team key.yaml │ ├── create_issue_handler_Valid issue with team name.yaml │ ├── create_issue_handler_Valid issue with team UUID.yaml │ ├── create_issue_handler_Valid issue with team.yaml │ ├── create_issue_handler_Valid issue with teamId.yaml │ ├── create_issue_handler_Valid issue.yaml │ ├── create_milestone_handler_Invalid project ID.yaml │ ├── create_milestone_handler_Missing name.yaml │ ├── create_milestone_handler_Valid milestone.yaml │ ├── create_milestone_handler_With all optional fields.yaml │ ├── create_project_handler_Invalid team ID.yaml │ ├── create_project_handler_Missing name.yaml │ ├── create_project_handler_Valid project.yaml │ ├── create_project_handler_With all optional fields.yaml │ ├── get_initiative_handler_By name.yaml │ ├── get_initiative_handler_Non-existent name.yaml │ ├── get_initiative_handler_Valid initiative.yaml │ ├── get_issue_comments_handler_Invalid issue.yaml │ ├── get_issue_comments_handler_Missing issue.yaml │ ├── get_issue_comments_handler_Thread_with_pagination.yaml │ ├── get_issue_comments_handler_Valid issue.yaml │ ├── get_issue_comments_handler_With limit.yaml │ ├── get_issue_comments_handler_With_thread_parameter.yaml │ ├── get_issue_handler_Get comment issue.yaml │ ├── get_issue_handler_Missing issue.yaml │ ├── get_issue_handler_Missing issueId.yaml │ ├── get_issue_handler_Valid issue.yaml │ ├── get_milestone_handler_By name.yaml │ ├── get_milestone_handler_Non-existent milestone.yaml │ ├── get_milestone_handler_Valid milestone.yaml │ ├── get_project_handler_By ID.yaml │ ├── get_project_handler_By name.yaml │ ├── get_project_handler_By slug.yaml │ ├── get_project_handler_Invalid project.yaml │ ├── get_project_handler_Missing project param.yaml │ ├── get_project_handler_Non-existent slug.yaml │ ├── get_teams_handler_Get Teams.yaml │ ├── get_user_issues_handler_Current user issues.yaml │ ├── get_user_issues_handler_Specific user issues.yaml │ ├── reply_to_comment_handler_Missing body.yaml │ ├── reply_to_comment_handler_Missing thread.yaml │ ├── reply_to_comment_handler_Reply with URL.yaml │ ├── reply_to_comment_handler_Valid reply.yaml │ ├── resource_TeamResourceHandler_Fetch By ID.yaml │ ├── resource_TeamResourceHandler_Fetch By Key.yaml │ ├── resource_TeamResourceHandler_Fetch By Name.yaml │ ├── resource_TeamResourceHandler_Invalid ID.yaml │ ├── resource_TeamResourceHandler_Missing ID.yaml │ ├── resource_TeamsResourceHandler_List All.yaml │ ├── search_issues_handler_Search by query.yaml │ ├── search_issues_handler_Search by team.yaml │ ├── search_projects_handler_Empty query.yaml │ ├── search_projects_handler_Multiple results.yaml │ ├── search_projects_handler_No results.yaml │ ├── search_projects_handler_Search by query.yaml │ ├── update_comment_handler_Invalid comment identifier.yaml │ ├── update_comment_handler_Missing body.yaml │ ├── update_comment_handler_Missing comment.yaml │ ├── update_comment_handler_Valid comment update with hash only.yaml │ ├── update_comment_handler_Valid comment update with shorthand.yaml │ ├── update_comment_handler_Valid comment update.yaml │ ├── update_initiative_handler_Non-existent initiative.yaml │ ├── update_initiative_handler_Valid update.yaml │ ├── update_issue_handler_Missing id.yaml │ ├── update_issue_handler_Valid update.yaml │ ├── update_milestone_handler_Non-existent milestone.yaml │ ├── update_milestone_handler_Valid update.yaml │ ├── update_project_handler_Non-existent project.yaml │ ├── update_project_handler_Update name and description.yaml │ ├── update_project_handler_Update only description.yaml │ └── update_project_handler_Valid update.yaml └── golden ├── add_comment_handler_Missing body.golden ├── add_comment_handler_Missing issue.golden ├── add_comment_handler_Missing issueId.golden ├── add_comment_handler_Reply with shorthand.golden ├── add_comment_handler_Reply with URL.golden ├── add_comment_handler_Reply_to_comment.golden ├── add_comment_handler_Valid comment.golden ├── create_initiative_handler_Missing name.golden ├── create_initiative_handler_Valid initiative.golden ├── create_initiative_handler_With description.golden ├── create_issue_handler_Create issue with invalid project.golden ├── create_issue_handler_Create issue with labels.golden ├── create_issue_handler_Create issue with project ID.golden ├── create_issue_handler_Create issue with project name.golden ├── create_issue_handler_Create issue with project slug.golden ├── create_issue_handler_Create sub issue from identifier.golden ├── create_issue_handler_Create sub issue with labels.golden ├── create_issue_handler_Create sub issue.golden ├── create_issue_handler_Invalid team.golden ├── create_issue_handler_Missing team.golden ├── create_issue_handler_Missing teamId.golden ├── create_issue_handler_Missing title.golden ├── create_issue_handler_Valid issue with team key.golden ├── create_issue_handler_Valid issue with team name.golden ├── create_issue_handler_Valid issue with team UUID.golden ├── create_issue_handler_Valid issue with team.golden ├── create_issue_handler_Valid issue with teamId.golden ├── create_issue_handler_Valid issue.golden ├── create_milestone_handler_Invalid project ID.golden ├── create_milestone_handler_Missing name.golden ├── create_milestone_handler_Valid milestone.golden ├── create_milestone_handler_With all optional fields.golden ├── create_project_handler_Invalid team ID.golden ├── create_project_handler_Missing name.golden ├── create_project_handler_Valid project.golden ├── create_project_handler_With all optional fields.golden ├── get_initiative_handler_By name.golden ├── get_initiative_handler_Non-existent name.golden ├── get_initiative_handler_Valid initiative.golden ├── get_issue_comments_handler_Invalid issue.golden ├── get_issue_comments_handler_Missing issue.golden ├── get_issue_comments_handler_Thread_with_pagination.golden ├── get_issue_comments_handler_Valid issue.golden ├── get_issue_comments_handler_With limit.golden ├── get_issue_comments_handler_With_thread_parameter.golden ├── get_issue_handler_Get comment issue.golden ├── get_issue_handler_Missing issue.golden ├── get_issue_handler_Missing issueId.golden ├── get_issue_handler_Valid issue.golden ├── get_milestone_handler_By name.golden ├── get_milestone_handler_Non-existent milestone.golden ├── get_milestone_handler_Valid milestone.golden ├── get_project_handler_By ID.golden ├── get_project_handler_By name.golden ├── get_project_handler_By slug.golden ├── get_project_handler_Invalid project.golden ├── get_project_handler_Missing project param.golden ├── get_project_handler_Non-existent slug.golden ├── get_teams_handler_Get Teams.golden ├── get_user_issues_handler_Current user issues.golden ├── get_user_issues_handler_Specific user issues.golden ├── reply_to_comment_handler_Missing body.golden ├── reply_to_comment_handler_Missing thread.golden ├── reply_to_comment_handler_Reply with URL.golden ├── reply_to_comment_handler_Valid reply.golden ├── resource_TeamResourceHandler_Fetch By ID.golden ├── resource_TeamResourceHandler_Fetch By Key.golden ├── resource_TeamResourceHandler_Fetch By Name.golden ├── resource_TeamResourceHandler_Invalid ID.golden ├── resource_TeamResourceHandler_Missing ID.golden ├── resource_TeamsResourceHandler_List All.golden ├── search_issues_handler_Search by query.golden ├── search_issues_handler_Search by team.golden ├── search_projects_handler_Empty query.golden ├── search_projects_handler_Multiple results.golden ├── search_projects_handler_No results.golden ├── search_projects_handler_Search by query.golden ├── update_comment_handler_Invalid comment identifier.golden ├── update_comment_handler_Missing body.golden ├── update_comment_handler_Missing comment.golden ├── update_comment_handler_Valid comment update with hash only.golden ├── update_comment_handler_Valid comment update with shorthand.golden ├── update_comment_handler_Valid comment update.golden ├── update_initiative_handler_Non-existent initiative.golden ├── update_initiative_handler_Valid update.golden ├── update_issue_handler_Missing id.golden ├── update_issue_handler_Valid update.golden ├── update_milestone_handler_Non-existent milestone.golden ├── update_milestone_handler_Valid update.golden ├── update_project_handler_Non-existent project.golden ├── update_project_handler_Update name and description.golden ├── update_project_handler_Update only description.golden └── update_project_handler_Valid update.golden ``` # Files -------------------------------------------------------------------------------- /pkg/linear/client.go: -------------------------------------------------------------------------------- ```go 1 | package linear 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "os" 11 | "strconv" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | const ( 17 | LinearAPIEndpoint = "https://api.linear.app/graphql" 18 | ) 19 | 20 | // LinearClient is a client for the Linear API 21 | type LinearClient struct { 22 | apiKey string 23 | httpClient *http.Client 24 | rateLimiter *RateLimiter 25 | 26 | serverVersion string 27 | } 28 | 29 | // NewLinearClient creates a new Linear API client 30 | func NewLinearClient(apiKey string, serverVersion string) (*LinearClient, error) { 31 | if apiKey == "" { 32 | return nil, errors.New("LINEAR_API_KEY environment variable is required") 33 | } 34 | 35 | return &LinearClient{ 36 | apiKey: apiKey, 37 | httpClient: &http.Client{ 38 | Timeout: 30 * time.Second, 39 | }, 40 | rateLimiter: NewRateLimiter(1400), // Linear API limit is 1400 requests per hour 41 | serverVersion: serverVersion, 42 | }, nil 43 | } 44 | 45 | // NewLinearClientFromEnv creates a new Linear API client from environment variables 46 | func NewLinearClientFromEnv(serverVersion string) (*LinearClient, error) { 47 | apiKey := os.Getenv("LINEAR_API_KEY") 48 | return NewLinearClient(apiKey, serverVersion) 49 | } 50 | 51 | // executeGraphQL executes a GraphQL query against the Linear API 52 | func (c *LinearClient) executeGraphQL(query string, variables map[string]interface{}) (*GraphQLResponse, error) { 53 | // Create the request body 54 | reqBody := GraphQLRequest{ 55 | Query: query, 56 | Variables: variables, 57 | } 58 | 59 | // Marshal the request body to JSON 60 | reqBodyBytes, err := json.Marshal(reqBody) 61 | if err != nil { 62 | return nil, fmt.Errorf("failed to marshal request body: %w", err) 63 | } 64 | 65 | // Create the HTTP request 66 | req, err := http.NewRequest("POST", LinearAPIEndpoint, bytes.NewBuffer(reqBodyBytes)) 67 | if err != nil { 68 | return nil, fmt.Errorf("failed to create request: %w", err) 69 | } 70 | 71 | // Set headers 72 | req.Header.Set("Content-Type", "application/json") 73 | req.Header.Set("Authorization", c.apiKey) 74 | req.Header.Set("User-Agent", fmt.Sprintf("linear-mcp-go/%s", c.serverVersion)) 75 | 76 | // Execute the request with rate limiting 77 | var resp *http.Response 78 | err = c.rateLimiter.Enqueue(func() error { 79 | var reqErr error 80 | resp, reqErr = c.httpClient.Do(req) 81 | return reqErr 82 | }, "graphql") 83 | 84 | if err != nil { 85 | return nil, fmt.Errorf("failed to execute request: %w", err) 86 | } 87 | defer resp.Body.Close() 88 | 89 | // Read the response body 90 | respBody, err := io.ReadAll(resp.Body) 91 | if err != nil { 92 | return nil, fmt.Errorf("failed to read response body: %w", err) 93 | } 94 | 95 | // Check for HTTP errors 96 | if resp.StatusCode != http.StatusOK { 97 | return nil, fmt.Errorf("API returned non-200 status code: %d, body: %s", resp.StatusCode, string(respBody)) 98 | } 99 | 100 | // Parse the response 101 | var graphQLResp GraphQLResponse 102 | if err := json.Unmarshal(respBody, &graphQLResp); err != nil { 103 | return nil, fmt.Errorf("failed to unmarshal response: %w", err) 104 | } 105 | 106 | // Check for GraphQL errors 107 | if len(graphQLResp.Errors) > 0 { 108 | return nil, fmt.Errorf("GraphQL error: %s", graphQLResp.Errors[0].Message) 109 | } 110 | 111 | return &graphQLResp, nil 112 | } 113 | 114 | // GetIssue gets an issue by ID 115 | func (c *LinearClient) GetIssue(issueID string) (*Issue, error) { 116 | query := ` 117 | query GetIssue($id: String!) { 118 | issue(id: $id) { 119 | id 120 | identifier 121 | title 122 | description 123 | priority 124 | url 125 | createdAt 126 | updatedAt 127 | state { 128 | id 129 | name 130 | } 131 | assignee { 132 | id 133 | name 134 | email 135 | } 136 | team { 137 | id 138 | name 139 | key 140 | } 141 | project { 142 | id 143 | name 144 | } 145 | projectMilestone { 146 | id 147 | name 148 | } 149 | relations(first: 20) { 150 | nodes { 151 | id 152 | type 153 | relatedIssue { 154 | id 155 | identifier 156 | title 157 | url 158 | } 159 | } 160 | } 161 | inverseRelations(first: 20) { 162 | nodes { 163 | id 164 | type 165 | issue { 166 | id 167 | identifier 168 | title 169 | url 170 | } 171 | } 172 | } 173 | attachments(first: 50) { 174 | nodes { 175 | id 176 | title 177 | subtitle 178 | url 179 | sourceType 180 | metadata 181 | createdAt 182 | } 183 | } 184 | } 185 | } 186 | ` 187 | 188 | variables := map[string]interface{}{ 189 | "id": issueID, 190 | } 191 | 192 | resp, err := c.executeGraphQL(query, variables) 193 | if err != nil { 194 | return nil, err 195 | } 196 | 197 | // Extract the issue from the response 198 | issueData, ok := resp.Data["issue"].(map[string]interface{}) 199 | if !ok || issueData == nil { 200 | return nil, fmt.Errorf("issue %s not found", issueID) 201 | } 202 | 203 | // Parse the issue data 204 | var issue Issue 205 | issueBytes, err := json.Marshal(issueData) 206 | if err != nil { 207 | return nil, fmt.Errorf("failed to marshal issue data: %w", err) 208 | } 209 | 210 | if err := json.Unmarshal(issueBytes, &issue); err != nil { 211 | return nil, fmt.Errorf("failed to unmarshal issue data: %w", err) 212 | } 213 | 214 | return &issue, nil 215 | } 216 | 217 | // GetProject gets a project by identifier (ID, name, or slug) 218 | func (c *LinearClient) GetProject(identifier string) (*Project, error) { 219 | // First, try to get the project by ID 220 | project, err := c.getProjectByID(identifier) 221 | if err == nil { 222 | return project, nil 223 | } 224 | 225 | // If not found by ID, try to get by name or slug 226 | return c.getProjectByNameOrSlug(identifier) 227 | } 228 | 229 | // getProjectByID gets a project by its UUID 230 | func (c *LinearClient) getProjectByID(id string) (*Project, error) { 231 | query := ` 232 | query GetProject($id: String!) { 233 | project(id: $id) { 234 | id 235 | name 236 | description 237 | slugId 238 | state 239 | url 240 | createdAt 241 | updatedAt 242 | lead { 243 | id 244 | name 245 | email 246 | } 247 | members { 248 | nodes { 249 | id 250 | name 251 | email 252 | } 253 | } 254 | teams { 255 | nodes { 256 | id 257 | name 258 | key 259 | } 260 | } 261 | initiatives(first: 10) { 262 | nodes { 263 | id 264 | name 265 | } 266 | } 267 | startDate 268 | targetDate 269 | } 270 | } 271 | ` 272 | 273 | variables := map[string]interface{}{ 274 | "id": id, 275 | } 276 | 277 | resp, err := c.executeGraphQL(query, variables) 278 | if err != nil { 279 | return nil, err 280 | } 281 | 282 | projectData, ok := resp.Data["project"].(map[string]interface{}) 283 | if !ok || projectData == nil { 284 | return nil, fmt.Errorf("project with ID %s not found", id) 285 | } 286 | 287 | var project Project 288 | projectBytes, err := json.Marshal(projectData) 289 | if err != nil { 290 | return nil, fmt.Errorf("failed to marshal project data: %w", err) 291 | } 292 | 293 | if err := json.Unmarshal(projectBytes, &project); err != nil { 294 | return nil, fmt.Errorf("failed to unmarshal project data: %w", err) 295 | } 296 | 297 | return &project, nil 298 | } 299 | 300 | // getProjectByNameOrSlug gets a project by its name or slug 301 | func (c *LinearClient) getProjectByNameOrSlug(identifier string) (*Project, error) { 302 | query := ` 303 | query GetProjectByNameOrSlug($filter: ProjectFilter) { 304 | projects(filter: $filter, first: 1) { 305 | nodes { 306 | id 307 | name 308 | description 309 | slugId 310 | state 311 | url 312 | createdAt 313 | updatedAt 314 | lead { 315 | id 316 | name 317 | email 318 | } 319 | members { 320 | nodes { 321 | id 322 | name 323 | email 324 | } 325 | } 326 | teams { 327 | nodes { 328 | id 329 | name 330 | key 331 | } 332 | } 333 | initiatives(first: 1) { 334 | nodes { 335 | id 336 | name 337 | } 338 | } 339 | startDate 340 | targetDate 341 | } 342 | } 343 | } 344 | ` 345 | 346 | // Check if the identifier is a slug and extract the slugId 347 | parts := strings.Split(identifier, "-") 348 | slugID := "" 349 | if len(parts) > 1 { 350 | slugID = parts[len(parts)-1] 351 | } 352 | 353 | filter := map[string]interface{}{ 354 | "or": []map[string]interface{}{ 355 | { 356 | "name": map[string]interface{}{"eq": identifier}, 357 | }, 358 | { 359 | "slugId": map[string]interface{}{"eq": slugID}, 360 | }, 361 | }, 362 | } 363 | 364 | variables := map[string]interface{}{ 365 | "filter": filter, 366 | } 367 | 368 | resp, err := c.executeGraphQL(query, variables) 369 | if err != nil { 370 | return nil, err 371 | } 372 | 373 | projectsData, ok := resp.Data["projects"].(map[string]interface{}) 374 | if !ok || projectsData == nil { 375 | return nil, fmt.Errorf("project with identifier '%s' not found", identifier) 376 | } 377 | 378 | nodes, ok := projectsData["nodes"].([]interface{}) 379 | if !ok || len(nodes) == 0 { 380 | return nil, fmt.Errorf("project with identifier '%s' not found", identifier) 381 | } 382 | 383 | projectData, ok := nodes[0].(map[string]interface{}) 384 | if !ok { 385 | return nil, fmt.Errorf("failed to parse project data for identifier '%s'", identifier) 386 | } 387 | 388 | var project Project 389 | projectBytes, err := json.Marshal(projectData) 390 | if err != nil { 391 | return nil, fmt.Errorf("failed to marshal project data: %w", err) 392 | } 393 | 394 | if err := json.Unmarshal(projectBytes, &project); err != nil { 395 | return nil, fmt.Errorf("failed to unmarshal project data: %w", err) 396 | } 397 | 398 | return &project, nil 399 | } 400 | 401 | // SearchProjects searches for projects 402 | func (c *LinearClient) SearchProjects(query string) ([]Project, error) { 403 | graphqlQuery := ` 404 | query SearchProjects($filter: ProjectFilter) { 405 | projects(filter: $filter) { 406 | nodes { 407 | id 408 | name 409 | description 410 | slugId 411 | state 412 | url 413 | initiatives(first: 1) { 414 | nodes { 415 | id 416 | name 417 | } 418 | } 419 | lead { 420 | id 421 | name 422 | } 423 | startDate 424 | targetDate 425 | } 426 | } 427 | } 428 | ` 429 | 430 | filter := map[string]interface{}{ 431 | "name": map[string]interface{}{"containsIgnoreCase": query}, 432 | } 433 | 434 | variables := map[string]interface{}{ 435 | "filter": filter, 436 | } 437 | 438 | resp, err := c.executeGraphQL(graphqlQuery, variables) 439 | if err != nil { 440 | return nil, err 441 | } 442 | 443 | projectsData, ok := resp.Data["projects"].(map[string]interface{}) 444 | if !ok || projectsData == nil { 445 | return []Project{}, nil 446 | } 447 | 448 | nodes, ok := projectsData["nodes"].([]interface{}) 449 | if !ok { 450 | return []Project{}, nil 451 | } 452 | 453 | var projects []Project 454 | for _, node := range nodes { 455 | projectData, ok := node.(map[string]interface{}) 456 | if !ok { 457 | continue 458 | } 459 | 460 | var project Project 461 | projectBytes, err := json.Marshal(projectData) 462 | if err != nil { 463 | return nil, fmt.Errorf("failed to marshal project data: %w", err) 464 | } 465 | 466 | if err := json.Unmarshal(projectBytes, &project); err != nil { 467 | return nil, fmt.Errorf("failed to unmarshal project data: %w", err) 468 | } 469 | projects = append(projects, project) 470 | } 471 | 472 | return projects, nil 473 | } 474 | 475 | // CreateProject creates a new project. 476 | func (c *LinearClient) CreateProject(input ProjectCreateInput) (*Project, error) { 477 | query := ` 478 | mutation ProjectCreate($input: ProjectCreateInput!) { 479 | projectCreate(input: $input) { 480 | success 481 | project { 482 | id 483 | name 484 | description 485 | slugId 486 | state 487 | url 488 | } 489 | } 490 | } 491 | ` 492 | 493 | variables := map[string]interface{}{ 494 | "input": input, 495 | } 496 | 497 | resp, err := c.executeGraphQL(query, variables) 498 | if err != nil { 499 | return nil, err 500 | } 501 | 502 | projectCreateData, ok := resp.Data["projectCreate"].(map[string]interface{}) 503 | if !ok || projectCreateData == nil { 504 | return nil, errors.New("failed to create project") 505 | } 506 | 507 | success, ok := projectCreateData["success"].(bool) 508 | if !ok || !success { 509 | return nil, errors.New("failed to create project") 510 | } 511 | 512 | projectData, ok := projectCreateData["project"].(map[string]interface{}) 513 | if !ok || projectData == nil { 514 | return nil, errors.New("failed to create project") 515 | } 516 | 517 | var project Project 518 | projectBytes, err := json.Marshal(projectData) 519 | if err != nil { 520 | return nil, fmt.Errorf("failed to marshal project data: %w", err) 521 | } 522 | 523 | if err := json.Unmarshal(projectBytes, &project); err != nil { 524 | return nil, fmt.Errorf("failed to unmarshal project data: %w", err) 525 | } 526 | 527 | return &project, nil 528 | } 529 | 530 | // UpdateProject updates an existing project. 531 | func (c *LinearClient) UpdateProject(id string, input ProjectUpdateInput) (*Project, error) { 532 | query := ` 533 | mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) { 534 | projectUpdate(id: $id, input: $input) { 535 | success 536 | project { 537 | id 538 | name 539 | description 540 | slugId 541 | state 542 | url 543 | } 544 | } 545 | } 546 | ` 547 | 548 | variables := map[string]interface{}{ 549 | "id": id, 550 | "input": input, 551 | } 552 | 553 | resp, err := c.executeGraphQL(query, variables) 554 | if err != nil { 555 | return nil, err 556 | } 557 | 558 | projectUpdateData, ok := resp.Data["projectUpdate"].(map[string]interface{}) 559 | if !ok || projectUpdateData == nil { 560 | return nil, errors.New("failed to update project") 561 | } 562 | 563 | success, ok := projectUpdateData["success"].(bool) 564 | if !ok || !success { 565 | return nil, errors.New("failed to update project") 566 | } 567 | 568 | projectData, ok := projectUpdateData["project"].(map[string]interface{}) 569 | if !ok || projectData == nil { 570 | return nil, errors.New("failed to update project") 571 | } 572 | 573 | var project Project 574 | projectBytes, err := json.Marshal(projectData) 575 | if err != nil { 576 | return nil, fmt.Errorf("failed to marshal project data: %w", err) 577 | } 578 | 579 | if err := json.Unmarshal(projectBytes, &project); err != nil { 580 | return nil, fmt.Errorf("failed to unmarshal project data: %w", err) 581 | } 582 | 583 | return &project, nil 584 | } 585 | 586 | // GetMilestone gets a project milestone by identifier (ID or name). 587 | func (c *LinearClient) GetMilestone(identifier string) (*ProjectMilestone, error) { 588 | // First, try to get the milestone by ID 589 | milestone, err := c.getMilestoneByID(identifier) 590 | if err == nil { 591 | return milestone, nil 592 | } 593 | 594 | // If not found by ID, try to get by name 595 | return c.getMilestoneByName(identifier) 596 | } 597 | 598 | // getMilestoneByID gets a project milestone by its UUID. 599 | func (c *LinearClient) getMilestoneByID(id string) (*ProjectMilestone, error) { 600 | query := ` 601 | query ProjectMilestone($id: String!) { 602 | projectMilestone(id: $id) { 603 | id 604 | name 605 | description 606 | targetDate 607 | project { 608 | id 609 | name 610 | } 611 | } 612 | } 613 | ` 614 | 615 | variables := map[string]interface{}{ 616 | "id": id, 617 | } 618 | 619 | resp, err := c.executeGraphQL(query, variables) 620 | if err != nil { 621 | return nil, err 622 | } 623 | 624 | milestoneData, ok := resp.Data["projectMilestone"].(map[string]interface{}) 625 | if !ok || milestoneData == nil { 626 | return nil, fmt.Errorf("milestone with ID %s not found", id) 627 | } 628 | 629 | var milestone ProjectMilestone 630 | milestoneBytes, err := json.Marshal(milestoneData) 631 | if err != nil { 632 | return nil, fmt.Errorf("failed to marshal milestone data: %w", err) 633 | } 634 | 635 | if err := json.Unmarshal(milestoneBytes, &milestone); err != nil { 636 | return nil, fmt.Errorf("failed to unmarshal milestone data: %w", err) 637 | } 638 | 639 | return &milestone, nil 640 | } 641 | 642 | // getMilestoneByName gets a project milestone by its name. 643 | func (c *LinearClient) getMilestoneByName(name string) (*ProjectMilestone, error) { 644 | query := ` 645 | query GetMilestoneByName($filter: ProjectMilestoneFilter) { 646 | projectMilestones(filter: $filter, first: 1) { 647 | nodes { 648 | id 649 | name 650 | description 651 | targetDate 652 | project { 653 | id 654 | name 655 | } 656 | } 657 | } 658 | } 659 | ` 660 | 661 | filter := map[string]interface{}{ 662 | "name": map[string]interface{}{"eq": name}, 663 | } 664 | 665 | variables := map[string]interface{}{ 666 | "filter": filter, 667 | } 668 | 669 | resp, err := c.executeGraphQL(query, variables) 670 | if err != nil { 671 | return nil, err 672 | } 673 | 674 | milestonesData, ok := resp.Data["projectMilestones"].(map[string]interface{}) 675 | if !ok || milestonesData == nil { 676 | return nil, fmt.Errorf("milestone with name '%s' not found", name) 677 | } 678 | 679 | nodes, ok := milestonesData["nodes"].([]interface{}) 680 | if !ok || len(nodes) == 0 { 681 | return nil, fmt.Errorf("milestone with name '%s' not found", name) 682 | } 683 | 684 | milestoneData, ok := nodes[0].(map[string]interface{}) 685 | if !ok { 686 | return nil, fmt.Errorf("failed to parse milestone data for name '%s'", name) 687 | } 688 | 689 | var milestone ProjectMilestone 690 | milestoneBytes, err := json.Marshal(milestoneData) 691 | if err != nil { 692 | return nil, fmt.Errorf("failed to marshal milestone data: %w", err) 693 | } 694 | 695 | if err := json.Unmarshal(milestoneBytes, &milestone); err != nil { 696 | return nil, fmt.Errorf("failed to unmarshal milestone data: %w", err) 697 | } 698 | 699 | return &milestone, nil 700 | } 701 | 702 | // UpdateMilestone updates an existing project milestone. 703 | func (c *LinearClient) UpdateMilestone(id string, input ProjectMilestoneUpdateInput) (*ProjectMilestone, error) { 704 | query := ` 705 | mutation ProjectMilestoneUpdate($id: String!, $input: ProjectMilestoneUpdateInput!) { 706 | projectMilestoneUpdate(id: $id, input: $input) { 707 | success 708 | projectMilestone { 709 | id 710 | name 711 | description 712 | targetDate 713 | project { 714 | id 715 | name 716 | } 717 | } 718 | } 719 | } 720 | ` 721 | 722 | variables := map[string]interface{}{ 723 | "id": id, 724 | "input": input, 725 | } 726 | 727 | resp, err := c.executeGraphQL(query, variables) 728 | if err != nil { 729 | return nil, err 730 | } 731 | 732 | milestoneUpdateData, ok := resp.Data["projectMilestoneUpdate"].(map[string]interface{}) 733 | if !ok || milestoneUpdateData == nil { 734 | return nil, errors.New("failed to update milestone") 735 | } 736 | 737 | success, ok := milestoneUpdateData["success"].(bool) 738 | if !ok || !success { 739 | return nil, errors.New("failed to update milestone") 740 | } 741 | 742 | milestoneData, ok := milestoneUpdateData["projectMilestone"].(map[string]interface{}) 743 | if !ok || milestoneData == nil { 744 | return nil, errors.New("failed to update milestone") 745 | } 746 | 747 | var milestone ProjectMilestone 748 | milestoneBytes, err := json.Marshal(milestoneData) 749 | if err != nil { 750 | return nil, fmt.Errorf("failed to marshal milestone data: %w", err) 751 | } 752 | 753 | if err := json.Unmarshal(milestoneBytes, &milestone); err != nil { 754 | return nil, fmt.Errorf("failed to unmarshal milestone data: %w", err) 755 | } 756 | 757 | return &milestone, nil 758 | } 759 | 760 | // CreateMilestone creates a new project milestone. 761 | func (c *LinearClient) CreateMilestone(input ProjectMilestoneCreateInput) (*ProjectMilestone, error) { 762 | query := ` 763 | mutation ProjectMilestoneCreate($input: ProjectMilestoneCreateInput!) { 764 | projectMilestoneCreate(input: $input) { 765 | success 766 | projectMilestone { 767 | id 768 | name 769 | description 770 | targetDate 771 | project { 772 | id 773 | name 774 | } 775 | } 776 | } 777 | } 778 | ` 779 | 780 | variables := map[string]interface{}{ 781 | "input": input, 782 | } 783 | 784 | resp, err := c.executeGraphQL(query, variables) 785 | if err != nil { 786 | return nil, err 787 | } 788 | 789 | milestoneCreateData, ok := resp.Data["projectMilestoneCreate"].(map[string]interface{}) 790 | if !ok || milestoneCreateData == nil { 791 | return nil, errors.New("failed to create milestone") 792 | } 793 | 794 | success, ok := milestoneCreateData["success"].(bool) 795 | if !ok || !success { 796 | return nil, errors.New("failed to create milestone") 797 | } 798 | 799 | milestoneData, ok := milestoneCreateData["projectMilestone"].(map[string]interface{}) 800 | if !ok || milestoneData == nil { 801 | return nil, errors.New("failed to create milestone") 802 | } 803 | 804 | var milestone ProjectMilestone 805 | milestoneBytes, err := json.Marshal(milestoneData) 806 | if err != nil { 807 | return nil, fmt.Errorf("failed to marshal milestone data: %w", err) 808 | } 809 | 810 | if err := json.Unmarshal(milestoneBytes, &milestone); err != nil { 811 | return nil, fmt.Errorf("failed to unmarshal milestone data: %w", err) 812 | } 813 | 814 | return &milestone, nil 815 | } 816 | 817 | // GetInitiative gets an initiative by identifier (ID or name) 818 | func (c *LinearClient) GetInitiative(identifier string) (*Initiative, error) { 819 | // First, try to get the initiative by ID 820 | initiative, err := c.getInitiativeByID(identifier) 821 | if err == nil { 822 | return initiative, nil 823 | } 824 | 825 | // If not found by ID, try to get by name 826 | return c.getInitiativeByName(identifier) 827 | } 828 | 829 | // getInitiativeByID gets an initiative by its UUID 830 | func (c *LinearClient) getInitiativeByID(id string) (*Initiative, error) { 831 | query := ` 832 | query GetInitiative($id: String!) { 833 | initiative(id: $id) { 834 | id 835 | name 836 | description 837 | url 838 | } 839 | } 840 | ` 841 | 842 | variables := map[string]interface{}{ 843 | "id": id, 844 | } 845 | 846 | resp, err := c.executeGraphQL(query, variables) 847 | if err != nil { 848 | return nil, err 849 | } 850 | 851 | initiativeData, ok := resp.Data["initiative"].(map[string]interface{}) 852 | if !ok || initiativeData == nil { 853 | return nil, fmt.Errorf("initiative with ID %s not found", id) 854 | } 855 | 856 | var initiative Initiative 857 | initiativeBytes, err := json.Marshal(initiativeData) 858 | if err != nil { 859 | return nil, fmt.Errorf("failed to marshal initiative data: %w", err) 860 | } 861 | 862 | if err := json.Unmarshal(initiativeBytes, &initiative); err != nil { 863 | return nil, fmt.Errorf("failed to unmarshal initiative data: %w", err) 864 | } 865 | 866 | return &initiative, nil 867 | } 868 | 869 | // getInitiativeByName gets an initiative by its name 870 | func (c *LinearClient) getInitiativeByName(name string) (*Initiative, error) { 871 | query := ` 872 | query GetInitiativeByName($filter: InitiativeFilter) { 873 | initiatives(filter: $filter, first: 1) { 874 | nodes { 875 | id 876 | name 877 | description 878 | url 879 | } 880 | } 881 | } 882 | ` 883 | 884 | filter := map[string]interface{}{ 885 | "name": map[string]interface{}{"eq": name}, 886 | } 887 | 888 | variables := map[string]interface{}{ 889 | "filter": filter, 890 | } 891 | 892 | resp, err := c.executeGraphQL(query, variables) 893 | if err != nil { 894 | return nil, err 895 | } 896 | 897 | initiativesData, ok := resp.Data["initiatives"].(map[string]interface{}) 898 | if !ok || initiativesData == nil { 899 | return nil, fmt.Errorf("initiative with name '%s' not found", name) 900 | } 901 | 902 | nodes, ok := initiativesData["nodes"].([]interface{}) 903 | if !ok || len(nodes) == 0 { 904 | return nil, fmt.Errorf("initiative with name '%s' not found", name) 905 | } 906 | 907 | initiativeData, ok := nodes[0].(map[string]interface{}) 908 | if !ok { 909 | return nil, fmt.Errorf("failed to parse initiative data for name '%s'", name) 910 | } 911 | 912 | var initiative Initiative 913 | initiativeBytes, err := json.Marshal(initiativeData) 914 | if err != nil { 915 | return nil, fmt.Errorf("failed to marshal initiative data: %w", err) 916 | } 917 | 918 | if err := json.Unmarshal(initiativeBytes, &initiative); err != nil { 919 | return nil, fmt.Errorf("failed to unmarshal initiative data: %w", err) 920 | } 921 | 922 | return &initiative, nil 923 | } 924 | 925 | // UpdateInitiative updates an existing initiative. 926 | func (c *LinearClient) UpdateInitiative(id string, input InitiativeUpdateInput) (*Initiative, error) { 927 | query := ` 928 | mutation InitiativeUpdate($id: String!, $input: InitiativeUpdateInput!) { 929 | initiativeUpdate(id: $id, input: $input) { 930 | success 931 | initiative { 932 | id 933 | name 934 | description 935 | url 936 | } 937 | } 938 | } 939 | ` 940 | 941 | variables := map[string]interface{}{ 942 | "id": id, 943 | "input": input, 944 | } 945 | 946 | resp, err := c.executeGraphQL(query, variables) 947 | if err != nil { 948 | return nil, err 949 | } 950 | 951 | initiativeUpdateData, ok := resp.Data["initiativeUpdate"].(map[string]interface{}) 952 | if !ok || initiativeUpdateData == nil { 953 | return nil, errors.New("failed to update initiative") 954 | } 955 | 956 | success, ok := initiativeUpdateData["success"].(bool) 957 | if !ok || !success { 958 | return nil, errors.New("failed to update initiative") 959 | } 960 | 961 | initiativeData, ok := initiativeUpdateData["initiative"].(map[string]interface{}) 962 | if !ok || initiativeData == nil { 963 | return nil, errors.New("failed to update initiative") 964 | } 965 | 966 | var initiative Initiative 967 | initiativeBytes, err := json.Marshal(initiativeData) 968 | if err != nil { 969 | return nil, fmt.Errorf("failed to marshal initiative data: %w", err) 970 | } 971 | 972 | if err := json.Unmarshal(initiativeBytes, &initiative); err != nil { 973 | return nil, fmt.Errorf("failed to unmarshal initiative data: %w", err) 974 | } 975 | 976 | return &initiative, nil 977 | } 978 | 979 | // CreateInitiative creates a new initiative. 980 | func (c *LinearClient) CreateInitiative(input InitiativeCreateInput) (*Initiative, error) { 981 | query := ` 982 | mutation InitiativeCreate($input: InitiativeCreateInput!) { 983 | initiativeCreate(input: $input) { 984 | success 985 | initiative { 986 | id 987 | name 988 | description 989 | url 990 | } 991 | } 992 | } 993 | ` 994 | 995 | variables := map[string]interface{}{ 996 | "input": input, 997 | } 998 | 999 | resp, err := c.executeGraphQL(query, variables) 1000 | if err != nil { 1001 | return nil, err 1002 | } 1003 | 1004 | initiativeCreateData, ok := resp.Data["initiativeCreate"].(map[string]interface{}) 1005 | if !ok || initiativeCreateData == nil { 1006 | return nil, errors.New("failed to create initiative") 1007 | } 1008 | 1009 | success, ok := initiativeCreateData["success"].(bool) 1010 | if !ok || !success { 1011 | return nil, errors.New("failed to create initiative") 1012 | } 1013 | 1014 | initiativeData, ok := initiativeCreateData["initiative"].(map[string]interface{}) 1015 | if !ok || initiativeData == nil { 1016 | return nil, errors.New("failed to create initiative") 1017 | } 1018 | 1019 | var initiative Initiative 1020 | initiativeBytes, err := json.Marshal(initiativeData) 1021 | if err != nil { 1022 | return nil, fmt.Errorf("failed to marshal initiative data: %w", err) 1023 | } 1024 | 1025 | if err := json.Unmarshal(initiativeBytes, &initiative); err != nil { 1026 | return nil, fmt.Errorf("failed to unmarshal initiative data: %w", err) 1027 | } 1028 | 1029 | return &initiative, nil 1030 | } 1031 | 1032 | // GetIssueComments gets paginated comments for an issue 1033 | func (c *LinearClient) GetIssueComments(input GetIssueCommentsInput) (*PaginatedCommentConnection, error) { 1034 | query := ` 1035 | query GetIssueComments($issueId: String!, $parentId: ID, $first: Int!, $after: String) { 1036 | issue(id: $issueId) { 1037 | comments( 1038 | first: $first, 1039 | after: $after, 1040 | filter: { parent: { id: { eq: $parentId } } } 1041 | ) { 1042 | nodes { 1043 | id 1044 | body 1045 | createdAt 1046 | user { 1047 | id 1048 | name 1049 | } 1050 | parent { 1051 | id 1052 | } 1053 | children(first: 1) { 1054 | nodes { 1055 | id 1056 | } 1057 | } 1058 | } 1059 | pageInfo { 1060 | hasNextPage 1061 | endCursor 1062 | } 1063 | } 1064 | } 1065 | } 1066 | ` 1067 | 1068 | // Set default limit if not provided 1069 | limit := 10 1070 | if input.Limit > 0 { 1071 | limit = input.Limit 1072 | } 1073 | 1074 | variables := map[string]interface{}{ 1075 | "issueId": input.IssueID, 1076 | "first": limit, 1077 | } 1078 | 1079 | // Add optional parameters if provided 1080 | if input.ParentID != "" { 1081 | variables["parentId"] = input.ParentID 1082 | } 1083 | 1084 | if input.AfterCursor != "" { 1085 | variables["after"] = input.AfterCursor 1086 | } 1087 | 1088 | resp, err := c.executeGraphQL(query, variables) 1089 | if err != nil { 1090 | return nil, err 1091 | } 1092 | 1093 | // Extract the issue from the response 1094 | issueData, ok := resp.Data["issue"].(map[string]interface{}) 1095 | if !ok || issueData == nil { 1096 | return nil, fmt.Errorf("issue %s not found", input.IssueID) 1097 | } 1098 | 1099 | // Extract the comments 1100 | commentsData, ok := issueData["comments"].(map[string]interface{}) 1101 | if !ok || commentsData == nil { 1102 | return &PaginatedCommentConnection{ 1103 | Nodes: []Comment{}, 1104 | PageInfo: PageInfo{HasNextPage: false}, 1105 | }, nil 1106 | } 1107 | 1108 | // Parse the comments data 1109 | var paginatedComments PaginatedCommentConnection 1110 | commentsBytes, err := json.Marshal(commentsData) 1111 | if err != nil { 1112 | return nil, fmt.Errorf("failed to marshal comments data: %w", err) 1113 | } 1114 | 1115 | if err := json.Unmarshal(commentsBytes, &paginatedComments); err != nil { 1116 | return nil, fmt.Errorf("failed to unmarshal comments data: %w", err) 1117 | } 1118 | 1119 | return &paginatedComments, nil 1120 | } 1121 | 1122 | // GetIssueByIdentifier gets an issue by its identifier (e.g., "TEAM-123") 1123 | func (c *LinearClient) GetIssueByIdentifier(identifier string) (*Issue, error) { 1124 | // Split the identifier into team key and number parts 1125 | parts := strings.Split(identifier, "-") 1126 | if len(parts) != 2 { 1127 | return nil, fmt.Errorf("invalid issue identifier format: %s (expected format: TEAM-123)", identifier) 1128 | } 1129 | 1130 | teamKey := parts[0] 1131 | numberStr := parts[1] 1132 | 1133 | // Convert the number part to an integer 1134 | number, err := strconv.Atoi(numberStr) 1135 | if err != nil { 1136 | return nil, fmt.Errorf("invalid issue number in identifier: %s", identifier) 1137 | } 1138 | 1139 | // Use the issues query with filters for team key and number 1140 | query := ` 1141 | query GetIssueByIdentifier($teamKey: String!, $number: Float!) { 1142 | issues(filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }, first: 1) { 1143 | nodes { 1144 | id 1145 | identifier 1146 | title 1147 | } 1148 | } 1149 | } 1150 | ` 1151 | 1152 | variables := map[string]interface{}{ 1153 | "teamKey": teamKey, 1154 | "number": float64(number), 1155 | } 1156 | 1157 | resp, err := c.executeGraphQL(query, variables) 1158 | if err != nil { 1159 | return nil, err 1160 | } 1161 | 1162 | // Extract the issues from the response 1163 | issuesData, ok := resp.Data["issues"].(map[string]interface{}) 1164 | if !ok || issuesData == nil { 1165 | return nil, fmt.Errorf("issue search failed for identifier %s", identifier) 1166 | } 1167 | 1168 | nodesData, ok := issuesData["nodes"].([]interface{}) 1169 | if !ok || nodesData == nil || len(nodesData) == 0 { 1170 | return nil, fmt.Errorf("no issue found with identifier %s", identifier) 1171 | } 1172 | 1173 | // Get the first issue 1174 | issueData, ok := nodesData[0].(map[string]interface{}) 1175 | if !ok || issueData == nil { 1176 | return nil, fmt.Errorf("invalid issue data for identifier %s", identifier) 1177 | } 1178 | 1179 | // Parse the issue data 1180 | var issue Issue 1181 | issueBytes, err := json.Marshal(issueData) 1182 | if err != nil { 1183 | return nil, fmt.Errorf("failed to marshal issue data: %w", err) 1184 | } 1185 | 1186 | if err := json.Unmarshal(issueBytes, &issue); err != nil { 1187 | return nil, fmt.Errorf("failed to unmarshal issue data: %w", err) 1188 | } 1189 | 1190 | return &issue, nil 1191 | } 1192 | 1193 | // GetLabelsByName gets labels by name for a team 1194 | func (c *LinearClient) GetLabelsByName(teamID string, labelNames []string) ([]Label, error) { 1195 | query := ` 1196 | query GetLabelsByName($teamId: String!, $names: [String!]!) { 1197 | team(id: $teamId) { 1198 | labels(filter: { name: { in: $names } }) { 1199 | nodes { 1200 | id 1201 | name 1202 | } 1203 | } 1204 | } 1205 | } 1206 | ` 1207 | 1208 | variables := map[string]interface{}{ 1209 | "teamId": teamID, 1210 | "names": labelNames, 1211 | } 1212 | 1213 | resp, err := c.executeGraphQL(query, variables) 1214 | if err != nil { 1215 | return nil, err 1216 | } 1217 | 1218 | // Extract the team from the response 1219 | teamData, ok := resp.Data["team"].(map[string]interface{}) 1220 | if !ok || teamData == nil { 1221 | return nil, fmt.Errorf("team %s not found", teamID) 1222 | } 1223 | 1224 | // Extract the labels 1225 | labelsData, ok := teamData["labels"].(map[string]interface{}) 1226 | if !ok || labelsData == nil { 1227 | return []Label{}, nil 1228 | } 1229 | 1230 | nodesData, ok := labelsData["nodes"].([]interface{}) 1231 | if !ok || nodesData == nil { 1232 | return []Label{}, nil 1233 | } 1234 | 1235 | // Parse the labels data 1236 | labels := make([]Label, 0, len(nodesData)) 1237 | for _, nodeData := range nodesData { 1238 | labelData, ok := nodeData.(map[string]interface{}) 1239 | if !ok { 1240 | continue 1241 | } 1242 | 1243 | label := Label{ 1244 | ID: getStringValue(labelData, "id"), 1245 | Name: getStringValue(labelData, "name"), 1246 | } 1247 | 1248 | labels = append(labels, label) 1249 | } 1250 | 1251 | return labels, nil 1252 | } 1253 | 1254 | // CreateIssue creates a new issue 1255 | func (c *LinearClient) CreateIssue(input CreateIssueInput) (*Issue, error) { 1256 | query := ` 1257 | mutation CreateIssue($input: IssueCreateInput!) { 1258 | issueCreate(input: $input) { 1259 | success 1260 | issue { 1261 | id 1262 | identifier 1263 | title 1264 | description 1265 | priority 1266 | url 1267 | createdAt 1268 | updatedAt 1269 | state { 1270 | id 1271 | name 1272 | } 1273 | team { 1274 | id 1275 | name 1276 | key 1277 | } 1278 | labels { 1279 | nodes { 1280 | id 1281 | name 1282 | } 1283 | } 1284 | project { 1285 | id 1286 | name 1287 | } 1288 | projectMilestone { 1289 | id 1290 | name 1291 | } 1292 | } 1293 | } 1294 | } 1295 | ` 1296 | 1297 | // Prepare variables 1298 | variables := map[string]interface{}{ 1299 | "input": map[string]interface{}{ 1300 | "title": input.Title, 1301 | "teamId": input.TeamID, 1302 | "description": input.Description, 1303 | }, 1304 | } 1305 | 1306 | if input.Priority != nil { 1307 | variables["input"].(map[string]interface{})["priority"] = *input.Priority 1308 | } 1309 | 1310 | if input.Status != "" { 1311 | variables["input"].(map[string]interface{})["stateId"] = input.Status 1312 | } 1313 | 1314 | if input.ParentID != nil && *input.ParentID != "" { 1315 | variables["input"].(map[string]interface{})["parentId"] = *input.ParentID 1316 | } 1317 | 1318 | if len(input.LabelIDs) > 0 { 1319 | variables["input"].(map[string]interface{})["labelIds"] = input.LabelIDs 1320 | } 1321 | 1322 | if input.ProjectID != "" { 1323 | variables["input"].(map[string]interface{})["projectId"] = input.ProjectID 1324 | } 1325 | 1326 | resp, err := c.executeGraphQL(query, variables) 1327 | if err != nil { 1328 | return nil, err 1329 | } 1330 | 1331 | // Extract the issue from the response 1332 | issueCreateData, ok := resp.Data["issueCreate"].(map[string]interface{}) 1333 | if !ok || issueCreateData == nil { 1334 | return nil, errors.New("failed to create issue") 1335 | } 1336 | 1337 | success, ok := issueCreateData["success"].(bool) 1338 | if !ok || !success { 1339 | return nil, errors.New("failed to create issue") 1340 | } 1341 | 1342 | issueData, ok := issueCreateData["issue"].(map[string]interface{}) 1343 | if !ok || issueData == nil { 1344 | return nil, errors.New("failed to create issue") 1345 | } 1346 | 1347 | // Parse the issue data 1348 | var issue Issue 1349 | issueBytes, err := json.Marshal(issueData) 1350 | if err != nil { 1351 | return nil, fmt.Errorf("failed to marshal issue data: %w", err) 1352 | } 1353 | 1354 | if err := json.Unmarshal(issueBytes, &issue); err != nil { 1355 | return nil, fmt.Errorf("failed to unmarshal issue data: %w", err) 1356 | } 1357 | 1358 | return &issue, nil 1359 | } 1360 | 1361 | // UpdateIssue updates an existing issue 1362 | func (c *LinearClient) UpdateIssue(input UpdateIssueInput) (*Issue, error) { 1363 | query := ` 1364 | mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { 1365 | issueUpdate(id: $id, input: $input) { 1366 | success 1367 | issue { 1368 | id 1369 | identifier 1370 | title 1371 | description 1372 | priority 1373 | url 1374 | createdAt 1375 | updatedAt 1376 | state { 1377 | id 1378 | name 1379 | } 1380 | team { 1381 | id 1382 | name 1383 | key 1384 | } 1385 | } 1386 | } 1387 | } 1388 | ` 1389 | 1390 | // Prepare variables 1391 | updateInput := map[string]interface{}{} 1392 | 1393 | if input.Title != "" { 1394 | updateInput["title"] = input.Title 1395 | } 1396 | 1397 | if input.Description != "" { 1398 | updateInput["description"] = input.Description 1399 | } 1400 | 1401 | if input.Priority != nil { 1402 | updateInput["priority"] = *input.Priority 1403 | } 1404 | 1405 | if input.Status != "" { 1406 | updateInput["stateId"] = input.Status 1407 | } 1408 | 1409 | if input.Status != "" { 1410 | updateInput["teamId"] = input.TeamID 1411 | } 1412 | 1413 | if input.ProjectID != "" { 1414 | updateInput["projectId"] = input.ProjectID 1415 | } 1416 | 1417 | if input.MilestoneID != "" { 1418 | updateInput["milestoneId"] = input.MilestoneID 1419 | } 1420 | 1421 | variables := map[string]interface{}{ 1422 | "id": input.ID, 1423 | "input": updateInput, 1424 | } 1425 | 1426 | resp, err := c.executeGraphQL(query, variables) 1427 | if err != nil { 1428 | return nil, err 1429 | } 1430 | 1431 | // Extract the issue from the response 1432 | issueUpdateData, ok := resp.Data["issueUpdate"].(map[string]interface{}) 1433 | if !ok || issueUpdateData == nil { 1434 | return nil, errors.New("failed to update issue") 1435 | } 1436 | 1437 | success, ok := issueUpdateData["success"].(bool) 1438 | if !ok || !success { 1439 | return nil, errors.New("failed to update issue") 1440 | } 1441 | 1442 | issueData, ok := issueUpdateData["issue"].(map[string]interface{}) 1443 | if !ok || issueData == nil { 1444 | return nil, errors.New("failed to update issue") 1445 | } 1446 | 1447 | // Parse the issue data 1448 | var issue Issue 1449 | issueBytes, err := json.Marshal(issueData) 1450 | if err != nil { 1451 | return nil, fmt.Errorf("failed to marshal issue data: %w", err) 1452 | } 1453 | 1454 | if err := json.Unmarshal(issueBytes, &issue); err != nil { 1455 | return nil, fmt.Errorf("failed to unmarshal issue data: %w", err) 1456 | } 1457 | 1458 | return &issue, nil 1459 | } 1460 | 1461 | // SearchIssues searches for issues with filters 1462 | func (c *LinearClient) SearchIssues(input SearchIssuesInput) ([]LinearIssueResponse, error) { 1463 | query := ` 1464 | query SearchIssues($filter: IssueFilter, $first: Int, $includeArchived: Boolean) { 1465 | issues(filter: $filter, first: $first, includeArchived: $includeArchived) { 1466 | nodes { 1467 | id 1468 | identifier 1469 | title 1470 | description 1471 | priority 1472 | url 1473 | state { 1474 | id 1475 | name 1476 | } 1477 | assignee { 1478 | id 1479 | name 1480 | } 1481 | labels { 1482 | nodes { 1483 | id 1484 | name 1485 | } 1486 | } 1487 | } 1488 | } 1489 | } 1490 | ` 1491 | 1492 | // Build the filter 1493 | filter := map[string]interface{}{} 1494 | 1495 | if input.Query != "" { 1496 | filter["or"] = []map[string]interface{}{ 1497 | {"title": map[string]interface{}{"contains": input.Query}}, 1498 | {"description": map[string]interface{}{"contains": input.Query}}, 1499 | } 1500 | } 1501 | 1502 | if input.TeamID != "" { 1503 | filter["team"] = map[string]interface{}{ 1504 | "id": map[string]interface{}{"eq": input.TeamID}, 1505 | } 1506 | } 1507 | 1508 | if input.Status != "" { 1509 | filter["state"] = map[string]interface{}{ 1510 | "name": map[string]interface{}{"eq": input.Status}, 1511 | } 1512 | } 1513 | 1514 | if input.AssigneeID != "" { 1515 | filter["assignee"] = map[string]interface{}{ 1516 | "id": map[string]interface{}{"eq": input.AssigneeID}, 1517 | } 1518 | } 1519 | 1520 | if len(input.Labels) > 0 { 1521 | filter["labels"] = map[string]interface{}{ 1522 | "some": map[string]interface{}{ 1523 | "name": map[string]interface{}{"in": input.Labels}, 1524 | }, 1525 | } 1526 | } 1527 | 1528 | if input.Priority != nil { 1529 | filter["priority"] = map[string]interface{}{"eq": *input.Priority} 1530 | } 1531 | 1532 | if input.Estimate != nil { 1533 | filter["estimate"] = map[string]interface{}{"eq": *input.Estimate} 1534 | } 1535 | 1536 | // Set default limit if not provided 1537 | limit := 10 1538 | if input.Limit > 0 { 1539 | limit = input.Limit 1540 | } 1541 | 1542 | variables := map[string]interface{}{ 1543 | "filter": filter, 1544 | "first": limit, 1545 | "includeArchived": input.IncludeArchived, 1546 | } 1547 | 1548 | resp, err := c.executeGraphQL(query, variables) 1549 | if err != nil { 1550 | return nil, err 1551 | } 1552 | 1553 | // Extract the issues from the response 1554 | issuesData, ok := resp.Data["issues"].(map[string]interface{}) 1555 | if !ok || issuesData == nil { 1556 | return []LinearIssueResponse{}, nil 1557 | } 1558 | 1559 | nodesData, ok := issuesData["nodes"].([]interface{}) 1560 | if !ok || nodesData == nil { 1561 | return []LinearIssueResponse{}, nil 1562 | } 1563 | 1564 | // Parse the issues data 1565 | issues := make([]LinearIssueResponse, 0, len(nodesData)) 1566 | for _, nodeData := range nodesData { 1567 | issueData, ok := nodeData.(map[string]interface{}) 1568 | if !ok { 1569 | continue 1570 | } 1571 | 1572 | // Extract state name 1573 | var stateName string 1574 | if stateData, ok := issueData["state"].(map[string]interface{}); ok && stateData != nil { 1575 | if name, ok := stateData["name"].(string); ok { 1576 | stateName = name 1577 | } 1578 | } 1579 | 1580 | // Create the issue response 1581 | issue := LinearIssueResponse{ 1582 | ID: getStringValue(issueData, "id"), 1583 | Identifier: getStringValue(issueData, "identifier"), 1584 | Title: getStringValue(issueData, "title"), 1585 | URL: getStringValue(issueData, "url"), 1586 | StateName: stateName, 1587 | } 1588 | 1589 | // Extract priority 1590 | if priority, ok := issueData["priority"].(float64); ok { 1591 | issue.Priority = int(priority) 1592 | } 1593 | 1594 | issues = append(issues, issue) 1595 | } 1596 | 1597 | return issues, nil 1598 | } 1599 | 1600 | // GetUserIssues gets issues assigned to a user 1601 | func (c *LinearClient) GetUserIssues(input GetUserIssuesInput) ([]LinearIssueResponse, error) { 1602 | var userID string 1603 | var err error 1604 | 1605 | if input.UserID == "" { 1606 | // Get the current user's ID 1607 | userID, err = c.getCurrentUserID() 1608 | if err != nil { 1609 | return nil, err 1610 | } 1611 | } else { 1612 | userID = input.UserID 1613 | } 1614 | 1615 | query := ` 1616 | query GetUserIssues($userId: String!, $first: Int, $includeArchived: Boolean) { 1617 | user(id: $userId) { 1618 | assignedIssues(first: $first, includeArchived: $includeArchived) { 1619 | nodes { 1620 | id 1621 | identifier 1622 | title 1623 | description 1624 | priority 1625 | url 1626 | state { 1627 | id 1628 | name 1629 | } 1630 | } 1631 | } 1632 | } 1633 | } 1634 | ` 1635 | 1636 | // Set default limit if not provided 1637 | limit := 50 1638 | if input.Limit > 0 { 1639 | limit = input.Limit 1640 | } 1641 | 1642 | variables := map[string]interface{}{ 1643 | "userId": userID, 1644 | "first": limit, 1645 | "includeArchived": input.IncludeArchived, 1646 | } 1647 | 1648 | resp, err := c.executeGraphQL(query, variables) 1649 | if err != nil { 1650 | return nil, err 1651 | } 1652 | 1653 | // Extract the user from the response 1654 | userData, ok := resp.Data["user"].(map[string]interface{}) 1655 | if !ok || userData == nil { 1656 | return nil, fmt.Errorf("user %s not found", userID) 1657 | } 1658 | 1659 | // Extract the assigned issues 1660 | assignedIssuesData, ok := userData["assignedIssues"].(map[string]interface{}) 1661 | if !ok || assignedIssuesData == nil { 1662 | return []LinearIssueResponse{}, nil 1663 | } 1664 | 1665 | nodesData, ok := assignedIssuesData["nodes"].([]interface{}) 1666 | if !ok || nodesData == nil { 1667 | return []LinearIssueResponse{}, nil 1668 | } 1669 | 1670 | // Parse the issues data 1671 | issues := make([]LinearIssueResponse, 0, len(nodesData)) 1672 | for _, nodeData := range nodesData { 1673 | issueData, ok := nodeData.(map[string]interface{}) 1674 | if !ok { 1675 | continue 1676 | } 1677 | 1678 | // Extract state name 1679 | var stateName string 1680 | if stateData, ok := issueData["state"].(map[string]interface{}); ok && stateData != nil { 1681 | if name, ok := stateData["name"].(string); ok { 1682 | stateName = name 1683 | } 1684 | } 1685 | 1686 | // Create the issue response 1687 | issue := LinearIssueResponse{ 1688 | ID: getStringValue(issueData, "id"), 1689 | Identifier: getStringValue(issueData, "identifier"), 1690 | Title: getStringValue(issueData, "title"), 1691 | URL: getStringValue(issueData, "url"), 1692 | StateName: stateName, 1693 | } 1694 | 1695 | // Extract priority 1696 | if priority, ok := issueData["priority"].(float64); ok { 1697 | issue.Priority = int(priority) 1698 | } 1699 | 1700 | issues = append(issues, issue) 1701 | } 1702 | 1703 | return issues, nil 1704 | } 1705 | 1706 | // AddComment adds a comment to an issue 1707 | func (c *LinearClient) AddComment(input AddCommentInput) (*Comment, *Issue, error) { 1708 | query := ` 1709 | mutation AddComment($input: CommentCreateInput!) { 1710 | commentCreate(input: $input) { 1711 | success 1712 | comment { 1713 | id 1714 | body 1715 | url 1716 | createdAt 1717 | user { 1718 | id 1719 | name 1720 | } 1721 | issue { 1722 | id 1723 | identifier 1724 | title 1725 | url 1726 | } 1727 | } 1728 | } 1729 | } 1730 | ` 1731 | 1732 | // Prepare variables 1733 | commentInput := map[string]interface{}{ 1734 | "issueId": input.IssueID, 1735 | "body": input.Body, 1736 | } 1737 | 1738 | if input.CreateAsUser != "" { 1739 | commentInput["createAsUser"] = input.CreateAsUser 1740 | } 1741 | 1742 | if input.ParentID != "" { 1743 | commentInput["parentId"] = input.ParentID 1744 | } 1745 | 1746 | variables := map[string]interface{}{ 1747 | "input": commentInput, 1748 | } 1749 | 1750 | resp, err := c.executeGraphQL(query, variables) 1751 | if err != nil { 1752 | return nil, nil, err 1753 | } 1754 | 1755 | // Extract the comment from the response 1756 | commentCreateData, ok := resp.Data["commentCreate"].(map[string]interface{}) 1757 | if !ok || commentCreateData == nil { 1758 | return nil, nil, errors.New("failed to create comment") 1759 | } 1760 | 1761 | success, ok := commentCreateData["success"].(bool) 1762 | if !ok || !success { 1763 | return nil, nil, errors.New("failed to create comment") 1764 | } 1765 | 1766 | commentData, ok := commentCreateData["comment"].(map[string]interface{}) 1767 | if !ok || commentData == nil { 1768 | return nil, nil, errors.New("failed to create comment") 1769 | } 1770 | 1771 | issueData, ok := commentData["issue"].(map[string]interface{}) 1772 | if !ok || issueData == nil { 1773 | return nil, nil, errors.New("failed to get issue for comment") 1774 | } 1775 | 1776 | // Parse the comment data 1777 | var comment Comment 1778 | commentBytes, err := json.Marshal(commentData) 1779 | if err != nil { 1780 | return nil, nil, fmt.Errorf("failed to marshal comment data: %w", err) 1781 | } 1782 | 1783 | if err := json.Unmarshal(commentBytes, &comment); err != nil { 1784 | return nil, nil, fmt.Errorf("failed to unmarshal comment data: %w", err) 1785 | } 1786 | 1787 | // Parse the issue data 1788 | var issue Issue 1789 | issueBytes, err := json.Marshal(issueData) 1790 | if err != nil { 1791 | return nil, nil, fmt.Errorf("failed to marshal issue data: %w", err) 1792 | } 1793 | 1794 | if err := json.Unmarshal(issueBytes, &issue); err != nil { 1795 | return nil, nil, fmt.Errorf("failed to unmarshal issue data: %w", err) 1796 | } 1797 | 1798 | return &comment, &issue, nil 1799 | } 1800 | 1801 | // UpdateComment updates an existing comment 1802 | func (c *LinearClient) UpdateComment(input UpdateCommentInput) (*Comment, *Issue, error) { 1803 | query := ` 1804 | mutation UpdateComment($id: String!, $input: CommentUpdateInput!) { 1805 | commentUpdate(id: $id, input: $input) { 1806 | success 1807 | comment { 1808 | id 1809 | body 1810 | url 1811 | createdAt 1812 | user { 1813 | id 1814 | name 1815 | } 1816 | issue { 1817 | id 1818 | identifier 1819 | title 1820 | url 1821 | } 1822 | } 1823 | } 1824 | } 1825 | ` 1826 | 1827 | // Prepare variables 1828 | commentInput := map[string]interface{}{ 1829 | "body": input.Body, 1830 | } 1831 | 1832 | variables := map[string]interface{}{ 1833 | "id": input.CommentID, 1834 | "input": commentInput, 1835 | } 1836 | 1837 | resp, err := c.executeGraphQL(query, variables) 1838 | if err != nil { 1839 | return nil, nil, err 1840 | } 1841 | 1842 | // Extract the comment from the response 1843 | commentUpdateData, ok := resp.Data["commentUpdate"].(map[string]interface{}) 1844 | if !ok || commentUpdateData == nil { 1845 | return nil, nil, errors.New("failed to update comment") 1846 | } 1847 | 1848 | success, ok := commentUpdateData["success"].(bool) 1849 | if !ok || !success { 1850 | return nil, nil, errors.New("failed to update comment") 1851 | } 1852 | 1853 | commentData, ok := commentUpdateData["comment"].(map[string]interface{}) 1854 | if !ok || commentData == nil { 1855 | return nil, nil, errors.New("failed to update comment") 1856 | } 1857 | 1858 | issueData, ok := commentData["issue"].(map[string]interface{}) 1859 | if !ok || issueData == nil { 1860 | return nil, nil, errors.New("failed to get issue for comment") 1861 | } 1862 | 1863 | // Parse the comment data 1864 | var comment Comment 1865 | commentBytes, err := json.Marshal(commentData) 1866 | if err != nil { 1867 | return nil, nil, fmt.Errorf("failed to marshal comment data: %w", err) 1868 | } 1869 | 1870 | if err := json.Unmarshal(commentBytes, &comment); err != nil { 1871 | return nil, nil, fmt.Errorf("failed to unmarshal comment data: %w", err) 1872 | } 1873 | 1874 | // Parse the issue data 1875 | var issue Issue 1876 | issueBytes, err := json.Marshal(issueData) 1877 | if err != nil { 1878 | return nil, nil, fmt.Errorf("failed to marshal issue data: %w", err) 1879 | } 1880 | 1881 | if err := json.Unmarshal(issueBytes, &issue); err != nil { 1882 | return nil, nil, fmt.Errorf("failed to unmarshal issue data: %w", err) 1883 | } 1884 | 1885 | return &comment, &issue, nil 1886 | } 1887 | 1888 | // GetComment gets a comment by its ID 1889 | func (c *LinearClient) GetComment(commentID string) (*Comment, error) { 1890 | query := ` 1891 | query GetComment($id: String!) { 1892 | comment(id: $id) { 1893 | id 1894 | body 1895 | url 1896 | createdAt 1897 | user { 1898 | id 1899 | name 1900 | } 1901 | issue { 1902 | id 1903 | identifier 1904 | } 1905 | } 1906 | } 1907 | ` 1908 | 1909 | variables := map[string]interface{}{ 1910 | "id": commentID, 1911 | } 1912 | 1913 | resp, err := c.executeGraphQL(query, variables) 1914 | if err != nil { 1915 | return nil, err 1916 | } 1917 | 1918 | // Extract the comment from the response 1919 | commentData, ok := resp.Data["comment"].(map[string]interface{}) 1920 | if !ok || commentData == nil { 1921 | return nil, errors.New("comment not found") 1922 | } 1923 | 1924 | // Parse the comment data 1925 | var comment Comment 1926 | commentBytes, err := json.Marshal(commentData) 1927 | if err != nil { 1928 | return nil, fmt.Errorf("failed to marshal comment data: %w", err) 1929 | } 1930 | 1931 | if err := json.Unmarshal(commentBytes, &comment); err != nil { 1932 | return nil, fmt.Errorf("failed to unmarshal comment data: %w", err) 1933 | } 1934 | 1935 | return &comment, nil 1936 | } 1937 | 1938 | // GetCommentByHash gets a comment by its hash (shorthand ID) 1939 | func (c *LinearClient) GetCommentByHash(hash string) (*Comment, error) { 1940 | query := ` 1941 | query GetCommentByHash($hash: String!) { 1942 | comment(hash: $hash) { 1943 | id 1944 | body 1945 | url 1946 | createdAt 1947 | user { 1948 | id 1949 | name 1950 | } 1951 | } 1952 | } 1953 | ` 1954 | 1955 | variables := map[string]interface{}{ 1956 | "hash": hash, 1957 | } 1958 | 1959 | resp, err := c.executeGraphQL(query, variables) 1960 | if err != nil { 1961 | return nil, err 1962 | } 1963 | 1964 | // Extract the comment from the response 1965 | commentData, ok := resp.Data["comment"].(map[string]interface{}) 1966 | if !ok || commentData == nil { 1967 | return nil, errors.New("comment not found") 1968 | } 1969 | 1970 | // Parse the comment data 1971 | var comment Comment 1972 | commentBytes, err := json.Marshal(commentData) 1973 | if err != nil { 1974 | return nil, fmt.Errorf("failed to marshal comment data: %w", err) 1975 | } 1976 | 1977 | if err := json.Unmarshal(commentBytes, &comment); err != nil { 1978 | return nil, fmt.Errorf("failed to unmarshal comment data: %w", err) 1979 | } 1980 | 1981 | return &comment, nil 1982 | } 1983 | 1984 | // GetTeamIssues gets issues for a team 1985 | func (c *LinearClient) GetTeamIssues(teamID string) ([]LinearIssueResponse, error) { 1986 | query := ` 1987 | query GetTeamIssues($teamId: ID!) { 1988 | team(id: $teamId) { 1989 | issues { 1990 | nodes { 1991 | id 1992 | identifier 1993 | title 1994 | description 1995 | priority 1996 | url 1997 | state { 1998 | id 1999 | name 2000 | } 2001 | assignee { 2002 | id 2003 | name 2004 | } 2005 | } 2006 | } 2007 | } 2008 | } 2009 | ` 2010 | 2011 | variables := map[string]interface{}{ 2012 | "teamId": teamID, 2013 | } 2014 | 2015 | resp, err := c.executeGraphQL(query, variables) 2016 | if err != nil { 2017 | return nil, err 2018 | } 2019 | 2020 | // Extract the team from the response 2021 | teamData, ok := resp.Data["team"].(map[string]interface{}) 2022 | if !ok || teamData == nil { 2023 | return nil, fmt.Errorf("team %s not found", teamID) 2024 | } 2025 | 2026 | // Extract the issues 2027 | issuesData, ok := teamData["issues"].(map[string]interface{}) 2028 | if !ok || issuesData == nil { 2029 | return []LinearIssueResponse{}, nil 2030 | } 2031 | 2032 | nodesData, ok := issuesData["nodes"].([]interface{}) 2033 | if !ok || nodesData == nil { 2034 | return []LinearIssueResponse{}, nil 2035 | } 2036 | 2037 | // Parse the issues data 2038 | issues := make([]LinearIssueResponse, 0, len(nodesData)) 2039 | for _, nodeData := range nodesData { 2040 | issueData, ok := nodeData.(map[string]interface{}) 2041 | if !ok { 2042 | continue 2043 | } 2044 | 2045 | // Extract state name 2046 | var stateName string 2047 | if stateData, ok := issueData["state"].(map[string]interface{}); ok && stateData != nil { 2048 | if name, ok := stateData["name"].(string); ok { 2049 | stateName = name 2050 | } 2051 | } 2052 | 2053 | // Create the issue response 2054 | issue := LinearIssueResponse{ 2055 | ID: getStringValue(issueData, "id"), 2056 | Identifier: getStringValue(issueData, "identifier"), 2057 | Title: getStringValue(issueData, "title"), 2058 | URL: getStringValue(issueData, "url"), 2059 | StateName: stateName, 2060 | } 2061 | 2062 | // Extract priority 2063 | if priority, ok := issueData["priority"].(float64); ok { 2064 | issue.Priority = int(priority) 2065 | } 2066 | 2067 | issues = append(issues, issue) 2068 | } 2069 | 2070 | return issues, nil 2071 | } 2072 | 2073 | // GetViewer gets the current user 2074 | func (c *LinearClient) GetViewer() (*User, []Team, *Organization, error) { 2075 | query := ` 2076 | query GetViewer { 2077 | viewer { 2078 | id 2079 | name 2080 | email 2081 | admin 2082 | teams { 2083 | nodes { 2084 | id 2085 | name 2086 | key 2087 | } 2088 | } 2089 | organization { 2090 | id 2091 | name 2092 | urlKey 2093 | } 2094 | } 2095 | } 2096 | ` 2097 | 2098 | resp, err := c.executeGraphQL(query, nil) 2099 | if err != nil { 2100 | return nil, nil, nil, err 2101 | } 2102 | 2103 | // Extract the viewer from the response 2104 | viewerData, ok := resp.Data["viewer"].(map[string]interface{}) 2105 | if !ok || viewerData == nil { 2106 | return nil, nil, nil, errors.New("failed to get viewer") 2107 | } 2108 | 2109 | // Parse the user data 2110 | var user User 2111 | user.ID = getStringValue(viewerData, "id") 2112 | user.Name = getStringValue(viewerData, "name") 2113 | user.Email = getStringValue(viewerData, "email") 2114 | if admin, ok := viewerData["admin"].(bool); ok { 2115 | user.Admin = admin 2116 | } 2117 | 2118 | // Extract teams 2119 | var teams []Team 2120 | if teamsData, ok := viewerData["teams"].(map[string]interface{}); ok && teamsData != nil { 2121 | if nodesData, ok := teamsData["nodes"].([]interface{}); ok && nodesData != nil { 2122 | teams = make([]Team, 0, len(nodesData)) 2123 | for _, nodeData := range nodesData { 2124 | teamData, ok := nodeData.(map[string]interface{}) 2125 | if !ok { 2126 | continue 2127 | } 2128 | 2129 | team := Team{ 2130 | ID: getStringValue(teamData, "id"), 2131 | Name: getStringValue(teamData, "name"), 2132 | Key: getStringValue(teamData, "key"), 2133 | } 2134 | teams = append(teams, team) 2135 | } 2136 | } 2137 | } 2138 | 2139 | // Extract organization 2140 | var org Organization 2141 | if orgData, ok := viewerData["organization"].(map[string]interface{}); ok && orgData != nil { 2142 | org.ID = getStringValue(orgData, "id") 2143 | org.Name = getStringValue(orgData, "name") 2144 | org.URLKey = getStringValue(orgData, "urlKey") 2145 | } 2146 | 2147 | return &user, teams, &org, nil 2148 | } 2149 | 2150 | // GetOrganization gets the organization 2151 | func (c *LinearClient) GetOrganization() (*Organization, error) { 2152 | query := ` 2153 | query GetOrganization { 2154 | organization { 2155 | id 2156 | name 2157 | urlKey 2158 | teams { 2159 | nodes { 2160 | id 2161 | name 2162 | key 2163 | } 2164 | } 2165 | users { 2166 | nodes { 2167 | id 2168 | name 2169 | email 2170 | admin 2171 | active 2172 | } 2173 | } 2174 | } 2175 | } 2176 | ` 2177 | 2178 | resp, err := c.executeGraphQL(query, nil) 2179 | if err != nil { 2180 | return nil, err 2181 | } 2182 | 2183 | // Extract the organization from the response 2184 | orgData, ok := resp.Data["organization"].(map[string]interface{}) 2185 | if !ok || orgData == nil { 2186 | return nil, errors.New("failed to get organization") 2187 | } 2188 | 2189 | // Parse the organization data 2190 | var org Organization 2191 | org.ID = getStringValue(orgData, "id") 2192 | org.Name = getStringValue(orgData, "name") 2193 | org.URLKey = getStringValue(orgData, "urlKey") 2194 | 2195 | // Extract teams 2196 | if teamsData, ok := orgData["teams"].(map[string]interface{}); ok && teamsData != nil { 2197 | if nodesData, ok := teamsData["nodes"].([]interface{}); ok && nodesData != nil { 2198 | org.Teams = make([]Team, 0, len(nodesData)) 2199 | for _, nodeData := range nodesData { 2200 | teamData, ok := nodeData.(map[string]interface{}) 2201 | if !ok { 2202 | continue 2203 | } 2204 | 2205 | team := Team{ 2206 | ID: getStringValue(teamData, "id"), 2207 | Name: getStringValue(teamData, "name"), 2208 | Key: getStringValue(teamData, "key"), 2209 | } 2210 | org.Teams = append(org.Teams, team) 2211 | } 2212 | } 2213 | } 2214 | 2215 | // Extract users 2216 | if usersData, ok := orgData["users"].(map[string]interface{}); ok && usersData != nil { 2217 | if nodesData, ok := usersData["nodes"].([]interface{}); ok && nodesData != nil { 2218 | org.Users = make([]User, 0, len(nodesData)) 2219 | for _, nodeData := range nodesData { 2220 | userData, ok := nodeData.(map[string]interface{}) 2221 | if !ok { 2222 | continue 2223 | } 2224 | 2225 | user := User{ 2226 | ID: getStringValue(userData, "id"), 2227 | Name: getStringValue(userData, "name"), 2228 | Email: getStringValue(userData, "email"), 2229 | } 2230 | 2231 | if admin, ok := userData["admin"].(bool); ok { 2232 | user.Admin = admin 2233 | } 2234 | 2235 | org.Users = append(org.Users, user) 2236 | } 2237 | } 2238 | } 2239 | 2240 | return &org, nil 2241 | } 2242 | 2243 | // ListIssues lists issues 2244 | func (c *LinearClient) ListIssues() ([]LinearIssueResponse, error) { 2245 | query := ` 2246 | query ListIssues { 2247 | issues(first: 50, orderBy: updatedAt) { 2248 | nodes { 2249 | id 2250 | identifier 2251 | title 2252 | priority 2253 | url 2254 | state { 2255 | name 2256 | } 2257 | assignee { 2258 | name 2259 | } 2260 | team { 2261 | name 2262 | } 2263 | } 2264 | } 2265 | } 2266 | ` 2267 | 2268 | resp, err := c.executeGraphQL(query, nil) 2269 | if err != nil { 2270 | return nil, err 2271 | } 2272 | 2273 | // Extract the issues from the response 2274 | issuesData, ok := resp.Data["issues"].(map[string]interface{}) 2275 | if !ok || issuesData == nil { 2276 | return []LinearIssueResponse{}, nil 2277 | } 2278 | 2279 | nodesData, ok := issuesData["nodes"].([]interface{}) 2280 | if !ok || nodesData == nil { 2281 | return []LinearIssueResponse{}, nil 2282 | } 2283 | 2284 | // Parse the issues data 2285 | issues := make([]LinearIssueResponse, 0, len(nodesData)) 2286 | for _, nodeData := range nodesData { 2287 | issueData, ok := nodeData.(map[string]interface{}) 2288 | if !ok { 2289 | continue 2290 | } 2291 | 2292 | // Extract state name 2293 | var stateName string 2294 | if stateData, ok := issueData["state"].(map[string]interface{}); ok && stateData != nil { 2295 | if name, ok := stateData["name"].(string); ok { 2296 | stateName = name 2297 | } 2298 | } 2299 | 2300 | // Create the issue response 2301 | issue := LinearIssueResponse{ 2302 | ID: getStringValue(issueData, "id"), 2303 | Identifier: getStringValue(issueData, "identifier"), 2304 | Title: getStringValue(issueData, "title"), 2305 | URL: getStringValue(issueData, "url"), 2306 | StateName: stateName, 2307 | } 2308 | 2309 | // Extract priority 2310 | if priority, ok := issueData["priority"].(float64); ok { 2311 | issue.Priority = int(priority) 2312 | } 2313 | 2314 | issues = append(issues, issue) 2315 | } 2316 | 2317 | return issues, nil 2318 | } 2319 | 2320 | // getCurrentUserID gets the current user's ID 2321 | func (c *LinearClient) getCurrentUserID() (string, error) { 2322 | query := ` 2323 | query GetCurrentUser { 2324 | viewer { 2325 | id 2326 | } 2327 | } 2328 | ` 2329 | 2330 | resp, err := c.executeGraphQL(query, nil) 2331 | if err != nil { 2332 | return "", err 2333 | } 2334 | 2335 | // Extract the viewer from the response 2336 | viewerData, ok := resp.Data["viewer"].(map[string]interface{}) 2337 | if !ok || viewerData == nil { 2338 | return "", errors.New("failed to get current user") 2339 | } 2340 | 2341 | // Extract the ID 2342 | id, ok := viewerData["id"].(string) 2343 | if !ok || id == "" { 2344 | return "", errors.New("failed to get current user ID") 2345 | } 2346 | 2347 | return id, nil 2348 | } 2349 | 2350 | // GetTeams gets teams by name (optional filter) 2351 | func (c *LinearClient) GetTeams(name string) ([]Team, error) { 2352 | query := ` 2353 | query GetTeams($filter: TeamFilter) { 2354 | teams(filter: $filter) { 2355 | nodes { 2356 | id 2357 | name 2358 | key 2359 | description 2360 | states { 2361 | nodes { 2362 | id 2363 | name 2364 | } 2365 | } 2366 | } 2367 | } 2368 | } 2369 | ` 2370 | 2371 | // Build the filter 2372 | variables := map[string]interface{}{} 2373 | 2374 | if name != "" { 2375 | variables["filter"] = map[string]interface{}{ 2376 | "name": map[string]interface{}{ 2377 | "contains": name, 2378 | }, 2379 | } 2380 | } 2381 | 2382 | resp, err := c.executeGraphQL(query, variables) 2383 | if err != nil { 2384 | return nil, err 2385 | } 2386 | 2387 | // Extract the teams from the response 2388 | teamsData, ok := resp.Data["teams"].(map[string]interface{}) 2389 | if !ok || teamsData == nil { 2390 | return []Team{}, nil 2391 | } 2392 | 2393 | nodesData, ok := teamsData["nodes"].([]interface{}) 2394 | if !ok || nodesData == nil { 2395 | return []Team{}, nil 2396 | } 2397 | 2398 | // Parse the teams data 2399 | teams := make([]Team, 0, len(nodesData)) 2400 | for _, nodeData := range nodesData { 2401 | teamData, ok := nodeData.(map[string]interface{}) 2402 | if !ok { 2403 | continue 2404 | } 2405 | 2406 | team := Team{ 2407 | ID: getStringValue(teamData, "id"), 2408 | Name: getStringValue(teamData, "name"), 2409 | Key: getStringValue(teamData, "key"), 2410 | } 2411 | 2412 | teams = append(teams, team) 2413 | } 2414 | 2415 | return teams, nil 2416 | } 2417 | 2418 | // GetMetrics returns metrics about the API usage 2419 | func (c *LinearClient) GetMetrics() APIMetrics { 2420 | metrics := c.rateLimiter.GetMetrics() 2421 | 2422 | return APIMetrics{ 2423 | RequestsInLastHour: metrics.RequestsInLastHour, 2424 | RemainingRequests: c.rateLimiter.requestsPerHour - metrics.RequestsInLastHour, 2425 | AverageRequestTime: fmt.Sprintf("%dms", metrics.AverageRequestTime), 2426 | QueueLength: metrics.QueueLength, 2427 | LastRequestTime: time.Unix(0, metrics.LastRequestTime*int64(time.Millisecond)).Format(time.RFC3339), 2428 | } 2429 | } 2430 | 2431 | // Helper function to safely extract string values from maps 2432 | func getStringValue(data map[string]interface{}, key string) string { 2433 | if value, ok := data[key].(string); ok { 2434 | return value 2435 | } 2436 | return "" 2437 | } 2438 | ```