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  }