Replace copilotDisplayName with actorDisplayName(typeName, login, name) which handles all actor types: known bots get friendly names (e.g. Copilot → 'Copilot (AI)'), regular bots return login, users with names return 'login (Name)', others return login. All DisplayName() methods on Author, CommentAuthor, GitHubUser, AssignableUser, AssignableBot, RequestedReviewer, and ReviewerBot now delegate to actorDisplayName with their available fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
424 lines
9.9 KiB
Go
424 lines
9.9 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
)
|
|
|
|
type IssuesPayload struct {
|
|
Assigned IssuesAndTotalCount
|
|
Mentioned IssuesAndTotalCount
|
|
Authored IssuesAndTotalCount
|
|
}
|
|
|
|
type IssuesAndTotalCount struct {
|
|
Issues []Issue
|
|
TotalCount int
|
|
SearchCapped bool
|
|
}
|
|
|
|
type Issue struct {
|
|
Typename string `json:"__typename"`
|
|
ID string
|
|
Number int
|
|
Title string
|
|
URL string
|
|
State string
|
|
StateReason string
|
|
Closed bool
|
|
Body string
|
|
ActiveLockReason string
|
|
Locked bool
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
ClosedAt *time.Time
|
|
Comments Comments
|
|
Author Author
|
|
Assignees Assignees
|
|
AssignedActors AssignedActors
|
|
Labels Labels
|
|
ProjectCards ProjectCards
|
|
ProjectItems ProjectItems
|
|
Milestone *Milestone
|
|
ReactionGroups ReactionGroups
|
|
IsPinned bool
|
|
|
|
ClosedByPullRequestsReferences ClosedByPullRequestsReferences
|
|
}
|
|
|
|
type ClosedByPullRequestsReferences struct {
|
|
Nodes []struct {
|
|
ID string
|
|
Number int
|
|
URL string
|
|
Repository struct {
|
|
ID string
|
|
Name string
|
|
Owner struct {
|
|
ID string
|
|
Login string
|
|
}
|
|
}
|
|
}
|
|
PageInfo struct {
|
|
HasNextPage bool
|
|
EndCursor string
|
|
}
|
|
}
|
|
|
|
// return values for Issue.Typename
|
|
const (
|
|
TypeIssue string = "Issue"
|
|
TypePullRequest string = "PullRequest"
|
|
)
|
|
|
|
func (i Issue) IsPullRequest() bool {
|
|
return i.Typename == TypePullRequest
|
|
}
|
|
|
|
type Assignees struct {
|
|
Nodes []GitHubUser
|
|
TotalCount int
|
|
}
|
|
|
|
func (a Assignees) Logins() []string {
|
|
logins := make([]string, len(a.Nodes))
|
|
for i, a := range a.Nodes {
|
|
logins[i] = a.Login
|
|
}
|
|
return logins
|
|
}
|
|
|
|
type AssignedActors struct {
|
|
Nodes []Actor
|
|
TotalCount int
|
|
}
|
|
|
|
func (a AssignedActors) Logins() []string {
|
|
logins := make([]string, len(a.Nodes))
|
|
for i, a := range a.Nodes {
|
|
logins[i] = a.Login
|
|
}
|
|
return logins
|
|
}
|
|
|
|
// DisplayNames returns a list of display names for the assigned actors.
|
|
func (a AssignedActors) DisplayNames() []string {
|
|
// These display names are used for populating the "default" assigned actors
|
|
// from the AssignedActors type. But, this is only one piece of the puzzle
|
|
// as later, other queries will fetch the full list of possible assignable
|
|
// actors from the repository, and the two lists will be reconciled.
|
|
//
|
|
// It's important that the display names are the same between the defaults
|
|
// (the values returned here) and the full list (the values returned by
|
|
// other repository queries). Any discrepancy would result in an
|
|
// "invalid default", which means an assigned actor will not be matched
|
|
// to an assignable actor and not presented as a "default" selection.
|
|
// Not being presented as a default would cause the actor to be potentially
|
|
// unassigned if the edits were submitted.
|
|
//
|
|
// To prevent this, we need shared logic to look up an actor's display name.
|
|
// However, our API types between assignedActors and the full list of
|
|
// assignableActors are different. So, as an attempt to maintain
|
|
// consistency we convert the assignedActors to the same types as the
|
|
// repository's assignableActors, treating the assignableActors DisplayName
|
|
// methods as the sources of truth.
|
|
// TODO KW: make this comment less of a wall of text if needed.
|
|
var displayNames []string
|
|
for _, a := range a.Nodes {
|
|
if a.TypeName == "User" {
|
|
u := NewAssignableUser(
|
|
a.ID,
|
|
a.Login,
|
|
a.Name,
|
|
)
|
|
displayNames = append(displayNames, u.DisplayName())
|
|
} else if a.TypeName == "Bot" {
|
|
b := NewAssignableBot(
|
|
a.ID,
|
|
a.Login,
|
|
)
|
|
displayNames = append(displayNames, b.DisplayName())
|
|
}
|
|
}
|
|
return displayNames
|
|
}
|
|
|
|
type Labels struct {
|
|
Nodes []IssueLabel
|
|
TotalCount int
|
|
}
|
|
|
|
func (l Labels) Names() []string {
|
|
names := make([]string, len(l.Nodes))
|
|
for i, l := range l.Nodes {
|
|
names[i] = l.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
type ProjectCards struct {
|
|
Nodes []*ProjectInfo
|
|
TotalCount int
|
|
}
|
|
|
|
type ProjectItems struct {
|
|
Nodes []*ProjectV2Item
|
|
TotalCount int
|
|
}
|
|
|
|
type ProjectInfo struct {
|
|
Project struct {
|
|
Name string `json:"name"`
|
|
} `json:"project"`
|
|
Column struct {
|
|
Name string `json:"name"`
|
|
} `json:"column"`
|
|
}
|
|
|
|
type ProjectV2Item struct {
|
|
ID string `json:"id"`
|
|
Project ProjectV2ItemProject
|
|
Status ProjectV2ItemStatus
|
|
}
|
|
|
|
type ProjectV2ItemProject struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
}
|
|
|
|
type ProjectV2ItemStatus struct {
|
|
OptionID string `json:"optionId"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func (p ProjectCards) ProjectNames() []string {
|
|
names := make([]string, len(p.Nodes))
|
|
for i, c := range p.Nodes {
|
|
names[i] = c.Project.Name
|
|
}
|
|
return names
|
|
}
|
|
|
|
func (p ProjectItems) ProjectTitles() []string {
|
|
titles := make([]string, len(p.Nodes))
|
|
for i, c := range p.Nodes {
|
|
titles[i] = c.Project.Title
|
|
}
|
|
return titles
|
|
}
|
|
|
|
type Milestone struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
DueOn *time.Time `json:"dueOn"`
|
|
}
|
|
|
|
type IssuesDisabledError struct {
|
|
error
|
|
}
|
|
|
|
type Owner struct {
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Login string `json:"login"`
|
|
}
|
|
|
|
type Author struct {
|
|
ID string
|
|
Name string
|
|
Login string
|
|
}
|
|
|
|
// DisplayName returns a user-friendly name via actorDisplayName.
|
|
func (a Author) DisplayName() string {
|
|
return actorDisplayName("", a.Login, a.Name)
|
|
}
|
|
|
|
func (author Author) MarshalJSON() ([]byte, error) {
|
|
if author.ID == "" {
|
|
return json.Marshal(map[string]interface{}{
|
|
"is_bot": true,
|
|
"login": "app/" + author.Login,
|
|
})
|
|
}
|
|
return json.Marshal(map[string]interface{}{
|
|
"is_bot": false,
|
|
"login": author.Login,
|
|
"id": author.ID,
|
|
"name": author.Name,
|
|
})
|
|
}
|
|
|
|
type CommentAuthor struct {
|
|
Login string `json:"login"`
|
|
// Unfortunately, there is no easy way to add "id" and "name" fields to this struct because it's being
|
|
// used in both shurcool-graphql type queries and string-based queries where the response gets parsed
|
|
// by an ordinary JSON decoder that doesn't understand "graphql" directives via struct tags.
|
|
// User *struct {
|
|
// ID string
|
|
// Name string
|
|
// } `graphql:"... on User"`
|
|
}
|
|
|
|
// DisplayName returns a user-friendly name via actorDisplayName.
|
|
func (a CommentAuthor) DisplayName() string {
|
|
return actorDisplayName("", a.Login, "")
|
|
}
|
|
|
|
// 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 {
|
|
id
|
|
url
|
|
}
|
|
}
|
|
}`
|
|
|
|
inputParams := map[string]interface{}{
|
|
"repositoryId": repo.ID,
|
|
}
|
|
for key, val := range params {
|
|
switch key {
|
|
case "assigneeIds", "body", "issueTemplate", "labelIds", "milestoneId", "projectIds", "repositoryId", "title":
|
|
inputParams[key] = val
|
|
case "projectV2Ids":
|
|
default:
|
|
return nil, fmt.Errorf("invalid IssueCreate mutation parameter %s", key)
|
|
}
|
|
}
|
|
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
|
|
}
|
|
issue := &result.CreateIssue.Issue
|
|
|
|
// projectV2 parameters aren't supported in the `createIssue` mutation,
|
|
// so add them after the issue has been created.
|
|
projectV2Ids, ok := params["projectV2Ids"].([]string)
|
|
if ok {
|
|
projectItems := make(map[string]string, len(projectV2Ids))
|
|
for _, p := range projectV2Ids {
|
|
projectItems[p] = issue.ID
|
|
}
|
|
err = UpdateProjectV2Items(client, repo, projectItems, nil)
|
|
if err != nil {
|
|
return issue, err
|
|
}
|
|
}
|
|
|
|
return issue, nil
|
|
}
|
|
|
|
type IssueStatusOptions struct {
|
|
Username string
|
|
Fields []string
|
|
}
|
|
|
|
func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptions) (*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
|
|
}
|
|
}
|
|
|
|
fragments := fmt.Sprintf("fragment issue on Issue{%s}", IssueGraphQL(options.Fields))
|
|
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": options.Username,
|
|
}
|
|
|
|
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 (i Issue) Link() string {
|
|
return i.URL
|
|
}
|
|
|
|
func (i Issue) Identifier() string {
|
|
return i.ID
|
|
}
|
|
|
|
func (i Issue) CurrentUserComments() []Comment {
|
|
return i.Comments.CurrentUserComments()
|
|
}
|