github.com/abdfnx/gh-api@v0.0.0-20210414084727-f5432eec23b8/api/queries_issue.go (about) 1 package api 2 3 import ( 4 "context" 5 "encoding/base64" 6 "fmt" 7 "strconv" 8 "strings" 9 "time" 10 11 "github.com/abdfnx/gh-api/internal/ghrepo" 12 "github.com/shurcooL/githubv4" 13 ) 14 15 type IssuesPayload struct { 16 Assigned IssuesAndTotalCount 17 Mentioned IssuesAndTotalCount 18 Authored IssuesAndTotalCount 19 } 20 21 type IssuesAndTotalCount struct { 22 Issues []Issue 23 TotalCount int 24 } 25 26 type Issue struct { 27 ID string 28 Number int 29 Title string 30 URL string 31 State string 32 Closed bool 33 Body string 34 CreatedAt time.Time 35 UpdatedAt time.Time 36 Comments Comments 37 Author Author 38 Assignees Assignees 39 Labels Labels 40 ProjectCards ProjectCards 41 Milestone Milestone 42 ReactionGroups ReactionGroups 43 } 44 45 type Assignees struct { 46 Nodes []struct { 47 Login string 48 } 49 TotalCount int 50 } 51 52 func (a Assignees) Logins() []string { 53 logins := make([]string, len(a.Nodes)) 54 for i, a := range a.Nodes { 55 logins[i] = a.Login 56 } 57 return logins 58 } 59 60 type Labels struct { 61 Nodes []struct { 62 Name string 63 } 64 TotalCount int 65 } 66 67 func (l Labels) Names() []string { 68 names := make([]string, len(l.Nodes)) 69 for i, l := range l.Nodes { 70 names[i] = l.Name 71 } 72 return names 73 } 74 75 type ProjectCards struct { 76 Nodes []struct { 77 Project struct { 78 Name string 79 } 80 Column struct { 81 Name string 82 } 83 } 84 TotalCount int 85 } 86 87 func (p ProjectCards) ProjectNames() []string { 88 names := make([]string, len(p.Nodes)) 89 for i, c := range p.Nodes { 90 names[i] = c.Project.Name 91 } 92 return names 93 } 94 95 type Milestone struct { 96 Title string 97 } 98 99 type IssuesDisabledError struct { 100 error 101 } 102 103 type Author struct { 104 Login string 105 } 106 107 const fragments = ` 108 fragment issue on Issue { 109 number 110 title 111 url 112 state 113 updatedAt 114 labels(first: 100) { 115 nodes { 116 name 117 } 118 totalCount 119 } 120 } 121 ` 122 123 // IssueCreate creates an issue in a GitHub repository 124 func IssueCreate(client *Client, repo *Repository, params map[string]interface{}) (*Issue, error) { 125 query := ` 126 mutation IssueCreate($input: CreateIssueInput!) { 127 createIssue(input: $input) { 128 issue { 129 url 130 } 131 } 132 }` 133 134 inputParams := map[string]interface{}{ 135 "repositoryId": repo.ID, 136 } 137 for key, val := range params { 138 inputParams[key] = val 139 } 140 variables := map[string]interface{}{ 141 "input": inputParams, 142 } 143 144 result := struct { 145 CreateIssue struct { 146 Issue Issue 147 } 148 }{} 149 150 err := client.GraphQL(repo.RepoHost(), query, variables, &result) 151 if err != nil { 152 return nil, err 153 } 154 155 return &result.CreateIssue.Issue, nil 156 } 157 158 func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) { 159 type response struct { 160 Repository struct { 161 Assigned struct { 162 TotalCount int 163 Nodes []Issue 164 } 165 Mentioned struct { 166 TotalCount int 167 Nodes []Issue 168 } 169 Authored struct { 170 TotalCount int 171 Nodes []Issue 172 } 173 HasIssuesEnabled bool 174 } 175 } 176 177 query := fragments + ` 178 query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) { 179 repository(owner: $owner, name: $repo) { 180 hasIssuesEnabled 181 assigned: issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { 182 totalCount 183 nodes { 184 ...issue 185 } 186 } 187 mentioned: issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { 188 totalCount 189 nodes { 190 ...issue 191 } 192 } 193 authored: issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) { 194 totalCount 195 nodes { 196 ...issue 197 } 198 } 199 } 200 }` 201 202 variables := map[string]interface{}{ 203 "owner": repo.RepoOwner(), 204 "repo": repo.RepoName(), 205 "viewer": currentUsername, 206 } 207 208 var resp response 209 err := client.GraphQL(repo.RepoHost(), query, variables, &resp) 210 if err != nil { 211 return nil, err 212 } 213 214 if !resp.Repository.HasIssuesEnabled { 215 return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) 216 } 217 218 payload := IssuesPayload{ 219 Assigned: IssuesAndTotalCount{ 220 Issues: resp.Repository.Assigned.Nodes, 221 TotalCount: resp.Repository.Assigned.TotalCount, 222 }, 223 Mentioned: IssuesAndTotalCount{ 224 Issues: resp.Repository.Mentioned.Nodes, 225 TotalCount: resp.Repository.Mentioned.TotalCount, 226 }, 227 Authored: IssuesAndTotalCount{ 228 Issues: resp.Repository.Authored.Nodes, 229 TotalCount: resp.Repository.Authored.TotalCount, 230 }, 231 } 232 233 return &payload, nil 234 } 235 236 func IssueList(client *Client, repo ghrepo.Interface, state string, assigneeString string, limit int, authorString string, mentionString string, milestoneString string) (*IssuesAndTotalCount, error) { 237 var states []string 238 switch state { 239 case "open", "": 240 states = []string{"OPEN"} 241 case "closed": 242 states = []string{"CLOSED"} 243 case "all": 244 states = []string{"OPEN", "CLOSED"} 245 default: 246 return nil, fmt.Errorf("invalid state: %s", state) 247 } 248 249 query := fragments + ` 250 query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $assignee: String, $author: String, $mention: String, $milestone: String) { 251 repository(owner: $owner, name: $repo) { 252 hasIssuesEnabled 253 issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention, milestone: $milestone}) { 254 totalCount 255 nodes { 256 ...issue 257 } 258 pageInfo { 259 hasNextPage 260 endCursor 261 } 262 } 263 } 264 } 265 ` 266 267 variables := map[string]interface{}{ 268 "owner": repo.RepoOwner(), 269 "repo": repo.RepoName(), 270 "states": states, 271 } 272 if assigneeString != "" { 273 variables["assignee"] = assigneeString 274 } 275 if authorString != "" { 276 variables["author"] = authorString 277 } 278 if mentionString != "" { 279 variables["mention"] = mentionString 280 } 281 282 if milestoneString != "" { 283 var milestone *RepoMilestone 284 if milestoneNumber, err := strconv.ParseInt(milestoneString, 10, 32); err == nil { 285 milestone, err = MilestoneByNumber(client, repo, int32(milestoneNumber)) 286 if err != nil { 287 return nil, err 288 } 289 } else { 290 milestone, err = MilestoneByTitle(client, repo, "all", milestoneString) 291 if err != nil { 292 return nil, err 293 } 294 } 295 296 milestoneRESTID, err := milestoneNodeIdToDatabaseId(milestone.ID) 297 if err != nil { 298 return nil, err 299 } 300 variables["milestone"] = milestoneRESTID 301 } 302 303 type responseData struct { 304 Repository struct { 305 Issues struct { 306 TotalCount int 307 Nodes []Issue 308 PageInfo struct { 309 HasNextPage bool 310 EndCursor string 311 } 312 } 313 HasIssuesEnabled bool 314 } 315 } 316 317 var issues []Issue 318 var totalCount int 319 pageLimit := min(limit, 100) 320 321 loop: 322 for { 323 var response responseData 324 variables["limit"] = pageLimit 325 err := client.GraphQL(repo.RepoHost(), query, variables, &response) 326 if err != nil { 327 return nil, err 328 } 329 if !response.Repository.HasIssuesEnabled { 330 return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) 331 } 332 totalCount = response.Repository.Issues.TotalCount 333 334 for _, issue := range response.Repository.Issues.Nodes { 335 issues = append(issues, issue) 336 if len(issues) == limit { 337 break loop 338 } 339 } 340 341 if response.Repository.Issues.PageInfo.HasNextPage { 342 variables["endCursor"] = response.Repository.Issues.PageInfo.EndCursor 343 pageLimit = min(pageLimit, limit-len(issues)) 344 } else { 345 break 346 } 347 } 348 349 res := IssuesAndTotalCount{Issues: issues, TotalCount: totalCount} 350 return &res, nil 351 } 352 353 func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) { 354 type response struct { 355 Repository struct { 356 Issue Issue 357 HasIssuesEnabled bool 358 } 359 } 360 361 query := ` 362 query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!) { 363 repository(owner: $owner, name: $repo) { 364 hasIssuesEnabled 365 issue(number: $issue_number) { 366 id 367 title 368 state 369 closed 370 body 371 author { 372 login 373 } 374 comments(last: 1) { 375 nodes { 376 author { 377 login 378 } 379 authorAssociation 380 body 381 createdAt 382 includesCreatedEdit 383 isMinimized 384 minimizedReason 385 reactionGroups { 386 content 387 users { 388 totalCount 389 } 390 } 391 } 392 totalCount 393 } 394 number 395 url 396 createdAt 397 assignees(first: 100) { 398 nodes { 399 login 400 } 401 totalCount 402 } 403 labels(first: 100) { 404 nodes { 405 name 406 } 407 totalCount 408 } 409 projectCards(first: 100) { 410 nodes { 411 project { 412 name 413 } 414 column { 415 name 416 } 417 } 418 totalCount 419 } 420 milestone { 421 title 422 } 423 reactionGroups { 424 content 425 users { 426 totalCount 427 } 428 } 429 } 430 } 431 }` 432 433 variables := map[string]interface{}{ 434 "owner": repo.RepoOwner(), 435 "repo": repo.RepoName(), 436 "issue_number": number, 437 } 438 439 var resp response 440 err := client.GraphQL(repo.RepoHost(), query, variables, &resp) 441 if err != nil { 442 return nil, err 443 } 444 445 if !resp.Repository.HasIssuesEnabled { 446 447 return nil, &IssuesDisabledError{fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))} 448 } 449 450 return &resp.Repository.Issue, nil 451 } 452 453 func IssueSearch(client *Client, repo ghrepo.Interface, searchQuery string, limit int) (*IssuesAndTotalCount, error) { 454 query := fragments + 455 `query IssueSearch($repo: String!, $owner: String!, $type: SearchType!, $limit: Int, $after: String, $query: String!) { 456 repository(name: $repo, owner: $owner) { 457 hasIssuesEnabled 458 } 459 search(type: $type, last: $limit, after: $after, query: $query) { 460 issueCount 461 nodes { ...issue } 462 pageInfo { 463 hasNextPage 464 endCursor 465 } 466 } 467 }` 468 469 type response struct { 470 Repository struct { 471 HasIssuesEnabled bool 472 } 473 Search struct { 474 IssueCount int 475 Nodes []Issue 476 PageInfo struct { 477 HasNextPage bool 478 EndCursor string 479 } 480 } 481 } 482 483 perPage := min(limit, 100) 484 searchQuery = fmt.Sprintf("repo:%s/%s %s", repo.RepoOwner(), repo.RepoName(), searchQuery) 485 486 variables := map[string]interface{}{ 487 "owner": repo.RepoOwner(), 488 "repo": repo.RepoName(), 489 "type": "ISSUE", 490 "limit": perPage, 491 "query": searchQuery, 492 } 493 494 ic := IssuesAndTotalCount{} 495 496 loop: 497 for { 498 var resp response 499 err := client.GraphQL(repo.RepoHost(), query, variables, &resp) 500 if err != nil { 501 return nil, err 502 } 503 504 if !resp.Repository.HasIssuesEnabled { 505 return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo)) 506 } 507 508 ic.TotalCount = resp.Search.IssueCount 509 510 for _, issue := range resp.Search.Nodes { 511 ic.Issues = append(ic.Issues, issue) 512 if len(ic.Issues) == limit { 513 break loop 514 } 515 } 516 517 if !resp.Search.PageInfo.HasNextPage { 518 break 519 } 520 variables["after"] = resp.Search.PageInfo.EndCursor 521 variables["perPage"] = min(perPage, limit-len(ic.Issues)) 522 } 523 524 return &ic, nil 525 } 526 527 func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error { 528 var mutation struct { 529 CloseIssue struct { 530 Issue struct { 531 ID githubv4.ID 532 } 533 } `graphql:"closeIssue(input: $input)"` 534 } 535 536 variables := map[string]interface{}{ 537 "input": githubv4.CloseIssueInput{ 538 IssueID: issue.ID, 539 }, 540 } 541 542 gql := graphQLClient(client.http, repo.RepoHost()) 543 err := gql.MutateNamed(context.Background(), "IssueClose", &mutation, variables) 544 545 if err != nil { 546 return err 547 } 548 549 return nil 550 } 551 552 func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error { 553 var mutation struct { 554 ReopenIssue struct { 555 Issue struct { 556 ID githubv4.ID 557 } 558 } `graphql:"reopenIssue(input: $input)"` 559 } 560 561 variables := map[string]interface{}{ 562 "input": githubv4.ReopenIssueInput{ 563 IssueID: issue.ID, 564 }, 565 } 566 567 gql := graphQLClient(client.http, repo.RepoHost()) 568 err := gql.MutateNamed(context.Background(), "IssueReopen", &mutation, variables) 569 570 return err 571 } 572 573 func IssueDelete(client *Client, repo ghrepo.Interface, issue Issue) error { 574 var mutation struct { 575 DeleteIssue struct { 576 Repository struct { 577 ID githubv4.ID 578 } 579 } `graphql:"deleteIssue(input: $input)"` 580 } 581 582 variables := map[string]interface{}{ 583 "input": githubv4.DeleteIssueInput{ 584 IssueID: issue.ID, 585 }, 586 } 587 588 gql := graphQLClient(client.http, repo.RepoHost()) 589 err := gql.MutateNamed(context.Background(), "IssueDelete", &mutation, variables) 590 591 return err 592 } 593 594 func IssueUpdate(client *Client, repo ghrepo.Interface, params githubv4.UpdateIssueInput) error { 595 var mutation struct { 596 UpdateIssue struct { 597 Issue struct { 598 ID string 599 } 600 } `graphql:"updateIssue(input: $input)"` 601 } 602 variables := map[string]interface{}{"input": params} 603 gql := graphQLClient(client.http, repo.RepoHost()) 604 err := gql.MutateNamed(context.Background(), "IssueUpdate", &mutation, variables) 605 return err 606 } 607 608 // milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID 609 // This conversion is necessary since the GraphQL API requires the use of the milestone's database ID 610 // for querying the related issues. 611 func milestoneNodeIdToDatabaseId(nodeId string) (string, error) { 612 // The Node ID is Base64 obfuscated, with an underlying pattern: 613 // "09:Milestone12345", where "12345" is the database ID 614 decoded, err := base64.StdEncoding.DecodeString(nodeId) 615 if err != nil { 616 return "", err 617 } 618 splitted := strings.Split(string(decoded), "Milestone") 619 if len(splitted) != 2 { 620 return "", fmt.Errorf("couldn't get database id from node id") 621 } 622 return splitted[1], nil 623 } 624 625 func (i Issue) Link() string { 626 return i.URL 627 } 628 629 func (i Issue) Identifier() string { 630 return i.ID 631 }