483 lines
10 KiB
Go
483 lines
10 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/cli/cli/internal/ghrepo"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
type IssuesPayload struct {
|
|
Assigned IssuesAndTotalCount
|
|
Mentioned IssuesAndTotalCount
|
|
Authored IssuesAndTotalCount
|
|
}
|
|
|
|
type IssuesAndTotalCount struct {
|
|
Issues []Issue
|
|
TotalCount int
|
|
}
|
|
|
|
type Issue struct {
|
|
ID string
|
|
Number int
|
|
Title string
|
|
URL string
|
|
State string
|
|
Closed bool
|
|
Body string
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
Comments Comments
|
|
Author Author
|
|
Assignees struct {
|
|
Nodes []struct {
|
|
Login string
|
|
}
|
|
TotalCount int
|
|
}
|
|
Labels struct {
|
|
Nodes []struct {
|
|
Name string
|
|
}
|
|
TotalCount int
|
|
}
|
|
ProjectCards struct {
|
|
Nodes []struct {
|
|
Project struct {
|
|
Name string
|
|
}
|
|
Column struct {
|
|
Name string
|
|
}
|
|
}
|
|
TotalCount int
|
|
}
|
|
Milestone struct {
|
|
Title string
|
|
}
|
|
ReactionGroups ReactionGroups
|
|
}
|
|
|
|
type IssuesDisabledError struct {
|
|
error
|
|
}
|
|
|
|
type Author struct {
|
|
Login string
|
|
}
|
|
|
|
const fragments = `
|
|
fragment issue on Issue {
|
|
number
|
|
title
|
|
url
|
|
state
|
|
updatedAt
|
|
labels(first: 100) {
|
|
nodes {
|
|
name
|
|
}
|
|
totalCount
|
|
}
|
|
}
|
|
`
|
|
|
|
// IssueCreate creates an issue in a GitHub repository
|
|
func IssueCreate(client *Client, repo *Repository, params map[string]interface{}) (*Issue, error) {
|
|
query := `
|
|
mutation IssueCreate($input: CreateIssueInput!) {
|
|
createIssue(input: $input) {
|
|
issue {
|
|
url
|
|
}
|
|
}
|
|
}`
|
|
|
|
inputParams := map[string]interface{}{
|
|
"repositoryId": repo.ID,
|
|
}
|
|
for key, val := range params {
|
|
inputParams[key] = val
|
|
}
|
|
variables := map[string]interface{}{
|
|
"input": inputParams,
|
|
}
|
|
|
|
result := struct {
|
|
CreateIssue struct {
|
|
Issue Issue
|
|
}
|
|
}{}
|
|
|
|
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &result.CreateIssue.Issue, nil
|
|
}
|
|
|
|
func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) (*IssuesPayload, error) {
|
|
type response struct {
|
|
Repository struct {
|
|
Assigned struct {
|
|
TotalCount int
|
|
Nodes []Issue
|
|
}
|
|
Mentioned struct {
|
|
TotalCount int
|
|
Nodes []Issue
|
|
}
|
|
Authored struct {
|
|
TotalCount int
|
|
Nodes []Issue
|
|
}
|
|
HasIssuesEnabled bool
|
|
}
|
|
}
|
|
|
|
query := fragments + `
|
|
query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) {
|
|
repository(owner: $owner, name: $repo) {
|
|
hasIssuesEnabled
|
|
assigned: issues(filterBy: {assignee: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
totalCount
|
|
nodes {
|
|
...issue
|
|
}
|
|
}
|
|
mentioned: issues(filterBy: {mentioned: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
totalCount
|
|
nodes {
|
|
...issue
|
|
}
|
|
}
|
|
authored: issues(filterBy: {createdBy: $viewer, states: OPEN}, first: $per_page, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
totalCount
|
|
nodes {
|
|
...issue
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
variables := map[string]interface{}{
|
|
"owner": repo.RepoOwner(),
|
|
"repo": repo.RepoName(),
|
|
"viewer": currentUsername,
|
|
}
|
|
|
|
var resp response
|
|
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !resp.Repository.HasIssuesEnabled {
|
|
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
|
|
}
|
|
|
|
payload := IssuesPayload{
|
|
Assigned: IssuesAndTotalCount{
|
|
Issues: resp.Repository.Assigned.Nodes,
|
|
TotalCount: resp.Repository.Assigned.TotalCount,
|
|
},
|
|
Mentioned: IssuesAndTotalCount{
|
|
Issues: resp.Repository.Mentioned.Nodes,
|
|
TotalCount: resp.Repository.Mentioned.TotalCount,
|
|
},
|
|
Authored: IssuesAndTotalCount{
|
|
Issues: resp.Repository.Authored.Nodes,
|
|
TotalCount: resp.Repository.Authored.TotalCount,
|
|
},
|
|
}
|
|
|
|
return &payload, nil
|
|
}
|
|
|
|
func IssueList(client *Client, repo ghrepo.Interface, state string, labels []string, assigneeString string, limit int, authorString string, mentionString string, milestoneString string) (*IssuesAndTotalCount, error) {
|
|
var states []string
|
|
switch state {
|
|
case "open", "":
|
|
states = []string{"OPEN"}
|
|
case "closed":
|
|
states = []string{"CLOSED"}
|
|
case "all":
|
|
states = []string{"OPEN", "CLOSED"}
|
|
default:
|
|
return nil, fmt.Errorf("invalid state: %s", state)
|
|
}
|
|
|
|
query := fragments + `
|
|
query IssueList($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $labels: [String!], $assignee: String, $author: String, $mention: String, $milestone: String) {
|
|
repository(owner: $owner, name: $repo) {
|
|
hasIssuesEnabled
|
|
issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, labels: $labels, filterBy: {assignee: $assignee, createdBy: $author, mentioned: $mention, milestone: $milestone}) {
|
|
totalCount
|
|
nodes {
|
|
...issue
|
|
}
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
}
|
|
}
|
|
}
|
|
`
|
|
|
|
variables := map[string]interface{}{
|
|
"owner": repo.RepoOwner(),
|
|
"repo": repo.RepoName(),
|
|
"states": states,
|
|
}
|
|
if len(labels) > 0 {
|
|
variables["labels"] = labels
|
|
}
|
|
if assigneeString != "" {
|
|
variables["assignee"] = assigneeString
|
|
}
|
|
if authorString != "" {
|
|
variables["author"] = authorString
|
|
}
|
|
if mentionString != "" {
|
|
variables["mention"] = mentionString
|
|
}
|
|
|
|
if milestoneString != "" {
|
|
var milestone *RepoMilestone
|
|
if milestoneNumber, err := strconv.ParseInt(milestoneString, 10, 32); err == nil {
|
|
milestone, err = MilestoneByNumber(client, repo, int32(milestoneNumber))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
milestone, err = MilestoneByTitle(client, repo, "all", milestoneString)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
milestoneRESTID, err := milestoneNodeIdToDatabaseId(milestone.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
variables["milestone"] = milestoneRESTID
|
|
}
|
|
|
|
type responseData struct {
|
|
Repository struct {
|
|
Issues struct {
|
|
TotalCount int
|
|
Nodes []Issue
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
}
|
|
HasIssuesEnabled bool
|
|
}
|
|
}
|
|
|
|
var issues []Issue
|
|
var totalCount int
|
|
pageLimit := min(limit, 100)
|
|
|
|
loop:
|
|
for {
|
|
var response responseData
|
|
variables["limit"] = pageLimit
|
|
err := client.GraphQL(repo.RepoHost(), query, variables, &response)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !response.Repository.HasIssuesEnabled {
|
|
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
|
|
}
|
|
totalCount = response.Repository.Issues.TotalCount
|
|
|
|
for _, issue := range response.Repository.Issues.Nodes {
|
|
issues = append(issues, issue)
|
|
if len(issues) == limit {
|
|
break loop
|
|
}
|
|
}
|
|
|
|
if response.Repository.Issues.PageInfo.HasNextPage {
|
|
variables["endCursor"] = response.Repository.Issues.PageInfo.EndCursor
|
|
pageLimit = min(pageLimit, limit-len(issues))
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
res := IssuesAndTotalCount{Issues: issues, TotalCount: totalCount}
|
|
return &res, nil
|
|
}
|
|
|
|
func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) {
|
|
type response struct {
|
|
Repository struct {
|
|
Issue Issue
|
|
HasIssuesEnabled bool
|
|
}
|
|
}
|
|
|
|
query := `
|
|
query IssueByNumber($owner: String!, $repo: String!, $issue_number: Int!) {
|
|
repository(owner: $owner, name: $repo) {
|
|
hasIssuesEnabled
|
|
issue(number: $issue_number) {
|
|
id
|
|
title
|
|
state
|
|
closed
|
|
body
|
|
author {
|
|
login
|
|
}
|
|
comments(last: 1) {
|
|
nodes {
|
|
author {
|
|
login
|
|
}
|
|
authorAssociation
|
|
body
|
|
createdAt
|
|
includesCreatedEdit
|
|
reactionGroups {
|
|
content
|
|
users {
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
totalCount
|
|
}
|
|
number
|
|
url
|
|
createdAt
|
|
assignees(first: 100) {
|
|
nodes {
|
|
login
|
|
}
|
|
totalCount
|
|
}
|
|
labels(first: 100) {
|
|
nodes {
|
|
name
|
|
}
|
|
totalCount
|
|
}
|
|
projectCards(first: 100) {
|
|
nodes {
|
|
project {
|
|
name
|
|
}
|
|
column {
|
|
name
|
|
}
|
|
}
|
|
totalCount
|
|
}
|
|
milestone {
|
|
title
|
|
}
|
|
reactionGroups {
|
|
content
|
|
users {
|
|
totalCount
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
|
|
variables := map[string]interface{}{
|
|
"owner": repo.RepoOwner(),
|
|
"repo": repo.RepoName(),
|
|
"issue_number": number,
|
|
}
|
|
|
|
var resp response
|
|
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !resp.Repository.HasIssuesEnabled {
|
|
|
|
return nil, &IssuesDisabledError{fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))}
|
|
}
|
|
|
|
return &resp.Repository.Issue, nil
|
|
}
|
|
|
|
func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error {
|
|
var mutation struct {
|
|
CloseIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
}
|
|
} `graphql:"closeIssue(input: $input)"`
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"input": githubv4.CloseIssueInput{
|
|
IssueID: issue.ID,
|
|
},
|
|
}
|
|
|
|
gql := graphQLClient(client.http, repo.RepoHost())
|
|
err := gql.MutateNamed(context.Background(), "IssueClose", &mutation, variables)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error {
|
|
var mutation struct {
|
|
ReopenIssue struct {
|
|
Issue struct {
|
|
ID githubv4.ID
|
|
}
|
|
} `graphql:"reopenIssue(input: $input)"`
|
|
}
|
|
|
|
variables := map[string]interface{}{
|
|
"input": githubv4.ReopenIssueInput{
|
|
IssueID: issue.ID,
|
|
},
|
|
}
|
|
|
|
gql := graphQLClient(client.http, repo.RepoHost())
|
|
err := gql.MutateNamed(context.Background(), "IssueReopen", &mutation, variables)
|
|
|
|
return err
|
|
}
|
|
|
|
// milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID
|
|
// This conversion is necessary since the GraphQL API requires the use of the milestone's database ID
|
|
// for querying the related issues.
|
|
func milestoneNodeIdToDatabaseId(nodeId string) (string, error) {
|
|
// The Node ID is Base64 obfuscated, with an underlying pattern:
|
|
// "09:Milestone12345", where "12345" is the database ID
|
|
decoded, err := base64.StdEncoding.DecodeString(nodeId)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
splitted := strings.Split(string(decoded), "Milestone")
|
|
if len(splitted) != 2 {
|
|
return "", fmt.Errorf("couldn't get database id from node id")
|
|
}
|
|
return splitted[1], nil
|
|
}
|