Merge remote-tracking branch 'origin/trunk' into cmbrose/pf-half-close
This commit is contained in:
commit
e489050715
57 changed files with 2627 additions and 429 deletions
|
|
@ -14,6 +14,8 @@ For [installation options see below](#installation), for usage instructions [see
|
|||
|
||||
If anything feels off, or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project.
|
||||
|
||||
If you are a hubber and are interested in shipping new commands for the CLI, check out our [doc on internal contributions][intake-doc].
|
||||
|
||||
<!-- this anchor is linked to from elsewhere, so avoid renaming it -->
|
||||
## Installation
|
||||
|
||||
|
|
@ -128,3 +130,4 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
|
|||
[contributing]: ./.github/CONTRIBUTING.md
|
||||
[gh-vs-hub]: ./docs/gh-vs-hub.md
|
||||
[build from source]: ./docs/source.md
|
||||
[intake-doc]: ./docs/working-with-us.md
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ type Issue struct {
|
|||
Assignees Assignees
|
||||
Labels Labels
|
||||
ProjectCards ProjectCards
|
||||
ProjectItems ProjectItems
|
||||
Milestone *Milestone
|
||||
ReactionGroups ReactionGroups
|
||||
IsPinned bool
|
||||
|
|
@ -86,6 +87,10 @@ type ProjectCards struct {
|
|||
TotalCount int
|
||||
}
|
||||
|
||||
type ProjectItems struct {
|
||||
Nodes []*ProjectV2Item
|
||||
}
|
||||
|
||||
type ProjectInfo struct {
|
||||
Project struct {
|
||||
Name string `json:"name"`
|
||||
|
|
@ -95,6 +100,14 @@ type ProjectInfo struct {
|
|||
} `json:"column"`
|
||||
}
|
||||
|
||||
type ProjectV2Item struct {
|
||||
ID string `json:"id"`
|
||||
Project struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
}
|
||||
|
||||
func (p ProjectCards) ProjectNames() []string {
|
||||
names := make([]string, len(p.Nodes))
|
||||
for i, c := range p.Nodes {
|
||||
|
|
@ -103,6 +116,14 @@ func (p ProjectCards) ProjectNames() []string {
|
|||
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"`
|
||||
|
|
@ -158,6 +179,7 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
|||
mutation IssueCreate($input: CreateIssueInput!) {
|
||||
createIssue(input: $input) {
|
||||
issue {
|
||||
id
|
||||
url
|
||||
}
|
||||
}
|
||||
|
|
@ -167,7 +189,13 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
|||
"repositoryId": repo.ID,
|
||||
}
|
||||
for key, val := range params {
|
||||
inputParams[key] = val
|
||||
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,
|
||||
|
|
@ -183,8 +211,23 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issue := &result.CreateIssue.Issue
|
||||
|
||||
return &result.CreateIssue.Issue, nil
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
||||
// OrganizationProjects fetches all open projects for an organization
|
||||
// OrganizationProjects fetches all open projects for an organization.
|
||||
func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
|
||||
type responseData struct {
|
||||
Organization struct {
|
||||
|
|
@ -42,6 +42,45 @@ func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject,
|
|||
return projects, nil
|
||||
}
|
||||
|
||||
// OrganizationProjectsV2 fetches all open projectsV2 for an organization.
|
||||
func OrganizationProjectsV2(client *Client, repo ghrepo.Interface) ([]RepoProjectV2, error) {
|
||||
type responseData struct {
|
||||
Organization struct {
|
||||
ProjectsV2 struct {
|
||||
Nodes []RepoProjectV2
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
} `graphql:"projectsV2(first: 100, orderBy: {field: TITLE, direction: ASC}, after: $endCursor, query: $query)"`
|
||||
} `graphql:"organization(login: $owner)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
"query": githubv4.String("is:open"),
|
||||
}
|
||||
|
||||
var projectsV2 []RepoProjectV2
|
||||
for {
|
||||
var query responseData
|
||||
err := client.Query(repo.RepoHost(), "OrganizationProjectV2List", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projectsV2 = append(projectsV2, query.Organization.ProjectsV2.Nodes...)
|
||||
|
||||
if !query.Organization.ProjectsV2.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
variables["endCursor"] = githubv4.String(query.Organization.ProjectsV2.PageInfo.EndCursor)
|
||||
}
|
||||
|
||||
return projectsV2, nil
|
||||
}
|
||||
|
||||
type OrgTeam struct {
|
||||
ID string
|
||||
Slug string
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ type PullRequest struct {
|
|||
Assignees Assignees
|
||||
Labels Labels
|
||||
ProjectCards ProjectCards
|
||||
ProjectItems ProjectItems
|
||||
Milestone *Milestone
|
||||
Comments Comments
|
||||
ReactionGroups ReactionGroups
|
||||
|
|
@ -378,6 +379,19 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
|||
}
|
||||
}
|
||||
|
||||
// projectsV2 are added in yet another mutation
|
||||
projectV2Ids, ok := params["projectV2Ids"].([]string)
|
||||
if ok {
|
||||
projectItems := make(map[string]string, len(projectV2Ids))
|
||||
for _, p := range projectV2Ids {
|
||||
projectItems[p] = pr.ID
|
||||
}
|
||||
err = UpdateProjectV2Items(client, repo, projectItems, nil)
|
||||
if err != nil {
|
||||
return pr, err
|
||||
}
|
||||
}
|
||||
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ type PullRequestReviews struct {
|
|||
|
||||
type PullRequestReview struct {
|
||||
ID string `json:"id"`
|
||||
Author Author `json:"author"`
|
||||
Author CommentAuthor `json:"author"`
|
||||
AuthorAssociation string `json:"authorAssociation"`
|
||||
Body string `json:"body"`
|
||||
SubmittedAt *time.Time `json:"submittedAt"`
|
||||
|
|
|
|||
144
api/queries_projects_v2.go
Normal file
144
api/queries_projects_v2.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
||||
const (
|
||||
errorProjectsV2ReadScope = "field requires one of the following scopes: ['read:project']"
|
||||
errorProjectsV2RepositoryField = "Field 'ProjectsV2' doesn't exist on type 'Repository'"
|
||||
errorProjectsV2OrganizationField = "Field 'ProjectsV2' doesn't exist on type 'Organization'"
|
||||
errorProjectsV2IssueField = "Field 'ProjectItems' doesn't exist on type 'Issue'"
|
||||
errorProjectsV2PullRequestField = "Field 'ProjectItems' doesn't exist on type 'PullRequest'"
|
||||
)
|
||||
|
||||
// UpdateProjectV2Items uses the addProjectV2ItemById and the deleteProjectV2Item mutations
|
||||
// to add and delete items from projects. The addProjectItems and deleteProjectItems arguments are
|
||||
// mappings between a project and an item. This function can be used across multiple projects
|
||||
// and items. Note that the deleteProjectV2Item mutation requires the item id from the project not
|
||||
// the global id.
|
||||
func UpdateProjectV2Items(client *Client, repo ghrepo.Interface, addProjectItems, deleteProjectItems map[string]string) error {
|
||||
l := len(addProjectItems) + len(deleteProjectItems)
|
||||
if l == 0 {
|
||||
return nil
|
||||
}
|
||||
inputs := make([]string, 0, l)
|
||||
mutations := make([]string, 0, l)
|
||||
variables := make(map[string]interface{}, l)
|
||||
var i int
|
||||
|
||||
for project, item := range addProjectItems {
|
||||
inputs = append(inputs, fmt.Sprintf("$input_%03d: AddProjectV2ItemByIdInput!", i))
|
||||
mutations = append(mutations, fmt.Sprintf("add_%03d: addProjectV2ItemById(input: $input_%03d) { item { id } }", i, i))
|
||||
variables[fmt.Sprintf("input_%03d", i)] = map[string]interface{}{"contentId": item, "projectId": project}
|
||||
i++
|
||||
}
|
||||
|
||||
for project, item := range deleteProjectItems {
|
||||
inputs = append(inputs, fmt.Sprintf("$input_%03d: DeleteProjectV2ItemInput!", i))
|
||||
mutations = append(mutations, fmt.Sprintf("delete_%03d: deleteProjectV2Item(input: $input_%03d) { deletedItemId }", i, i))
|
||||
variables[fmt.Sprintf("input_%03d", i)] = map[string]interface{}{"itemId": item, "projectId": project}
|
||||
i++
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(`mutation UpdateProjectV2Items(%s) {%s}`, strings.Join(inputs, " "), strings.Join(mutations, " "))
|
||||
|
||||
return client.GraphQL(repo.RepoHost(), query, variables, nil)
|
||||
}
|
||||
|
||||
// ProjectsV2ItemsForIssue fetches all ProjectItems for an issue.
|
||||
func ProjectsV2ItemsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) error {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
Issue struct {
|
||||
ProjectItems struct {
|
||||
Nodes []*ProjectV2Item
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
} `graphql:"projectItems(first: 100, after: $endCursor)"`
|
||||
} `graphql:"issue(number: $number)"`
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
}
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
"number": githubv4.Int(issue.Number),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
var items ProjectItems
|
||||
for {
|
||||
var query response
|
||||
err := client.Query(repo.RepoHost(), "IssueProjectItems", &query, variables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items.Nodes = append(items.Nodes, query.Repository.Issue.ProjectItems.Nodes...)
|
||||
if !query.Repository.Issue.ProjectItems.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
variables["endCursor"] = githubv4.String(query.Repository.Issue.ProjectItems.PageInfo.EndCursor)
|
||||
}
|
||||
issue.ProjectItems = items
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProjectsV2ItemsForPullRequest fetches all ProjectItems for a pull request.
|
||||
func ProjectsV2ItemsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequest struct {
|
||||
ProjectItems struct {
|
||||
Nodes []*ProjectV2Item
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
} `graphql:"projectItems(first: 100, after: $endCursor)"`
|
||||
} `graphql:"pullRequest(number: $number)"`
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
}
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
"number": githubv4.Int(pr.Number),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
var items ProjectItems
|
||||
for {
|
||||
var query response
|
||||
err := client.Query(repo.RepoHost(), "PullRequestProjectItems", &query, variables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
items.Nodes = append(items.Nodes, query.Repository.PullRequest.ProjectItems.Nodes...)
|
||||
if !query.Repository.PullRequest.ProjectItems.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
variables["endCursor"] = githubv4.String(query.Repository.PullRequest.ProjectItems.PageInfo.EndCursor)
|
||||
}
|
||||
pr.ProjectItems = items
|
||||
return nil
|
||||
}
|
||||
|
||||
// When querying ProjectsV2 fields we generally dont want to show the user
|
||||
// scope errors and field does not exist errors. ProjectsV2IgnorableError
|
||||
// checks against known error strings to see if an error can be safely ignored.
|
||||
// Due to the fact that the GQLClient can return multiple types of errors
|
||||
// this uses brittle string comparison to check against the known error strings.
|
||||
func ProjectsV2IgnorableError(err error) bool {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, errorProjectsV2ReadScope) ||
|
||||
strings.Contains(msg, errorProjectsV2RepositoryField) ||
|
||||
strings.Contains(msg, errorProjectsV2OrganizationField) ||
|
||||
strings.Contains(msg, errorProjectsV2IssueField) ||
|
||||
strings.Contains(msg, errorProjectsV2PullRequestField) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
252
api/queries_projects_v2_test.go
Normal file
252
api/queries_projects_v2_test.go
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestUpdateProjectV2Items(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "updates project items",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{"add_000":{"item":{"id":"1"}},"delete_001":{"item":{"id":"2"}}}}`,
|
||||
func(mutations string, inputs map[string]interface{}) {
|
||||
expectedMutations := `
|
||||
mutation UpdateProjectV2Items(
|
||||
$input_000: AddProjectV2ItemByIdInput!
|
||||
$input_001: AddProjectV2ItemByIdInput!
|
||||
$input_002: DeleteProjectV2ItemInput!
|
||||
$input_003: DeleteProjectV2ItemInput!
|
||||
) {
|
||||
add_000: addProjectV2ItemById(input: $input_000) { item { id } }
|
||||
add_001: addProjectV2ItemById(input: $input_001) { item { id } }
|
||||
delete_002: deleteProjectV2Item(input: $input_002) { deletedItemId }
|
||||
delete_003: deleteProjectV2Item(input: $input_003) { deletedItemId }
|
||||
}`
|
||||
expectedVariables := map[string]interface{}{
|
||||
"input_000": map[string]interface{}{"contentId": "item1", "projectId": "project1"},
|
||||
"input_001": map[string]interface{}{"contentId": "item2", "projectId": "project2"},
|
||||
"input_002": map[string]interface{}{"itemId": "item3", "projectId": "project3"},
|
||||
"input_003": map[string]interface{}{"itemId": "item4", "projectId": "project4"},
|
||||
}
|
||||
assert.Equal(t, stripSpace(expectedMutations), stripSpace(mutations))
|
||||
assert.Equal(t, expectedVariables, inputs)
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails to update project items",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
||||
httpmock.GraphQLMutation(`{"data":{}, "errors": [{"message": "some gql error"}]}`, func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
client := newTestClient(reg)
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
addProjectItems := map[string]string{"project1": "item1", "project2": "item2"}
|
||||
deleteProjectItems := map[string]string{"project3": "item3", "project4": "item4"}
|
||||
err := UpdateProjectV2Items(client, repo, addProjectItems, deleteProjectItems)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectsV2ItemsForIssue(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
expectItems ProjectItems
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "retrieves project items for issue",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueProjectItems\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{"repository":{"issue":{"projectItems":{"nodes": [{"id":"projectItem1"},{"id":"projectItem2"}]}}}}}`,
|
||||
func(query string, inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
expectItems: ProjectItems{
|
||||
Nodes: []*ProjectV2Item{
|
||||
{ID: "projectItem1"},
|
||||
{ID: "projectItem2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails to retrieve project items for issue",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueProjectItems\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{}, "errors": [{"message": "some gql error"}]}`,
|
||||
func(query string, inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
client := newTestClient(reg)
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
issue := &Issue{Number: 1}
|
||||
err := ProjectsV2ItemsForIssue(client, repo, issue)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.expectItems, issue.ProjectItems)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectsV2ItemsForPullRequest(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
expectItems ProjectItems
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "retrieves project items for pull request",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{"repository":{"pullRequest":{"projectItems":{"nodes": [{"id":"projectItem3"},{"id":"projectItem4"}]}}}}}`,
|
||||
func(query string, inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
expectItems: ProjectItems{
|
||||
Nodes: []*ProjectV2Item{
|
||||
{ID: "projectItem3"},
|
||||
{ID: "projectItem4"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fails to retrieve project items for pull request",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestProjectItems\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{}, "errors": [{"message": "some gql error"}]}`,
|
||||
func(query string, inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
client := newTestClient(reg)
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
pr := &PullRequest{Number: 1}
|
||||
err := ProjectsV2ItemsForPullRequest(client, repo, pr)
|
||||
if tt.expectError {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.expectItems, pr.ProjectItems)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectsV2IgnorableError(t *testing.T) {
|
||||
var tests = []struct {
|
||||
name string
|
||||
errMsg string
|
||||
expectOut bool
|
||||
}{
|
||||
{
|
||||
name: "read scope error",
|
||||
errMsg: "field requires one of the following scopes: ['read:project']",
|
||||
expectOut: true,
|
||||
},
|
||||
{
|
||||
name: "repository projectsV2 field error",
|
||||
errMsg: "Field 'ProjectsV2' doesn't exist on type 'Repository'",
|
||||
expectOut: true,
|
||||
},
|
||||
{
|
||||
name: "organization projectsV2 field error",
|
||||
errMsg: "Field 'ProjectsV2' doesn't exist on type 'Organization'",
|
||||
expectOut: true,
|
||||
},
|
||||
{
|
||||
name: "issue projectItems field error",
|
||||
errMsg: "Field 'ProjectItems' doesn't exist on type 'Issue'",
|
||||
expectOut: true,
|
||||
},
|
||||
{
|
||||
name: "pullRequest projectItems field error",
|
||||
errMsg: "Field 'ProjectItems' doesn't exist on type 'PullRequest'",
|
||||
expectOut: true,
|
||||
},
|
||||
{
|
||||
name: "other error",
|
||||
errMsg: "some other graphql error message",
|
||||
expectOut: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := errors.New(tt.errMsg)
|
||||
out := ProjectsV2IgnorableError(err)
|
||||
assert.Equal(t, tt.expectOut, out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stripSpace(str string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(str))
|
||||
for _, ch := range str {
|
||||
if !unicode.IsSpace(ch) {
|
||||
b.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -12,6 +13,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
|
|
@ -647,6 +649,7 @@ type RepoMetadataResult struct {
|
|||
AssignableUsers []RepoAssignee
|
||||
Labels []RepoLabel
|
||||
Projects []RepoProject
|
||||
ProjectsV2 []RepoProjectV2
|
||||
Milestones []RepoMilestone
|
||||
Teams []OrgTeam
|
||||
}
|
||||
|
|
@ -706,25 +709,52 @@ func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) {
|
|||
return ids, nil
|
||||
}
|
||||
|
||||
func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) {
|
||||
// ProjectsToIDs returns two arrays:
|
||||
// - the first contains IDs of projects V1
|
||||
// - the second contains IDs of projects V2
|
||||
// - if neither project V1 or project V2 can be found with a given name, then an error is returned
|
||||
func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, []string, error) {
|
||||
var ids []string
|
||||
var idsV2 []string
|
||||
for _, projectName := range names {
|
||||
found := false
|
||||
for _, p := range m.Projects {
|
||||
if strings.EqualFold(projectName, p.Name) {
|
||||
ids = append(ids, p.ID)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
id, found := m.projectNameToID(projectName)
|
||||
if found {
|
||||
ids = append(ids, id)
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("'%s' not found", projectName)
|
||||
|
||||
idV2, found := m.projectV2TitleToID(projectName)
|
||||
if found {
|
||||
idsV2 = append(idsV2, idV2)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("'%s' not found", projectName)
|
||||
}
|
||||
return ids, nil
|
||||
return ids, idsV2, nil
|
||||
}
|
||||
|
||||
func ProjectsToPaths(projects []RepoProject, names []string) ([]string, error) {
|
||||
func (m *RepoMetadataResult) projectNameToID(projectName string) (string, bool) {
|
||||
for _, p := range m.Projects {
|
||||
if strings.EqualFold(projectName, p.Name) {
|
||||
return p.ID, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (m *RepoMetadataResult) projectV2TitleToID(projectTitle string) (string, bool) {
|
||||
for _, p := range m.ProjectsV2 {
|
||||
if strings.EqualFold(projectTitle, p.Title) {
|
||||
return p.ID, true
|
||||
}
|
||||
}
|
||||
|
||||
return "", false
|
||||
}
|
||||
|
||||
func ProjectsToPaths(projects []RepoProject, projectsV2 []RepoProjectV2, names []string) ([]string, error) {
|
||||
var paths []string
|
||||
for _, projectName := range names {
|
||||
found := false
|
||||
|
|
@ -744,6 +774,25 @@ func ProjectsToPaths(projects []RepoProject, names []string) ([]string, error) {
|
|||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
}
|
||||
for _, p := range projectsV2 {
|
||||
if strings.EqualFold(projectName, p.Title) {
|
||||
// format of ResourcePath: /OWNER/REPO/projects/PROJECT_NUMBER or /orgs/ORG/projects/PROJECT_NUMBER
|
||||
// required format of path: OWNER/REPO/PROJECT_NUMBER or ORG/PROJECT_NUMBER
|
||||
var path string
|
||||
pathParts := strings.Split(p.ResourcePath, "/")
|
||||
if pathParts[1] == "orgs" {
|
||||
path = fmt.Sprintf("%s/%s", pathParts[2], pathParts[4])
|
||||
} else {
|
||||
path = fmt.Sprintf("%s/%s/%s", pathParts[1], pathParts[2], pathParts[4])
|
||||
}
|
||||
paths = append(paths, path)
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("'%s' not found", projectName)
|
||||
}
|
||||
|
|
@ -854,6 +903,18 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
|
|||
errc <- nil
|
||||
}()
|
||||
}
|
||||
if input.Projects {
|
||||
count++
|
||||
go func() {
|
||||
projectsV2, err := RepoAndOrgProjectsV2(client, repo)
|
||||
if err != nil {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
result.ProjectsV2 = projectsV2
|
||||
errc <- nil
|
||||
}()
|
||||
}
|
||||
if input.Milestones {
|
||||
count++
|
||||
go func() {
|
||||
|
|
@ -985,7 +1046,15 @@ type RepoProject struct {
|
|||
ResourcePath string `json:"resourcePath"`
|
||||
}
|
||||
|
||||
// RepoProjects fetches all open projects for a repository
|
||||
type RepoProjectV2 struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Number int `json:"number"`
|
||||
ResourcePath string `json:"resourcePath"`
|
||||
Closed bool `json:"closed"`
|
||||
}
|
||||
|
||||
// RepoProjects fetches all open projects for a repository.
|
||||
func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
|
||||
type responseData struct {
|
||||
Repository struct {
|
||||
|
|
@ -1023,23 +1092,87 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
|
|||
return projects, nil
|
||||
}
|
||||
|
||||
// RepoAndOrgProjects fetches all open projects for a repository and its org
|
||||
// RepoProjectsV2 fetches all open projectsV2 for a repository.
|
||||
func RepoProjectsV2(client *Client, repo ghrepo.Interface) ([]RepoProjectV2, error) {
|
||||
type responseData struct {
|
||||
Repository struct {
|
||||
ProjectsV2 struct {
|
||||
Nodes []RepoProjectV2
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
} `graphql:"projectsV2(first: 100, orderBy: {field: TITLE, direction: ASC}, after: $endCursor, query: $query)"`
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
"query": githubv4.String("is:open"),
|
||||
}
|
||||
|
||||
var projectsV2 []RepoProjectV2
|
||||
for {
|
||||
var query responseData
|
||||
err := client.Query(repo.RepoHost(), "RepositoryProjectV2List", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
projectsV2 = append(projectsV2, query.Repository.ProjectsV2.Nodes...)
|
||||
|
||||
if !query.Repository.ProjectsV2.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
variables["endCursor"] = githubv4.String(query.Repository.ProjectsV2.PageInfo.EndCursor)
|
||||
}
|
||||
|
||||
return projectsV2, nil
|
||||
}
|
||||
|
||||
// RepoAndOrgProjects fetches all open projects for a repository and its organization.
|
||||
func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
|
||||
projects, err := RepoProjects(client, repo)
|
||||
if err != nil {
|
||||
return projects, fmt.Errorf("error fetching projects: %w", err)
|
||||
return nil, fmt.Errorf("error fetching projects: %w", err)
|
||||
}
|
||||
|
||||
orgProjects, err := OrganizationProjects(client, repo)
|
||||
// TODO: better detection of non-org repos
|
||||
// TODO: Better detection of non-org repos.
|
||||
if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") {
|
||||
return projects, fmt.Errorf("error fetching organization projects: %w", err)
|
||||
return nil, fmt.Errorf("error fetching organization projects: %w", err)
|
||||
}
|
||||
|
||||
projects = append(projects, orgProjects...)
|
||||
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// RepoAndOrgProjectsV2 fetches all open projectsV2 for a repository and its organization.
|
||||
// Note: If the auth token does not have sufficient scopes or projectsV2 is not supported
|
||||
// on the host then those errors are swallowed and nil is returned.
|
||||
func RepoAndOrgProjectsV2(client *Client, repo ghrepo.Interface) ([]RepoProjectV2, error) {
|
||||
projectsV2, err := RepoProjectsV2(client, repo)
|
||||
if err != nil {
|
||||
if ProjectsV2IgnorableError(err) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("error fetching projectsV2: %w", err)
|
||||
}
|
||||
|
||||
orgProjectsV2, err := OrganizationProjectsV2(client, repo)
|
||||
if err != nil && !strings.Contains(err.Error(), "Could not resolve to an Organization") {
|
||||
return nil, fmt.Errorf("error fetching organization projectsV2: %w", err)
|
||||
}
|
||||
|
||||
projectsV2 = append(projectsV2, orgProjectsV2...)
|
||||
|
||||
return projectsV2, nil
|
||||
}
|
||||
|
||||
type RepoAssignee struct {
|
||||
ID string
|
||||
Login string
|
||||
|
|
@ -1192,12 +1325,27 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
|
|||
}
|
||||
|
||||
func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
|
||||
var paths []string
|
||||
projects, err := RepoAndOrgProjects(client, repo)
|
||||
if err != nil {
|
||||
return paths, err
|
||||
g, _ := errgroup.WithContext(context.Background())
|
||||
var projects []RepoProject
|
||||
var projectsV2 []RepoProjectV2
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
projects, err = RepoAndOrgProjects(client, repo)
|
||||
return err
|
||||
})
|
||||
|
||||
g.Go(func() error {
|
||||
var err error
|
||||
projectsV2, err = RepoAndOrgProjectsV2(client, repo)
|
||||
return err
|
||||
})
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ProjectsToPaths(projects, projectNames)
|
||||
|
||||
return ProjectsToPaths(projects, projectsV2, projectNames)
|
||||
}
|
||||
|
||||
func CreateRepoTransformToV4(apiClient *Client, hostname string, method string, path string, body io.Reader) (*Repository, error) {
|
||||
|
|
|
|||
|
|
@ -89,6 +89,17 @@ func Test_RepoMetadata(t *testing.T) {
|
|||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
|
||||
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||
httpmock.StringResponse(`
|
||||
|
|
@ -99,6 +110,16 @@ func Test_RepoMetadata(t *testing.T) {
|
|||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "TriageV2", "id": "TRIAGEV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationTeamList\b`),
|
||||
httpmock.StringResponse(`
|
||||
|
|
@ -149,13 +170,17 @@ func Test_RepoMetadata(t *testing.T) {
|
|||
}
|
||||
|
||||
expectedProjectIDs := []string{"TRIAGEID", "ROADMAPID"}
|
||||
projectIDs, err := result.ProjectsToIDs([]string{"triage", "roadmap"})
|
||||
expectedProjectV2IDs := []string{"TRIAGEV2ID", "ROADMAPV2ID"}
|
||||
projectIDs, projectV2IDs, err := result.ProjectsToIDs([]string{"triage", "roadmap", "triagev2", "roadmapv2"})
|
||||
if err != nil {
|
||||
t.Errorf("error resolving projects: %v", err)
|
||||
}
|
||||
if !sliceEqual(projectIDs, expectedProjectIDs) {
|
||||
t.Errorf("expected projects %v, got %v", expectedProjectIDs, projectIDs)
|
||||
}
|
||||
if !sliceEqual(projectV2IDs, expectedProjectV2IDs) {
|
||||
t.Errorf("expected projectsV2 %v, got %v", expectedProjectV2IDs, projectV2IDs)
|
||||
}
|
||||
|
||||
expectedMilestoneID := "BIGONEID"
|
||||
milestoneID, err := result.MilestoneToID("big one.oh")
|
||||
|
|
@ -173,15 +198,19 @@ func Test_RepoMetadata(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_ProjectsToPaths(t *testing.T) {
|
||||
expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER"}
|
||||
expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER", "OWNER/REPO/PROJECT_NUMBER_2"}
|
||||
projects := []RepoProject{
|
||||
{ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"},
|
||||
{ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"},
|
||||
{ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"},
|
||||
}
|
||||
projectNames := []string{"My Project", "Org Project"}
|
||||
projectsV2 := []RepoProjectV2{
|
||||
{ID: "id4", Title: "My Project V2", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER_2"},
|
||||
{ID: "id5", Title: "Org Project V2", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_3"},
|
||||
}
|
||||
projectNames := []string{"My Project", "Org Project", "My Project V2"}
|
||||
|
||||
projectPaths, err := ProjectsToPaths(projects, projectNames)
|
||||
projectPaths, err := ProjectsToPaths(projects, projectsV2, projectNames)
|
||||
if err != nil {
|
||||
t.Errorf("error resolving projects: %v", err)
|
||||
}
|
||||
|
|
@ -210,20 +239,41 @@ func Test_ProjectNamesToPaths(t *testing.T) {
|
|||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projects": {
|
||||
"nodes": [
|
||||
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
{ "data": { "organization": { "projects": {
|
||||
"nodes": [
|
||||
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/3" },
|
||||
{ "title": "RoadmapV2", "id": "ROADMAPV2ID", "resourcePath": "/OWNER/REPO/projects/4" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
|
||||
projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap"})
|
||||
projectPaths, err := ProjectNamesToPaths(client, repo, []string{"Triage", "Roadmap", "TriageV2", "RoadmapV2"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2"}
|
||||
expectedProjectPaths := []string{"ORG/1", "OWNER/REPO/2", "ORG/2", "OWNER/REPO/4"}
|
||||
if !sliceEqual(projectPaths, expectedProjectPaths) {
|
||||
t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ func IssueGraphQL(fields []string) string {
|
|||
case "author":
|
||||
q = append(q, `author{login,...on User{id,name}}`)
|
||||
case "mergedBy":
|
||||
q = append(q, `mergedBy{login}`)
|
||||
q = append(q, `mergedBy{login,...on User{id,name}}`)
|
||||
case "headRepositoryOwner":
|
||||
q = append(q, `headRepositoryOwner{id,login,...on User{name}}`)
|
||||
case "headRepository":
|
||||
|
|
@ -274,6 +274,8 @@ func IssueGraphQL(fields []string) string {
|
|||
q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`)
|
||||
case "projectCards":
|
||||
q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`)
|
||||
case "projectItems":
|
||||
q = append(q, `projectItems(first:100){nodes{id, project{id,title}},totalCount}`)
|
||||
case "milestone":
|
||||
q = append(q, `milestone{number,title,description,dueOn}`)
|
||||
case "reactionGroups":
|
||||
|
|
|
|||
55
docs/working-with-us.md
Normal file
55
docs/working-with-us.md
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# Working with the GitHub CLI Team: Hubber Edition
|
||||
|
||||
POV: your team at GitHub is interested in shipping a new command in `gh`.
|
||||
|
||||
This document outlines the process the CLI team prefers for helping ensure success both for your new feature and the CLI project as a whole.
|
||||
|
||||
## Step 0: Create an extension
|
||||
|
||||
Even if you want to see your code merged into `gh`, you should start with [an extension](https://docs.github.com/en/github-cli/github-cli/creating-github-cli-extensions) written in Go and leveraging [go-gh](https://github.com/cli/go-gh). Though `gh` extensions can be written in any language, we treat Go as a first class experience and ship a library of helpers for extensions written in Go.
|
||||
|
||||
Creating an extension enables you to start prototyping immediately, without waiting for us, and gives us something tangible to review if you decide you'd like the work incorporated into `gh`. It also means that you can decide to simply release your work without waiting for us to merge it, which leaves you in charge of release scheduling moving forward.
|
||||
|
||||
If you know from this point that you're comfortable with your new feature being an extension, don't worry about the rest of this document. We don't dictate how people create and release `gh` extensions.
|
||||
|
||||
If you do want your feature merged into `gh`, read on.
|
||||
|
||||
## Step 1: UX review
|
||||
|
||||
No matter what state your code is in, open up an issue either in [the open source cli/cli repository](https://github.com/cli/cli) or, if you'd rather not make the new feature public yet, [the closed github/cli repository](https://github.com/github/cli).
|
||||
|
||||
Describe how your new command would be used. Include mock-up examples, including a mock-up of what usage information would be printed if a user ran your command with `--help`.
|
||||
|
||||
We take this step seriously because we believe in keeping `gh`'s interface consistent and intuitive.
|
||||
|
||||
## Step 2: Beta
|
||||
|
||||
Once we've signed off on the proposed UX on the issue opened in step 1, develop your extension to at least beta quality. It's up to you if you actually want to go through a beta release phase with real users or not.
|
||||
|
||||
## Step 3: Merge or no merge
|
||||
|
||||
With a beta in hand it's time to decide whether or not to mainline your extension into the `trunk` of `gh`. Some questions to consider:
|
||||
|
||||
- How complex is the support burden for your feature?
|
||||
|
||||
If this feature requires extensive or specialized support, you will either need to release it as an extension or work with the CLI team to get maintainer access to `cli/cli`. The CLI team is very small and cannot promise any kind of SLA for supporting your work. For example, the `gh cs` command is sufficiently specialized and complex that we have given the `codespaces` team write access to the repository to maintain their own pull request review process. We have not put it in an extension as Codespaces are a core GitHub product with widespread use among our users.
|
||||
|
||||
- What kind of release cadence do you want?
|
||||
|
||||
We do a `gh` release roughly every other week, but if the changeset for a given week is light we may skip one. We make no official promise as to our cadence, and while we do have an on-call rotation there is no guarantee that you'll be able to get emergency fixes out within hours. If this is troubling, consider keeping your work in an extension.
|
||||
|
||||
- What kind of audience are you trying to reach?
|
||||
|
||||
Is this new feature intended for all GitHub users or just a few? If it's as applicable to your average GitHub user or customer as something like Codespaces or Pull Requests, that's a strong indication it should be merged into `trunk`. If not, consider keeping it an extension.
|
||||
|
||||
If after all of this consideration you think your feature should be merged, please open an issue in [cli/cli](https://github.com/cli/cli) with a link to your extension's code. It will go into our triage queue and we'll confirm that merging into `trunk` is feasible and appropriate.
|
||||
|
||||
## Step 4
|
||||
|
||||
Once we've signed off, open up a pull request in [cli/cli](https://github.com/cli/cli) adding your command. Since we make use of `go-gh` within our code already, it shouldn't be too onerous to make your extension merge-able. Link to the issue you opened in step 3 so we have some context on the pull request.
|
||||
|
||||
## Other considerations
|
||||
|
||||
- If you have a high need for secrecy until the point of release, let us know in [#cli on slack](https://github.slack.com/archives/CLLG3RMAR). We'll come up with a solution to work on merging your command in private.
|
||||
- We are a highly asynchronous team due to wide timezone differences. The best way to get in touch with us is via issue and pull request comments to which we'll respond within 24 hours. You can ping us on Slack but that's generally not our preference.
|
||||
- We are happy to pair with you on extension authoring! Just let us know if we can provide guidance and we can schedule synchronous time to work together with you.
|
||||
|
|
@ -20,6 +20,10 @@ import (
|
|||
|
||||
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
|
||||
|
||||
type errWithExitCode interface {
|
||||
ExitCode() int
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
GhPath string
|
||||
RepoDir string
|
||||
|
|
@ -489,21 +493,16 @@ func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBra
|
|||
return remote, nil
|
||||
}
|
||||
|
||||
func (c *Client) InGitDirectory(ctx context.Context) bool {
|
||||
showCmd, err := c.Command(ctx, "rev-parse", "--is-inside-work-tree")
|
||||
func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) {
|
||||
_, err := c.GitDir(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
var execError errWithExitCode
|
||||
if errors.As(err, &execError) && execError.ExitCode() == 128 {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
out, err := showCmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
split := strings.Split(string(out), "\n")
|
||||
if len(split) > 0 {
|
||||
return split[0] == "true"
|
||||
}
|
||||
return false
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,6 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session
|
|||
defer progress.StopProgressIndicator()
|
||||
|
||||
return liveshare.Connect(ctx, liveshare.Options{
|
||||
ClientName: "gh",
|
||||
SessionID: codespace.Connection.SessionID,
|
||||
SessionToken: codespace.Connection.SessionToken,
|
||||
RelaySAS: codespace.Connection.RelaySAS,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,116 @@ const (
|
|||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||
)
|
||||
|
||||
type NotifyCodespaceOfClientActivityRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ClientId string `protobuf:"bytes,1,opt,name=ClientId,proto3" json:"ClientId,omitempty"`
|
||||
ClientActivities []string `protobuf:"bytes,2,rep,name=ClientActivities,proto3" json:"ClientActivities,omitempty"`
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityRequest) Reset() {
|
||||
*x = NotifyCodespaceOfClientActivityRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NotifyCodespaceOfClientActivityRequest) ProtoMessage() {}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use NotifyCodespaceOfClientActivityRequest.ProtoReflect.Descriptor instead.
|
||||
func (*NotifyCodespaceOfClientActivityRequest) Descriptor() ([]byte, []int) {
|
||||
return file_codespace_codespace_host_service_v1_proto_rawDescGZIP(), []int{0}
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityRequest) GetClientId() string {
|
||||
if x != nil {
|
||||
return x.ClientId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityRequest) GetClientActivities() []string {
|
||||
if x != nil {
|
||||
return x.ClientActivities
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type NotifyCodespaceOfClientActivityResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Result bool `protobuf:"varint,1,opt,name=Result,proto3" json:"Result,omitempty"`
|
||||
Message string `protobuf:"bytes,2,opt,name=Message,proto3" json:"Message,omitempty"`
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityResponse) Reset() {
|
||||
*x = NotifyCodespaceOfClientActivityResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*NotifyCodespaceOfClientActivityResponse) ProtoMessage() {}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use NotifyCodespaceOfClientActivityResponse.ProtoReflect.Descriptor instead.
|
||||
func (*NotifyCodespaceOfClientActivityResponse) Descriptor() ([]byte, []int) {
|
||||
return file_codespace_codespace_host_service_v1_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityResponse) GetResult() bool {
|
||||
if x != nil {
|
||||
return x.Result
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *NotifyCodespaceOfClientActivityResponse) GetMessage() string {
|
||||
if x != nil {
|
||||
return x.Message
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type RebuildContainerRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
|
|
@ -31,7 +141,7 @@ type RebuildContainerRequest struct {
|
|||
func (x *RebuildContainerRequest) Reset() {
|
||||
*x = RebuildContainerRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[0]
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -44,7 +154,7 @@ func (x *RebuildContainerRequest) String() string {
|
|||
func (*RebuildContainerRequest) ProtoMessage() {}
|
||||
|
||||
func (x *RebuildContainerRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[0]
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -57,7 +167,7 @@ func (x *RebuildContainerRequest) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use RebuildContainerRequest.ProtoReflect.Descriptor instead.
|
||||
func (*RebuildContainerRequest) Descriptor() ([]byte, []int) {
|
||||
return file_codespace_codespace_host_service_v1_proto_rawDescGZIP(), []int{0}
|
||||
return file_codespace_codespace_host_service_v1_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *RebuildContainerRequest) GetIncremental() bool {
|
||||
|
|
@ -78,7 +188,7 @@ type RebuildContainerResponse struct {
|
|||
func (x *RebuildContainerResponse) Reset() {
|
||||
*x = RebuildContainerResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[1]
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
|
@ -91,7 +201,7 @@ func (x *RebuildContainerResponse) String() string {
|
|||
func (*RebuildContainerResponse) ProtoMessage() {}
|
||||
|
||||
func (x *RebuildContainerResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[1]
|
||||
mi := &file_codespace_codespace_host_service_v1_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
|
|
@ -104,7 +214,7 @@ func (x *RebuildContainerResponse) ProtoReflect() protoreflect.Message {
|
|||
|
||||
// Deprecated: Use RebuildContainerResponse.ProtoReflect.Descriptor instead.
|
||||
func (*RebuildContainerResponse) Descriptor() ([]byte, []int) {
|
||||
return file_codespace_codespace_host_service_v1_proto_rawDescGZIP(), []int{1}
|
||||
return file_codespace_codespace_host_service_v1_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *RebuildContainerResponse) GetRebuildContainer() bool {
|
||||
|
|
@ -122,29 +232,54 @@ var file_codespace_codespace_host_service_v1_proto_rawDesc = []byte{
|
|||
0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x27, 0x43, 0x6f, 0x64,
|
||||
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6f, 0x64,
|
||||
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x2e, 0x76, 0x31, 0x22, 0x50, 0x0a, 0x17, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43,
|
||||
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||
0x25, 0x0a, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x61, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x49, 0x6e, 0x63, 0x72, 0x65,
|
||||
0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x22, 0x46, 0x0a, 0x18, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c,
|
||||
0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e,
|
||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x52, 0x65,
|
||||
0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x32, 0xae,
|
||||
0x01, 0x0a, 0x0d, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74,
|
||||
0x12, 0x9c, 0x01, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x41, 0x73, 0x79, 0x6e, 0x63, 0x12, 0x40, 0x2e, 0x43, 0x6f, 0x64,
|
||||
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6f, 0x64,
|
||||
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
||||
0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x41, 0x2e, 0x43,
|
||||
0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x43,
|
||||
0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42,
|
||||
0x0d, 0x5a, 0x0b, 0x2e, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x62, 0x06,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x65, 0x2e, 0x76, 0x31, 0x22, 0x70, 0x0a, 0x26, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x43, 0x6f,
|
||||
0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x66, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41,
|
||||
0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x43, 0x6c,
|
||||
0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x02,
|
||||
0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x74, 0x69,
|
||||
0x76, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x5b, 0x0a, 0x27, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79,
|
||||
0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x66, 0x43, 0x6c, 0x69, 0x65, 0x6e,
|
||||
0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x08, 0x52, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73,
|
||||
0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73,
|
||||
0x61, 0x67, 0x65, 0x22, 0x50, 0x0a, 0x17, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f,
|
||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25,
|
||||
0x0a, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x18, 0x01, 0x20,
|
||||
0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||
0x61, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x61, 0x6c, 0x22, 0x46, 0x0a, 0x18, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x2a, 0x0a, 0x10, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74,
|
||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x52, 0x65, 0x62,
|
||||
0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x32, 0xf5, 0x02,
|
||||
0x0a, 0x0d, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74, 0x12,
|
||||
0xc4, 0x01, 0x0a, 0x1f, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70,
|
||||
0x61, 0x63, 0x65, 0x4f, 0x66, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76,
|
||||
0x69, 0x74, 0x79, 0x12, 0x4f, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73,
|
||||
0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48,
|
||||
0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e, 0x6f,
|
||||
0x74, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x66, 0x43,
|
||||
0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x50, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65,
|
||||
0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65,
|
||||
0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x4e,
|
||||
0x6f, 0x74, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x66,
|
||||
0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x9c, 0x01, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69,
|
||||
0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x41, 0x73, 0x79, 0x6e, 0x63,
|
||||
0x12, 0x40, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72,
|
||||
0x70, 0x63, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74,
|
||||
0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69,
|
||||
0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x41, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e,
|
||||
0x47, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f,
|
||||
0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x62,
|
||||
0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0d, 0x5a, 0x0b, 0x2e, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x73,
|
||||
0x70, 0x61, 0x63, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -159,16 +294,20 @@ func file_codespace_codespace_host_service_v1_proto_rawDescGZIP() []byte {
|
|||
return file_codespace_codespace_host_service_v1_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_codespace_codespace_host_service_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
|
||||
var file_codespace_codespace_host_service_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
|
||||
var file_codespace_codespace_host_service_v1_proto_goTypes = []interface{}{
|
||||
(*RebuildContainerRequest)(nil), // 0: Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerRequest
|
||||
(*RebuildContainerResponse)(nil), // 1: Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerResponse
|
||||
(*NotifyCodespaceOfClientActivityRequest)(nil), // 0: Codespaces.Grpc.CodespaceHostService.v1.NotifyCodespaceOfClientActivityRequest
|
||||
(*NotifyCodespaceOfClientActivityResponse)(nil), // 1: Codespaces.Grpc.CodespaceHostService.v1.NotifyCodespaceOfClientActivityResponse
|
||||
(*RebuildContainerRequest)(nil), // 2: Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerRequest
|
||||
(*RebuildContainerResponse)(nil), // 3: Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerResponse
|
||||
}
|
||||
var file_codespace_codespace_host_service_v1_proto_depIdxs = []int32{
|
||||
0, // 0: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.RebuildContainerAsync:input_type -> Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerRequest
|
||||
1, // 1: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.RebuildContainerAsync:output_type -> Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerResponse
|
||||
1, // [1:2] is the sub-list for method output_type
|
||||
0, // [0:1] is the sub-list for method input_type
|
||||
0, // 0: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.NotifyCodespaceOfClientActivity:input_type -> Codespaces.Grpc.CodespaceHostService.v1.NotifyCodespaceOfClientActivityRequest
|
||||
2, // 1: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.RebuildContainerAsync:input_type -> Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerRequest
|
||||
1, // 2: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.NotifyCodespaceOfClientActivity:output_type -> Codespaces.Grpc.CodespaceHostService.v1.NotifyCodespaceOfClientActivityResponse
|
||||
3, // 3: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.RebuildContainerAsync:output_type -> Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerResponse
|
||||
2, // [2:4] is the sub-list for method output_type
|
||||
0, // [0:2] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
|
|
@ -181,7 +320,7 @@ func file_codespace_codespace_host_service_v1_proto_init() {
|
|||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_codespace_codespace_host_service_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*RebuildContainerRequest); i {
|
||||
switch v := v.(*NotifyCodespaceOfClientActivityRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
|
|
@ -193,6 +332,30 @@ func file_codespace_codespace_host_service_v1_proto_init() {
|
|||
}
|
||||
}
|
||||
file_codespace_codespace_host_service_v1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*NotifyCodespaceOfClientActivityResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_codespace_codespace_host_service_v1_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*RebuildContainerRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_codespace_codespace_host_service_v1_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
|
||||
switch v := v.(*RebuildContainerResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
|
|
@ -205,14 +368,14 @@ func file_codespace_codespace_host_service_v1_proto_init() {
|
|||
}
|
||||
}
|
||||
}
|
||||
file_codespace_codespace_host_service_v1_proto_msgTypes[0].OneofWrappers = []interface{}{}
|
||||
file_codespace_codespace_host_service_v1_proto_msgTypes[2].OneofWrappers = []interface{}{}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_codespace_codespace_host_service_v1_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 2,
|
||||
NumMessages: 4,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -5,9 +5,19 @@ option go_package = "./codespace";
|
|||
package Codespaces.Grpc.CodespaceHostService.v1;
|
||||
|
||||
service CodespaceHost {
|
||||
rpc NotifyCodespaceOfClientActivity (NotifyCodespaceOfClientActivityRequest) returns (NotifyCodespaceOfClientActivityResponse);
|
||||
rpc RebuildContainerAsync (RebuildContainerRequest) returns (RebuildContainerResponse);
|
||||
}
|
||||
|
||||
message NotifyCodespaceOfClientActivityRequest {
|
||||
string ClientId = 1;
|
||||
repeated string ClientActivities = 2;
|
||||
}
|
||||
message NotifyCodespaceOfClientActivityResponse {
|
||||
bool Result = 1;
|
||||
string Message = 2;
|
||||
}
|
||||
|
||||
message RebuildContainerRequest {
|
||||
optional bool Incremental = 1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const _ = grpc.SupportPackageIsVersion7
|
|||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
type CodespaceHostClient interface {
|
||||
NotifyCodespaceOfClientActivity(ctx context.Context, in *NotifyCodespaceOfClientActivityRequest, opts ...grpc.CallOption) (*NotifyCodespaceOfClientActivityResponse, error)
|
||||
RebuildContainerAsync(ctx context.Context, in *RebuildContainerRequest, opts ...grpc.CallOption) (*RebuildContainerResponse, error)
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +34,15 @@ func NewCodespaceHostClient(cc grpc.ClientConnInterface) CodespaceHostClient {
|
|||
return &codespaceHostClient{cc}
|
||||
}
|
||||
|
||||
func (c *codespaceHostClient) NotifyCodespaceOfClientActivity(ctx context.Context, in *NotifyCodespaceOfClientActivityRequest, opts ...grpc.CallOption) (*NotifyCodespaceOfClientActivityResponse, error) {
|
||||
out := new(NotifyCodespaceOfClientActivityResponse)
|
||||
err := c.cc.Invoke(ctx, "/Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost/NotifyCodespaceOfClientActivity", in, out, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *codespaceHostClient) RebuildContainerAsync(ctx context.Context, in *RebuildContainerRequest, opts ...grpc.CallOption) (*RebuildContainerResponse, error) {
|
||||
out := new(RebuildContainerResponse)
|
||||
err := c.cc.Invoke(ctx, "/Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost/RebuildContainerAsync", in, out, opts...)
|
||||
|
|
@ -46,6 +56,7 @@ func (c *codespaceHostClient) RebuildContainerAsync(ctx context.Context, in *Reb
|
|||
// All implementations must embed UnimplementedCodespaceHostServer
|
||||
// for forward compatibility
|
||||
type CodespaceHostServer interface {
|
||||
NotifyCodespaceOfClientActivity(context.Context, *NotifyCodespaceOfClientActivityRequest) (*NotifyCodespaceOfClientActivityResponse, error)
|
||||
RebuildContainerAsync(context.Context, *RebuildContainerRequest) (*RebuildContainerResponse, error)
|
||||
mustEmbedUnimplementedCodespaceHostServer()
|
||||
}
|
||||
|
|
@ -54,6 +65,9 @@ type CodespaceHostServer interface {
|
|||
type UnimplementedCodespaceHostServer struct {
|
||||
}
|
||||
|
||||
func (UnimplementedCodespaceHostServer) NotifyCodespaceOfClientActivity(context.Context, *NotifyCodespaceOfClientActivityRequest) (*NotifyCodespaceOfClientActivityResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method NotifyCodespaceOfClientActivity not implemented")
|
||||
}
|
||||
func (UnimplementedCodespaceHostServer) RebuildContainerAsync(context.Context, *RebuildContainerRequest) (*RebuildContainerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method RebuildContainerAsync not implemented")
|
||||
}
|
||||
|
|
@ -70,6 +84,24 @@ func RegisterCodespaceHostServer(s grpc.ServiceRegistrar, srv CodespaceHostServe
|
|||
s.RegisterService(&CodespaceHost_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _CodespaceHost_NotifyCodespaceOfClientActivity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(NotifyCodespaceOfClientActivityRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CodespaceHostServer).NotifyCodespaceOfClientActivity(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: "/Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost/NotifyCodespaceOfClientActivity",
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CodespaceHostServer).NotifyCodespaceOfClientActivity(ctx, req.(*NotifyCodespaceOfClientActivityRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CodespaceHost_RebuildContainerAsync_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(RebuildContainerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
|
|
@ -95,6 +127,10 @@ var CodespaceHost_ServiceDesc = grpc.ServiceDesc{
|
|||
ServiceName: "Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost",
|
||||
HandlerType: (*CodespaceHostServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "NotifyCodespaceOfClientActivity",
|
||||
Handler: _CodespaceHost_NotifyCodespaceOfClientActivity_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "RebuildContainerAsync",
|
||||
Handler: _CodespaceHost_RebuildContainerAsync_Handler,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ const (
|
|||
const (
|
||||
codespacesInternalPort = 16634
|
||||
codespacesInternalSessionName = "CodespacesInternal"
|
||||
clientName = "gh"
|
||||
connectedEventName = "connected"
|
||||
)
|
||||
|
||||
type StartSSHServerOptions struct {
|
||||
|
|
@ -128,6 +130,9 @@ func connect(ctx context.Context, session liveshare.LiveshareSession) (Invoker,
|
|||
invoker.codespaceClient = codespace.NewCodespaceHostClient(conn)
|
||||
invoker.sshClient = ssh.NewSshServerHostClient(conn)
|
||||
|
||||
// Start the activity heatbeats
|
||||
go invoker.heartbeat(pfctx, 1*time.Minute)
|
||||
|
||||
return invoker, nil
|
||||
}
|
||||
|
||||
|
|
@ -242,3 +247,35 @@ func listenTCP() (*net.TCPListener, error) {
|
|||
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
// Periodically check whether there is a reason to keep the connection alive, and if so, notify the codespace to do so
|
||||
func (i *invoker) heartbeat(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
// Send initial connection heartbeat (no need to throw if we fail to get a response from the server)
|
||||
_ = i.notifyCodespaceOfClientActivity(ctx, connectedEventName)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
reason := i.session.GetKeepAliveReason()
|
||||
_ = i.notifyCodespaceOfClientActivity(ctx, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *invoker) notifyCodespaceOfClientActivity(ctx context.Context, activity string) error {
|
||||
ctx = i.appendMetadata(ctx)
|
||||
ctx, cancel := context.WithTimeout(ctx, requestTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := i.codespaceClient.NotifyCodespaceOfClientActivity(ctx, &codespace.NotifyCodespaceOfClientActivityRequest{ClientId: clientName, ClientActivities: []string{activity}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to invoke notify RPC: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,18 +35,29 @@ func startServer(t *testing.T) {
|
|||
func createTestInvoker(t *testing.T) Invoker {
|
||||
t.Helper()
|
||||
|
||||
// Clear the stored client activity
|
||||
rpctest.NotifyReceivedActivity = ""
|
||||
|
||||
invoker, err := CreateInvoker(context.Background(), &rpctest.Session{})
|
||||
if err != nil {
|
||||
t.Fatalf("error connecting to internal server: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
testNotifyCodespaceOfClientActivity(t)
|
||||
invoker.Close()
|
||||
})
|
||||
|
||||
return invoker
|
||||
}
|
||||
|
||||
// Test that the RPC invoker notifies the codespace of client activity on connection
|
||||
func testNotifyCodespaceOfClientActivity(t *testing.T) {
|
||||
if rpctest.NotifyReceivedActivity != connectedEventName {
|
||||
t.Fatalf("expected %s, got %s", connectedEventName, rpctest.NotifyMessage)
|
||||
}
|
||||
}
|
||||
|
||||
// Test that the RPC invoker returns the correct port and URL when the JupyterLab server starts successfully
|
||||
func TestStartJupyterServerSuccess(t *testing.T) {
|
||||
startServer(t)
|
||||
|
|
|
|||
|
|
@ -29,6 +29,14 @@ var (
|
|||
RebuildContainer = true
|
||||
)
|
||||
|
||||
// Mock responses for the `NotifyCodespaceOfClientActivity` RPC method
|
||||
// NotifyMessage is used to store the activity that was sent to the server
|
||||
var (
|
||||
NotifyMessage = ""
|
||||
NotifyResult = true
|
||||
NotifyReceivedActivity = ""
|
||||
)
|
||||
|
||||
// Mock responses for the `StartRemoteServerAsync` RPC method
|
||||
var (
|
||||
SshServerPort = 1234
|
||||
|
|
@ -58,6 +66,18 @@ func (s *server) RebuildContainerAsync(ctx context.Context, in *codespace.Rebuil
|
|||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) NotifyCodespaceOfClientActivity(ctx context.Context, in *codespace.NotifyCodespaceOfClientActivityRequest) (*codespace.NotifyCodespaceOfClientActivityResponse, error) {
|
||||
// If there is at least one client activity, set NotifyReceivedActivity to the first one (should be "connected")
|
||||
if len(in.GetClientActivities()) > 0 {
|
||||
NotifyReceivedActivity = in.GetClientActivities()[0]
|
||||
}
|
||||
|
||||
return &codespace.NotifyCodespaceOfClientActivityResponse{
|
||||
Message: NotifyMessage,
|
||||
Result: NotifyResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *server) StartRemoteServerAsync(ctx context.Context, in *ssh.StartRemoteServerRequest) (*ssh.StartRemoteServerResponse, error) {
|
||||
return &ssh.StartRemoteServerResponse{
|
||||
ServerPort: strconv.Itoa(SshServerPort),
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ func (*Session) GetSharedServers(context.Context) ([]*liveshare.Port, error) {
|
|||
func (s *Session) KeepAlive(reason string) {
|
||||
}
|
||||
|
||||
func (s *Session) GetKeepAliveReason() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) {
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -106,18 +106,36 @@ type commandStub struct {
|
|||
callbacks []CommandCallback
|
||||
}
|
||||
|
||||
type errWithExitCode struct {
|
||||
message string
|
||||
exitCode int
|
||||
}
|
||||
|
||||
func (e errWithExitCode) Error() string {
|
||||
return e.message
|
||||
}
|
||||
|
||||
func (e errWithExitCode) ExitCode() int {
|
||||
return e.exitCode
|
||||
}
|
||||
|
||||
// Run satisfies Runnable
|
||||
func (s *commandStub) Run() error {
|
||||
if s.exitStatus != 0 {
|
||||
return fmt.Errorf("%s exited with status %d", s.pattern, s.exitStatus)
|
||||
// It's nontrivial to construct a fake `exec.ExitError` instance, so we return an error type
|
||||
// that has the `ExitCode() int` method.
|
||||
return errWithExitCode{
|
||||
message: fmt.Sprintf("%s exited with status %d", s.pattern, s.exitStatus),
|
||||
exitCode: s.exitStatus,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Output satisfies Runnable
|
||||
func (s *commandStub) Output() ([]byte, error) {
|
||||
if s.exitStatus != 0 {
|
||||
return []byte(nil), fmt.Errorf("%s exited with status %d", s.pattern, s.exitStatus)
|
||||
if err := s.Run(); err != nil {
|
||||
return []byte(nil), err
|
||||
}
|
||||
return []byte(s.stdout), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,11 +24,12 @@ func (t *TablePrinter) HeaderRow(columns ...string) {
|
|||
t.EndRow()
|
||||
}
|
||||
|
||||
func (tp *TablePrinter) AddTimeField(t time.Time, c func(string) string) {
|
||||
// In tty mode display the fuzzy time difference between now and t.
|
||||
// In nontty mode just display t with the time.RFC3339 format.
|
||||
func (tp *TablePrinter) AddTimeField(now, t time.Time, c func(string) string) {
|
||||
tf := t.Format(time.RFC3339)
|
||||
if tp.isTTY {
|
||||
// TODO: use a static time.Now
|
||||
tf = text.FuzzyAgo(time.Now(), t)
|
||||
tf = text.FuzzyAgo(now, t)
|
||||
}
|
||||
tp.AddField(tf, tableprinter.WithColor(c))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,9 +52,8 @@ func tokenRun(opts *TokenOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
key := "oauth_token"
|
||||
val, err := cfg.GetOrDefault(hostname, key)
|
||||
if err != nil {
|
||||
val, _ := cfg.AuthToken(hostname)
|
||||
if val == "" {
|
||||
return fmt.Errorf("no oauth token")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -217,6 +217,24 @@ func Test_createRun(t *testing.T) {
|
|||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/2" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/2" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }`))
|
||||
},
|
||||
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=&projects=OWNER%2FREPO%2F1",
|
||||
wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n",
|
||||
|
|
@ -612,6 +630,22 @@ func TestIssueCreate_metadata(t *testing.T) {
|
|||
}]
|
||||
}
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
|
|
@ -625,12 +659,9 @@ func TestIssueCreate_metadata(t *testing.T) {
|
|||
assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
|
||||
assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
|
||||
assert.Equal(t, "BIGONEID", inputs["milestoneId"])
|
||||
if v, ok := inputs["userIds"]; ok {
|
||||
t.Errorf("did not expect userIds: %v", v)
|
||||
}
|
||||
if v, ok := inputs["teamIds"]; ok {
|
||||
t.Errorf("did not expect teamIds: %v", v)
|
||||
}
|
||||
assert.NotContains(t, inputs, "userIds")
|
||||
assert.NotContains(t, inputs, "teamIds")
|
||||
assert.NotContains(t, inputs, "projectV2Ids")
|
||||
}))
|
||||
|
||||
output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`, nil)
|
||||
|
|
@ -712,3 +743,81 @@ func TestIssueCreate_AtMeAssignee(t *testing.T) {
|
|||
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
||||
}
|
||||
|
||||
func TestIssueCreate_projectsV2(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
defer http.Verify(t)
|
||||
|
||||
http.StubRepoInfoResponse("OWNER", "REPO", "main")
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projects": {
|
||||
"nodes": [],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projects": {
|
||||
"nodes": [],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
|
||||
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "TriageV2", "id": "TriageV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createIssue": { "issue": {
|
||||
"id": "Issue#1",
|
||||
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||
} } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "TITLE", inputs["title"])
|
||||
assert.Equal(t, "BODY", inputs["body"])
|
||||
assert.Nil(t, inputs["projectIds"])
|
||||
assert.NotContains(t, inputs, "projectV2Ids")
|
||||
}))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{ "data": { "add_000": { "item": {
|
||||
"id": "1"
|
||||
} } } }
|
||||
`, func(mutations string, inputs map[string]interface{}) {
|
||||
variables, err := json.Marshal(inputs)
|
||||
assert.NoError(t, err)
|
||||
expectedMutations := "mutation UpdateProjectV2Items($input_000: AddProjectV2ItemByIdInput!) {add_000: addProjectV2ItemById(input: $input_000) { item { id } }}"
|
||||
expectedVariables := `{"input_000":{"contentId":"Issue#1","projectId":"ROADMAPV2ID"}}`
|
||||
assert.Equal(t, expectedMutations, mutations)
|
||||
assert.Equal(t, expectedVariables, string(variables))
|
||||
}))
|
||||
|
||||
output, err := runCommand(http, true, `-t TITLE -b BODY -p roadmapv2`, nil)
|
||||
if err != nil {
|
||||
t.Errorf("error running command `issue create`: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,7 @@ func editRun(opts *EditOptions) error {
|
|||
}
|
||||
if opts.Interactive || editable.Projects.Edited {
|
||||
lookupFields = append(lookupFields, "projectCards")
|
||||
lookupFields = append(lookupFields, "projectItems")
|
||||
}
|
||||
if opts.Interactive || editable.Milestone.Edited {
|
||||
lookupFields = append(lookupFields, "milestone")
|
||||
|
|
@ -159,7 +160,12 @@ func editRun(opts *EditOptions) error {
|
|||
editable.Body.Default = issue.Body
|
||||
editable.Assignees.Default = issue.Assignees.Logins()
|
||||
editable.Labels.Default = issue.Labels.Names()
|
||||
editable.Projects.Default = issue.ProjectCards.ProjectNames()
|
||||
editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...)
|
||||
projectItems := map[string]string{}
|
||||
for _, n := range issue.ProjectItems.Nodes {
|
||||
projectItems[n.Project.ID] = n.ID
|
||||
}
|
||||
editable.Projects.ProjectItems = projectItems
|
||||
if issue.Milestone != nil {
|
||||
editable.Milestone.Default = issue.Milestone.Title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -165,9 +165,11 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
Editable: prShared.Editable{
|
||||
Projects: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -179,9 +181,11 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
Editable: prShared.Editable{
|
||||
Projects: prShared.EditableSlice{
|
||||
Remove: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
Remove: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -278,10 +282,12 @@ func Test_editRun(t *testing.T) {
|
|||
Remove: []string{"docs"},
|
||||
Edited: true,
|
||||
},
|
||||
Projects: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "Roadmap"},
|
||||
Remove: []string{"Features"},
|
||||
Edited: true,
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "RoadmapV2"},
|
||||
Remove: []string{"Roadmap", "CleanupV2"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
|
|
@ -297,9 +303,11 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
mockIssueGet(t, reg)
|
||||
mockIssueProjectItemsGet(t, reg)
|
||||
mockRepoMetadata(t, reg)
|
||||
mockIssueUpdate(t, reg)
|
||||
mockIssueUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
||||
},
|
||||
|
|
@ -322,7 +330,9 @@ func Test_editRun(t *testing.T) {
|
|||
eo.Body.Value = "new body"
|
||||
eo.Assignees.Value = []string{"monalisa", "hubot"}
|
||||
eo.Labels.Value = []string{"feature", "TODO", "bug"}
|
||||
eo.Projects.Value = []string{"Cleanup", "Roadmap"}
|
||||
eo.Labels.Add = []string{"feature", "TODO", "bug"}
|
||||
eo.Labels.Remove = []string{"docs"}
|
||||
eo.Projects.Value = []string{"Cleanup", "RoadmapV2"}
|
||||
eo.Milestone.Value = "GA"
|
||||
return nil
|
||||
},
|
||||
|
|
@ -331,8 +341,11 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
mockIssueGet(t, reg)
|
||||
mockIssueProjectItemsGet(t, reg)
|
||||
mockRepoMetadata(t, reg)
|
||||
mockIssueUpdate(t, reg)
|
||||
mockIssueUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
||||
},
|
||||
|
|
@ -369,7 +382,31 @@ func mockIssueGet(_ *testing.T, reg *httpmock.Registry) {
|
|||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
|
||||
"number": 123,
|
||||
"url": "https://github.com/OWNER/REPO/issue/123"
|
||||
"url": "https://github.com/OWNER/REPO/issue/123",
|
||||
"labels": {
|
||||
"nodes": [
|
||||
{ "id": "DOCSID", "name": "docs" }
|
||||
], "totalCount": 1
|
||||
},
|
||||
"projectCards": {
|
||||
"nodes": [
|
||||
{ "project": { "name": "Roadmap" } }
|
||||
], "totalCount": 1
|
||||
}
|
||||
} } } }`),
|
||||
)
|
||||
}
|
||||
|
||||
func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueProjectItems\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "issue": {
|
||||
"projectItems": {
|
||||
"nodes": [
|
||||
{ "id": "ITEMID", "project": { "title": "CleanupV2" } }
|
||||
]
|
||||
}
|
||||
} } } }`),
|
||||
)
|
||||
}
|
||||
|
|
@ -431,6 +468,27 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
|
|||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
|
||||
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "TriageV2", "id": "TRIAGEV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
}
|
||||
|
||||
func mockIssueUpdate(t *testing.T, reg *httpmock.Registry) {
|
||||
|
|
@ -456,3 +514,12 @@ func mockIssueUpdateLabels(t *testing.T, reg *httpmock.Registry) {
|
|||
func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
}
|
||||
|
||||
func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "add_000": { "item": { "id": "1" } }, "delete_001": { "item": { "id": "2" } } } }`,
|
||||
func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f
|
|||
fieldSet.Remove("stateReason")
|
||||
}
|
||||
}
|
||||
|
||||
var getProjectItems bool
|
||||
if fieldSet.Contains("projectItems") {
|
||||
getProjectItems = true
|
||||
fieldSet.Remove("projectItems")
|
||||
}
|
||||
|
||||
fields = fieldSet.ToSlice()
|
||||
|
||||
type response struct {
|
||||
|
|
@ -151,5 +158,13 @@ func findIssueOrPR(httpClient *http.Client, repo ghrepo.Interface, number int, f
|
|||
return nil, errors.New("issue was not found but GraphQL reported no error")
|
||||
}
|
||||
|
||||
if getProjectItems {
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
err := api.ProjectsV2ItemsForIssue(apiClient, repo, resp.Repository.Issue)
|
||||
if err != nil && !api.ProjectsV2IgnorableError(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return resp.Repository.Issue, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,6 +280,94 @@ func Test_createRun(t *testing.T) {
|
|||
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
|
||||
expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
|
||||
},
|
||||
{
|
||||
name: "project v2",
|
||||
tty: true,
|
||||
setup: func(opts *CreateOptions, t *testing.T) func() {
|
||||
opts.TitleProvided = true
|
||||
opts.BodyProvided = true
|
||||
opts.Title = "my title"
|
||||
opts.Body = "my body"
|
||||
opts.Projects = []string{"RoadmapV2"}
|
||||
return func() {}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.StubRepoResponse("OWNER", "REPO")
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectList\b`),
|
||||
httpmock.StringResponse(`{ "data": { "repository": { "projects": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } }`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||
httpmock.StringResponse(`{ "data": { "organization": { "projects": { "nodes": [], "pageInfo": { "hasNextPage": false } } } } }`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
|
||||
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation PullRequestCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createPullRequest": { "pullRequest": {
|
||||
"id": "PullRequest#1",
|
||||
"URL": "https://github.com/OWNER/REPO/pull/12"
|
||||
} } } }
|
||||
`, func(input map[string]interface{}) {
|
||||
assert.Equal(t, "REPOID", input["repositoryId"].(string))
|
||||
assert.Equal(t, "my title", input["title"].(string))
|
||||
assert.Equal(t, "my body", input["body"].(string))
|
||||
assert.Equal(t, "master", input["baseRefName"].(string))
|
||||
assert.Equal(t, "feature", input["headRefName"].(string))
|
||||
assert.Equal(t, false, input["draft"].(bool))
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
||||
httpmock.GraphQLQuery(`
|
||||
{ "data": { "add_000": { "item": {
|
||||
"id": "1"
|
||||
} } } }
|
||||
`, func(mutations string, inputs map[string]interface{}) {
|
||||
variables, err := json.Marshal(inputs)
|
||||
assert.NoError(t, err)
|
||||
expectedMutations := "mutation UpdateProjectV2Items($input_000: AddProjectV2ItemByIdInput!) {add_000: addProjectV2ItemById(input: $input_000) { item { id } }}"
|
||||
expectedVariables := `{"input_000":{"contentId":"PullRequest#1","projectId":"ROADMAPV2ID"}}`
|
||||
assert.Equal(t, expectedMutations, mutations)
|
||||
assert.Equal(t, expectedVariables, string(variables))
|
||||
}))
|
||||
},
|
||||
cmdStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git config --get-regexp.+branch\\\.feature\\\.`, 0, "")
|
||||
cs.Register(`git show-ref --verify -- HEAD refs/remotes/origin/feature`, 0, "")
|
||||
cs.Register(`git push --set-upstream origin HEAD:feature`, 0, "")
|
||||
},
|
||||
promptStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
|
||||
if p == "Where should we push the 'feature' branch?" {
|
||||
return 0, nil
|
||||
} else {
|
||||
return -1, prompter.NoSuchPromptErr(p)
|
||||
}
|
||||
}
|
||||
},
|
||||
expectedOut: "https://github.com/OWNER/REPO/pull/12\n",
|
||||
expectedErrOut: "\nCreating pull request for feature into master in OWNER/REPO\n\n",
|
||||
},
|
||||
{
|
||||
name: "no maintainer modify",
|
||||
tty: true,
|
||||
|
|
@ -575,6 +663,17 @@ func Test_createRun(t *testing.T) {
|
|||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
|
||||
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||
httpmock.StringResponse(`
|
||||
|
|
@ -583,6 +682,14 @@ func Test_createRun(t *testing.T) {
|
|||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation PullRequestCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
|
|
@ -696,6 +803,16 @@ func Test_createRun(t *testing.T) {
|
|||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID", "resourcePath": "/OWNER/REPO/projects/2" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||
|
|
@ -706,6 +823,16 @@ func Test_createRun(t *testing.T) {
|
|||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "TriageV2", "id": "TRIAGEV2ID", "resourcePath": "/orgs/ORG/projects/2" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
},
|
||||
cmdStubs: func(cs *run.CommandStubber) {
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
func editRun(opts *EditOptions) error {
|
||||
findOptions := shared.FindOptions{
|
||||
Selector: opts.SelectorArg,
|
||||
Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "milestone"},
|
||||
Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "projectItems", "milestone"},
|
||||
}
|
||||
pr, repo, err := opts.Finder.Find(findOptions)
|
||||
if err != nil {
|
||||
|
|
@ -166,7 +166,12 @@ func editRun(opts *EditOptions) error {
|
|||
editable.Reviewers.Default = pr.ReviewRequests.Logins()
|
||||
editable.Assignees.Default = pr.Assignees.Logins()
|
||||
editable.Labels.Default = pr.Labels.Names()
|
||||
editable.Projects.Default = pr.ProjectCards.ProjectNames()
|
||||
editable.Projects.Default = append(pr.ProjectCards.ProjectNames(), pr.ProjectItems.ProjectTitles()...)
|
||||
projectItems := map[string]string{}
|
||||
for _, n := range pr.ProjectItems.Nodes {
|
||||
projectItems[n.Project.ID] = n.ID
|
||||
}
|
||||
editable.Projects.ProjectItems = projectItems
|
||||
if pr.Milestone != nil {
|
||||
editable.Milestone.Default = pr.Milestone.Title
|
||||
}
|
||||
|
|
|
|||
|
|
@ -216,9 +216,11 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
Editable: shared.Editable{
|
||||
Projects: shared.EditableSlice{
|
||||
Add: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
Projects: shared.EditableProjects{
|
||||
EditableSlice: shared.EditableSlice{
|
||||
Add: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -230,9 +232,11 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
Editable: shared.Editable{
|
||||
Projects: shared.EditableSlice{
|
||||
Remove: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
Projects: shared.EditableProjects{
|
||||
EditableSlice: shared.EditableSlice{
|
||||
Remove: []string{"Cleanup", "Roadmap"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -341,10 +345,12 @@ func Test_editRun(t *testing.T) {
|
|||
Remove: []string{"docs"},
|
||||
Edited: true,
|
||||
},
|
||||
Projects: shared.EditableSlice{
|
||||
Add: []string{"Cleanup", "Roadmap"},
|
||||
Remove: []string{"Features"},
|
||||
Edited: true,
|
||||
Projects: shared.EditableProjects{
|
||||
EditableSlice: shared.EditableSlice{
|
||||
Add: []string{"Cleanup", "RoadmapV2"},
|
||||
Remove: []string{"CleanupV2", "Roadmap"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
Milestone: shared.EditableString{
|
||||
Value: "GA",
|
||||
|
|
@ -358,6 +364,7 @@ func Test_editRun(t *testing.T) {
|
|||
mockPullRequestUpdate(t, reg)
|
||||
mockPullRequestReviewersUpdate(t, reg)
|
||||
mockPullRequestUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: "https://github.com/OWNER/REPO/pull/123\n",
|
||||
},
|
||||
|
|
@ -392,10 +399,12 @@ func Test_editRun(t *testing.T) {
|
|||
Remove: []string{"docs"},
|
||||
Edited: true,
|
||||
},
|
||||
Projects: shared.EditableSlice{
|
||||
Value: []string{"Cleanup", "Roadmap"},
|
||||
Remove: []string{"Features"},
|
||||
Edited: true,
|
||||
Projects: shared.EditableProjects{
|
||||
EditableSlice: shared.EditableSlice{
|
||||
Add: []string{"Cleanup", "RoadmapV2"},
|
||||
Remove: []string{"CleanupV2", "Roadmap"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
Milestone: shared.EditableString{
|
||||
Value: "GA",
|
||||
|
|
@ -408,6 +417,7 @@ func Test_editRun(t *testing.T) {
|
|||
mockRepoMetadata(t, reg, true)
|
||||
mockPullRequestUpdate(t, reg)
|
||||
mockPullRequestUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: "https://github.com/OWNER/REPO/pull/123\n",
|
||||
},
|
||||
|
|
@ -427,6 +437,8 @@ func Test_editRun(t *testing.T) {
|
|||
mockRepoMetadata(t, reg, false)
|
||||
mockPullRequestUpdate(t, reg)
|
||||
mockPullRequestReviewersUpdate(t, reg)
|
||||
mockPullRequestUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: "https://github.com/OWNER/REPO/pull/123\n",
|
||||
},
|
||||
|
|
@ -445,6 +457,8 @@ func Test_editRun(t *testing.T) {
|
|||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
mockRepoMetadata(t, reg, true)
|
||||
mockPullRequestUpdate(t, reg)
|
||||
mockPullRequestUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: "https://github.com/OWNER/REPO/pull/123\n",
|
||||
},
|
||||
|
|
@ -465,6 +479,7 @@ func Test_editRun(t *testing.T) {
|
|||
tt.input.HttpClient = httpClient
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fmt.Println(tt.name)
|
||||
err := editRun(tt.input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.stdout, stdout.String())
|
||||
|
|
@ -530,6 +545,27 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool)
|
|||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "CleanupV2", "id": "CLEANUPV2ID" },
|
||||
{ "title": "RoadmapV2", "id": "ROADMAPV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "organization": { "projectsV2": {
|
||||
"nodes": [
|
||||
{ "title": "TriageV2", "id": "TRIAGEV2ID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
if !skipReviewers {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationTeamList\b`),
|
||||
|
|
@ -577,6 +613,15 @@ func mockPullRequestUpdateLabels(t *testing.T, reg *httpmock.Registry) {
|
|||
)
|
||||
}
|
||||
|
||||
func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation UpdateProjectV2Items\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "add_000": { "item": { "id": "1" } }, "delete_001": { "item": { "id": "2" } } } }`,
|
||||
func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
}
|
||||
|
||||
type testFetcher struct{}
|
||||
type testSurveyor struct {
|
||||
skipReviewers bool
|
||||
|
|
@ -608,7 +653,11 @@ func (s testSurveyor) EditFields(e *shared.Editable, _ string) error {
|
|||
}
|
||||
e.Assignees.Value = []string{"monalisa", "hubot"}
|
||||
e.Labels.Value = []string{"feature", "TODO", "bug"}
|
||||
e.Projects.Value = []string{"Cleanup", "Roadmap"}
|
||||
e.Labels.Add = []string{"feature", "TODO", "bug"}
|
||||
e.Labels.Remove = []string{"docs"}
|
||||
e.Projects.Value = []string{"Cleanup", "RoadmapV2"}
|
||||
e.Projects.Add = []string{"Cleanup", "RoadmapV2"}
|
||||
e.Projects.Remove = []string{"CleanupV2", "Roadmap"}
|
||||
e.Milestone.Value = "GA"
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ type Editable struct {
|
|||
Reviewers EditableSlice
|
||||
Assignees EditableSlice
|
||||
Labels EditableSlice
|
||||
Projects EditableSlice
|
||||
Projects EditableProjects
|
||||
Milestone EditableString
|
||||
Metadata api.RepoMetadataResult
|
||||
}
|
||||
|
|
@ -40,6 +40,13 @@ type EditableSlice struct {
|
|||
Allowed bool
|
||||
}
|
||||
|
||||
// ProjectsV2 mutations require a mapping of an item ID to a project ID.
|
||||
// Keep that map along with standard EditableSlice data.
|
||||
type EditableProjects struct {
|
||||
EditableSlice
|
||||
ProjectItems map[string]string
|
||||
}
|
||||
|
||||
func (e Editable) Dirty() bool {
|
||||
return e.Title.Edited ||
|
||||
e.Body.Edited ||
|
||||
|
|
@ -120,6 +127,7 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str
|
|||
return &a, err
|
||||
}
|
||||
|
||||
// ProjectIds returns a slice containing IDs of projects v1 that the issue or a PR has to be linked to.
|
||||
func (e Editable) ProjectIds() (*[]string, error) {
|
||||
if !e.Projects.Edited {
|
||||
return nil, nil
|
||||
|
|
@ -131,10 +139,53 @@ func (e Editable) ProjectIds() (*[]string, error) {
|
|||
s.RemoveValues(e.Projects.Remove)
|
||||
e.Projects.Value = s.ToSlice()
|
||||
}
|
||||
p, err := e.Metadata.ProjectsToIDs(e.Projects.Value)
|
||||
p, _, err := e.Metadata.ProjectsToIDs(e.Projects.Value)
|
||||
return &p, err
|
||||
}
|
||||
|
||||
// ProjectV2Ids returns a pair of slices.
|
||||
// The first is the projects the item should be added to.
|
||||
// The second is the projects the items should be removed from.
|
||||
func (e Editable) ProjectV2Ids() (*[]string, *[]string, error) {
|
||||
if !e.Projects.Edited {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
// titles of projects to add
|
||||
addTitles := set.NewStringSet()
|
||||
addTitles.AddValues(e.Projects.Value)
|
||||
addTitles.AddValues(e.Projects.Add)
|
||||
addTitles.RemoveValues(e.Projects.Default)
|
||||
addTitles.RemoveValues(e.Projects.Remove)
|
||||
|
||||
// titles of projects to remove
|
||||
removeTitles := set.NewStringSet()
|
||||
removeTitles.AddValues(e.Projects.Default)
|
||||
removeTitles.AddValues(e.Projects.Remove)
|
||||
removeTitles.RemoveValues(e.Projects.Value)
|
||||
removeTitles.RemoveValues(e.Projects.Add)
|
||||
|
||||
var addIds []string
|
||||
var removeIds []string
|
||||
var err error
|
||||
|
||||
if addTitles.Len() > 0 {
|
||||
_, addIds, err = e.Metadata.ProjectsToIDs(addTitles.ToSlice())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if removeTitles.Len() > 0 {
|
||||
_, removeIds, err = e.Metadata.ProjectsToIDs(removeTitles.ToSlice())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &addIds, &removeIds, nil
|
||||
}
|
||||
|
||||
func (e Editable) MilestoneId() (*string, error) {
|
||||
if !e.Milestone.Edited {
|
||||
return nil, nil
|
||||
|
|
@ -285,8 +336,11 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable)
|
|||
labels = append(labels, l.Name)
|
||||
}
|
||||
var projects []string
|
||||
for _, l := range metadata.Projects {
|
||||
projects = append(projects, l.Name)
|
||||
for _, p := range metadata.Projects {
|
||||
projects = append(projects, p.Name)
|
||||
}
|
||||
for _, p := range metadata.ProjectsV2 {
|
||||
projects = append(projects, p.Title)
|
||||
}
|
||||
milestones := []string{noMilestone}
|
||||
for _, m := range metadata.Milestones {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,29 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR
|
|||
}
|
||||
}
|
||||
|
||||
// updateIssue mutation does not support ProjectsV2 so do them in a seperate request.
|
||||
if options.Projects.Edited {
|
||||
wg.Go(func() error {
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
addIds, removeIds, err := options.ProjectV2Ids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if addIds == nil && removeIds == nil {
|
||||
return nil
|
||||
}
|
||||
toAdd := make(map[string]string, len(*addIds))
|
||||
toRemove := make(map[string]string, len(*removeIds))
|
||||
for _, p := range *addIds {
|
||||
toAdd[p] = id
|
||||
}
|
||||
for _, p := range *removeIds {
|
||||
toRemove[p] = options.Projects.ProjectItems[p]
|
||||
}
|
||||
return api.UpdateProjectV2Items(apiClient, repo, toAdd, toRemove)
|
||||
})
|
||||
}
|
||||
|
||||
if dirtyExcludingLabels(options) {
|
||||
wg.Go(func() error {
|
||||
return replaceIssueFields(httpClient, repo, id, isPR, options)
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
|
|||
fields := set.NewStringSet()
|
||||
fields.AddValues(opts.Fields)
|
||||
numberFieldOnly := fields.Len() == 1 && fields.Contains("number")
|
||||
fields.Add("id") // for additional preload queries below
|
||||
fields.AddValues([]string{"id", "number"}) // for additional preload queries below
|
||||
|
||||
if fields.Contains("isInMergeQueue") || fields.Contains("isMergeQueueEnabled") {
|
||||
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
|
||||
|
|
@ -154,6 +154,12 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
|
|||
}
|
||||
}
|
||||
|
||||
var getProjectItems bool
|
||||
if fields.Contains("projectItems") {
|
||||
getProjectItems = true
|
||||
fields.Remove("projectItems")
|
||||
}
|
||||
|
||||
var pr *api.PullRequest
|
||||
if f.prNumber > 0 {
|
||||
if numberFieldOnly {
|
||||
|
|
@ -184,6 +190,16 @@ func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, err
|
|||
return preloadPrChecks(httpClient, f.repo, pr)
|
||||
})
|
||||
}
|
||||
if getProjectItems {
|
||||
g.Go(func() error {
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
err := api.ProjectsV2ItemsForPullRequest(apiClient, f.repo, pr)
|
||||
if err != nil && !api.ProjectsV2IgnorableError(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
return pr, f.repo, g.Wait()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,11 +109,12 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par
|
|||
}
|
||||
params["labelIds"] = labelIDs
|
||||
|
||||
projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects)
|
||||
projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not add to project: %w", err)
|
||||
}
|
||||
params["projectIds"] = projectIDs
|
||||
params["projectV2Ids"] = projectV2IDs
|
||||
|
||||
if len(tb.Milestones) > 0 {
|
||||
milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0])
|
||||
|
|
|
|||
|
|
@ -203,8 +203,11 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher
|
|||
labels = append(labels, l.Name)
|
||||
}
|
||||
var projects []string
|
||||
for _, l := range metadataResult.Projects {
|
||||
projects = append(projects, l.Name)
|
||||
for _, p := range metadataResult.Projects {
|
||||
projects = append(projects, p.Name)
|
||||
}
|
||||
for _, p := range metadataResult.ProjectsV2 {
|
||||
projects = append(projects, p.Title)
|
||||
}
|
||||
milestones := []string{noMilestone}
|
||||
for _, m := range metadataResult.Milestones {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package list
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
|
|
@ -107,7 +108,7 @@ func listRun(opts *ListOptions) error {
|
|||
if rel.PublishedAt.IsZero() {
|
||||
pubDate = rel.CreatedAt
|
||||
}
|
||||
table.AddTimeField(pubDate, iofmt.Gray)
|
||||
table.AddTimeField(time.Now(), pubDate, iofmt.Gray)
|
||||
table.EndRow()
|
||||
}
|
||||
err = table.Render()
|
||||
|
|
|
|||
|
|
@ -90,9 +90,9 @@ func NewCmdSetDefault(f *cmdutil.Factory, runF func(*SetDefaultOptions) error) *
|
|||
return cmdutil.FlagErrorf("repository required when not running interactively")
|
||||
}
|
||||
|
||||
c := &git.Client{}
|
||||
|
||||
if !c.InGitDirectory(ctx.Background()) {
|
||||
if isLocal, err := opts.GitClient.IsLocalGitRepo(cmd.Context()); err != nil {
|
||||
return err
|
||||
} else if !isLocal {
|
||||
return errors.New("must be run from inside a git repository")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ func TestNewCmdSetDefault(t *testing.T) {
|
|||
{
|
||||
name: "no argument",
|
||||
gitStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git rev-parse --is-inside-work-tree`, 0, "true")
|
||||
cs.Register(`git rev-parse --git-dir`, 0, ".git")
|
||||
},
|
||||
input: "",
|
||||
output: SetDefaultOptions{},
|
||||
|
|
@ -37,7 +37,7 @@ func TestNewCmdSetDefault(t *testing.T) {
|
|||
{
|
||||
name: "repo argument",
|
||||
gitStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git rev-parse --is-inside-work-tree`, 0, "true")
|
||||
cs.Register(`git rev-parse --git-dir`, 0, ".git")
|
||||
},
|
||||
input: "cli/cli",
|
||||
output: SetDefaultOptions{Repo: ghrepo.New("cli", "cli")},
|
||||
|
|
@ -52,7 +52,7 @@ func TestNewCmdSetDefault(t *testing.T) {
|
|||
{
|
||||
name: "view flag",
|
||||
gitStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git rev-parse --is-inside-work-tree`, 0, "true")
|
||||
cs.Register(`git rev-parse --git-dir`, 0, ".git")
|
||||
},
|
||||
input: "--view",
|
||||
output: SetDefaultOptions{ViewMode: true},
|
||||
|
|
@ -60,7 +60,7 @@ func TestNewCmdSetDefault(t *testing.T) {
|
|||
{
|
||||
name: "unset flag",
|
||||
gitStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git rev-parse --is-inside-work-tree`, 0, "true")
|
||||
cs.Register(`git rev-parse --git-dir`, 0, ".git")
|
||||
},
|
||||
input: "--unset",
|
||||
output: SetDefaultOptions{UnsetMode: true},
|
||||
|
|
@ -68,7 +68,7 @@ func TestNewCmdSetDefault(t *testing.T) {
|
|||
{
|
||||
name: "run from non-git directory",
|
||||
gitStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git rev-parse --is-inside-work-tree`, 1, "")
|
||||
cs.Register(`git rev-parse --git-dir`, 128, "")
|
||||
},
|
||||
input: "",
|
||||
wantErr: true,
|
||||
|
|
@ -83,6 +83,7 @@ func TestNewCmdSetDefault(t *testing.T) {
|
|||
io.SetStderrTTY(true)
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
GitClient: &git.Client{GitPath: "/fake/path/to/git"},
|
||||
}
|
||||
|
||||
var gotOpts *SetDefaultOptions
|
||||
|
|
|
|||
173
pkg/cmd/search/commits/commits.go
Normal file
173
pkg/cmd/search/commits/commits.go
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
package commits
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/search/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/search"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type CommitsOptions struct {
|
||||
Browser browser.Browser
|
||||
Exporter cmdutil.Exporter
|
||||
IO *iostreams.IOStreams
|
||||
Now time.Time
|
||||
Query search.Query
|
||||
Searcher search.Searcher
|
||||
WebMode bool
|
||||
}
|
||||
|
||||
func NewCmdCommits(f *cmdutil.Factory, runF func(*CommitsOptions) error) *cobra.Command {
|
||||
var order string
|
||||
var sort string
|
||||
opts := &CommitsOptions{
|
||||
Browser: f.Browser,
|
||||
IO: f.IOStreams,
|
||||
Query: search.Query{Kind: search.KindCommits},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "commits [<query>]",
|
||||
Short: "Search for commits",
|
||||
Long: heredoc.Doc(`
|
||||
Search for commits on GitHub.
|
||||
|
||||
The command supports constructing queries using the GitHub search syntax,
|
||||
using the parameter and qualifier flags, or a combination of the two.
|
||||
|
||||
GitHub search syntax is documented at:
|
||||
<https://docs.github.com/search-github/searching-on-github/searching-commits>
|
||||
`),
|
||||
Example: heredoc.Doc(`
|
||||
# search commits matching set of keywords "readme" and "typo"
|
||||
$ gh search commits readme typo
|
||||
|
||||
# search commits matching phrase "bug fix"
|
||||
$ gh search commits "bug fix"
|
||||
|
||||
# search commits committed by user "monalisa"
|
||||
$ gh search commits --committer=monalisa
|
||||
|
||||
# search commits authored by users with name "Jane Doe"
|
||||
$ gh search commits --author-name="Jane Doe"
|
||||
|
||||
# search commits matching hash "8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3"
|
||||
$ gh search commits --hash=8dd03144ffdc6c0d486d6b705f9c7fba871ee7c3
|
||||
|
||||
# search commits authored before February 1st, 2022
|
||||
$ gh search commits --author-date="<2022-02-01"
|
||||
`),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
if len(args) == 0 && c.Flags().NFlag() == 0 {
|
||||
return cmdutil.FlagErrorf("specify search keywords or flags")
|
||||
}
|
||||
if opts.Query.Limit < 1 || opts.Query.Limit > shared.SearchMaxResults {
|
||||
return cmdutil.FlagErrorf("`--limit` must be between 1 and 1000")
|
||||
}
|
||||
if c.Flags().Changed("order") {
|
||||
opts.Query.Order = order
|
||||
}
|
||||
if c.Flags().Changed("sort") {
|
||||
opts.Query.Sort = sort
|
||||
}
|
||||
opts.Query.Keywords = args
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
var err error
|
||||
opts.Searcher, err = shared.Searcher(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return commitsRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
// Output flags
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, search.CommitFields)
|
||||
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the search query in the web browser")
|
||||
|
||||
// Query parameter flags
|
||||
cmd.Flags().IntVarP(&opts.Query.Limit, "limit", "L", 30, "Maximum number of commits to fetch")
|
||||
cmdutil.StringEnumFlag(cmd, &order, "order", "", "desc", []string{"asc", "desc"}, "Order of commits returned, ignored unless '--sort' flag is specified")
|
||||
cmdutil.StringEnumFlag(cmd, &sort, "sort", "", "best-match", []string{"author-date", "committer-date"}, "Sort fetched commits")
|
||||
|
||||
// Query qualifier flags
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Author, "author", "", "Filter by author")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.AuthorDate, "author-date", "", "Filter based on authored `date`")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.AuthorEmail, "author-email", "", "Filter on author email")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.AuthorName, "author-name", "", "Filter on author name")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Committer, "committer", "", "Filter by committer")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.CommitterDate, "committer-date", "", "Filter based on committed `date`")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.CommitterEmail, "committer-email", "", "Filter on committer email")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.CommitterName, "committer-name", "", "Filter on committer name")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Hash, "hash", "", "Filter by commit hash")
|
||||
cmdutil.NilBoolFlag(cmd, &opts.Query.Qualifiers.Merge, "merge", "", "Filter on merge commits")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Parent, "parent", "", "Filter by parent hash")
|
||||
cmd.Flags().StringSliceVar(&opts.Query.Qualifiers.Repo, "repo", nil, "Filter on repository")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.Tree, "tree", "", "Filter by tree hash")
|
||||
cmd.Flags().StringVar(&opts.Query.Qualifiers.User, "owner", "", "Filter on repository owner")
|
||||
cmdutil.StringSliceEnumFlag(cmd, &opts.Query.Qualifiers.Is, "visibility", "", nil, []string{"public", "private", "internal"}, "Filter based on repository visibility")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func commitsRun(opts *CommitsOptions) error {
|
||||
io := opts.IO
|
||||
if opts.WebMode {
|
||||
url := opts.Searcher.URL(opts.Query)
|
||||
if io.IsStdoutTTY() {
|
||||
fmt.Fprintf(io.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(url))
|
||||
}
|
||||
return opts.Browser.Browse(url)
|
||||
}
|
||||
io.StartProgressIndicator()
|
||||
result, err := opts.Searcher.Commits(opts.Query)
|
||||
io.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(result.Items) == 0 && opts.Exporter == nil {
|
||||
return cmdutil.NewNoResultsError("no commits matched your search")
|
||||
}
|
||||
if err := io.StartPager(); err == nil {
|
||||
defer io.StopPager()
|
||||
} else {
|
||||
fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err)
|
||||
}
|
||||
if opts.Exporter != nil {
|
||||
return opts.Exporter.Write(io, result.Items)
|
||||
}
|
||||
|
||||
return displayResults(io, opts.Now, result)
|
||||
}
|
||||
|
||||
func displayResults(io *iostreams.IOStreams, now time.Time, results search.CommitsResult) error {
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
cs := io.ColorScheme()
|
||||
tp := tableprinter.New(io)
|
||||
tp.HeaderRow("Repo", "SHA", "Message", "Author", "Created")
|
||||
for _, commit := range results.Items {
|
||||
tp.AddField(commit.Repo.FullName)
|
||||
tp.AddField(commit.Sha)
|
||||
tp.AddField(text.RemoveExcessiveWhitespace(commit.Info.Message))
|
||||
tp.AddField(commit.Author.Login)
|
||||
tp.AddTimeField(now, commit.Info.Author.Date, cs.Gray)
|
||||
tp.EndRow()
|
||||
}
|
||||
if io.IsStdoutTTY() {
|
||||
header := fmt.Sprintf("Showing %d of %d commits\n\n", len(results.Items), results.Total)
|
||||
fmt.Fprintf(io.Out, "\n%s", header)
|
||||
}
|
||||
return tp.Render()
|
||||
}
|
||||
311
pkg/cmd/search/commits/commits_test.go
Normal file
311
pkg/cmd/search/commits/commits_test.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
package commits
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/search"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdCommits(t *testing.T) {
|
||||
var trueBool = true
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
output CommitsOptions
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
errMsg: "specify search keywords or flags",
|
||||
},
|
||||
{
|
||||
name: "keyword arguments",
|
||||
input: "some search terms",
|
||||
output: CommitsOptions{
|
||||
Query: search.Query{Keywords: []string{"some", "search", "terms"}, Kind: "commits", Limit: 30},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web flag",
|
||||
input: "--web",
|
||||
output: CommitsOptions{
|
||||
Query: search.Query{Keywords: []string{}, Kind: "commits", Limit: 30},
|
||||
WebMode: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "limit flag",
|
||||
input: "--limit 10",
|
||||
output: CommitsOptions{Query: search.Query{Keywords: []string{}, Kind: "commits", Limit: 10}},
|
||||
},
|
||||
{
|
||||
name: "invalid limit flag",
|
||||
input: "--limit 1001",
|
||||
wantErr: true,
|
||||
errMsg: "`--limit` must be between 1 and 1000",
|
||||
},
|
||||
{
|
||||
name: "order flag",
|
||||
input: "--order asc",
|
||||
output: CommitsOptions{
|
||||
Query: search.Query{Keywords: []string{}, Kind: "commits", Limit: 30, Order: "asc"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid order flag",
|
||||
input: "--order invalid",
|
||||
wantErr: true,
|
||||
errMsg: "invalid argument \"invalid\" for \"--order\" flag: valid values are {asc|desc}",
|
||||
},
|
||||
{
|
||||
name: "qualifier flags",
|
||||
input: `
|
||||
--author=foo
|
||||
--author-date=01-01-2000
|
||||
--author-email=foo@example.com
|
||||
--author-name=Foo
|
||||
--committer=bar
|
||||
--committer-date=01-02-2000
|
||||
--committer-email=bar@example.com
|
||||
--committer-name=Bar
|
||||
--hash=aaa
|
||||
--merge
|
||||
--parent=bbb
|
||||
--repo=owner/repo
|
||||
--tree=ccc
|
||||
--owner=owner
|
||||
--visibility=public
|
||||
`,
|
||||
output: CommitsOptions{
|
||||
Query: search.Query{
|
||||
Keywords: []string{},
|
||||
Kind: "commits",
|
||||
Limit: 30,
|
||||
Qualifiers: search.Qualifiers{
|
||||
Author: "foo",
|
||||
AuthorDate: "01-01-2000",
|
||||
AuthorEmail: "foo@example.com",
|
||||
AuthorName: "Foo",
|
||||
Committer: "bar",
|
||||
CommitterDate: "01-02-2000",
|
||||
CommitterEmail: "bar@example.com",
|
||||
CommitterName: "Bar",
|
||||
Hash: "aaa",
|
||||
Merge: &trueBool,
|
||||
Parent: "bbb",
|
||||
Repo: []string{"owner/repo"},
|
||||
Tree: "ccc",
|
||||
User: "owner",
|
||||
Is: []string{"public"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
}
|
||||
argv, err := shlex.Split(tt.input)
|
||||
assert.NoError(t, err)
|
||||
var gotOpts *CommitsOptions
|
||||
cmd := NewCmdCommits(f, func(opts *CommitsOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.output.Query, gotOpts.Query)
|
||||
assert.Equal(t, tt.output.WebMode, gotOpts.WebMode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommitsRun(t *testing.T) {
|
||||
var now = time.Date(2023, 1, 17, 12, 30, 0, 0, time.UTC)
|
||||
var author = search.CommitUser{Date: time.Date(2022, 12, 27, 11, 30, 0, 0, time.UTC)}
|
||||
var committer = search.CommitUser{Date: time.Date(2022, 12, 28, 12, 30, 0, 0, time.UTC)}
|
||||
var query = search.Query{
|
||||
Keywords: []string{"cli"},
|
||||
Kind: "commits",
|
||||
Limit: 30,
|
||||
Qualifiers: search.Qualifiers{},
|
||||
}
|
||||
tests := []struct {
|
||||
errMsg string
|
||||
name string
|
||||
opts *CommitsOptions
|
||||
tty bool
|
||||
wantErr bool
|
||||
wantStderr string
|
||||
wantStdout string
|
||||
}{
|
||||
{
|
||||
name: "displays results tty",
|
||||
opts: &CommitsOptions{
|
||||
Query: query,
|
||||
Searcher: &search.SearcherMock{
|
||||
CommitsFunc: func(query search.Query) (search.CommitsResult, error) {
|
||||
return search.CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []search.Commit{
|
||||
{
|
||||
Author: search.User{Login: "monalisa"},
|
||||
Info: search.CommitInfo{Author: author, Committer: committer, Message: "hello"},
|
||||
Repo: search.Repository{FullName: "test/cli"},
|
||||
Sha: "aaaaaaaa",
|
||||
},
|
||||
{
|
||||
Author: search.User{Login: "johnnytest"},
|
||||
Info: search.CommitInfo{Author: author, Committer: committer, Message: "hi"},
|
||||
Repo: search.Repository{FullName: "test/cliing", IsPrivate: true},
|
||||
Sha: "bbbbbbbb",
|
||||
},
|
||||
{
|
||||
Author: search.User{Login: "hubot"},
|
||||
Info: search.CommitInfo{Author: author, Committer: committer, Message: "greetings"},
|
||||
Repo: search.Repository{FullName: "cli/cli"},
|
||||
Sha: "cccccccc",
|
||||
},
|
||||
},
|
||||
Total: 300,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
tty: true,
|
||||
wantStdout: "\nShowing 3 of 300 commits\n\nREPO SHA MESSAGE AUTHOR CREATED\ntest/cli aaaaaaaa hello monalisa about 21 days ago\ntest/cliing bbbbbbbb hi johnnytest about 21 days ago\ncli/cli cccccccc greetings hubot about 21 days ago\n",
|
||||
},
|
||||
{
|
||||
name: "displays results notty",
|
||||
opts: &CommitsOptions{
|
||||
Query: query,
|
||||
Searcher: &search.SearcherMock{
|
||||
CommitsFunc: func(query search.Query) (search.CommitsResult, error) {
|
||||
return search.CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []search.Commit{
|
||||
{
|
||||
Author: search.User{Login: "monalisa"},
|
||||
Info: search.CommitInfo{Author: author, Committer: committer, Message: "hello"},
|
||||
Repo: search.Repository{FullName: "test/cli"},
|
||||
Sha: "aaaaaaaa",
|
||||
},
|
||||
{
|
||||
Author: search.User{Login: "johnnytest"},
|
||||
Info: search.CommitInfo{Author: author, Committer: committer, Message: "hi"},
|
||||
Repo: search.Repository{FullName: "test/cliing", IsPrivate: true},
|
||||
Sha: "bbbbbbbb",
|
||||
},
|
||||
{
|
||||
Author: search.User{Login: "hubot"},
|
||||
Info: search.CommitInfo{Author: author, Committer: committer, Message: "greetings"},
|
||||
Repo: search.Repository{FullName: "cli/cli"},
|
||||
Sha: "cccccccc",
|
||||
},
|
||||
},
|
||||
Total: 300,
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
wantStdout: "test/cli\taaaaaaaa\thello\tmonalisa\t2022-12-27T11:30:00Z\ntest/cliing\tbbbbbbbb\thi\tjohnnytest\t2022-12-27T11:30:00Z\ncli/cli\tcccccccc\tgreetings\thubot\t2022-12-27T11:30:00Z\n",
|
||||
},
|
||||
{
|
||||
name: "displays no results",
|
||||
opts: &CommitsOptions{
|
||||
Query: query,
|
||||
Searcher: &search.SearcherMock{
|
||||
CommitsFunc: func(query search.Query) (search.CommitsResult, error) {
|
||||
return search.CommitsResult{}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "no commits matched your search",
|
||||
},
|
||||
{
|
||||
name: "displays search error",
|
||||
opts: &CommitsOptions{
|
||||
Query: query,
|
||||
Searcher: &search.SearcherMock{
|
||||
CommitsFunc: func(query search.Query) (search.CommitsResult, error) {
|
||||
return search.CommitsResult{}, fmt.Errorf("error with query")
|
||||
},
|
||||
},
|
||||
},
|
||||
errMsg: "error with query",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "opens browser for web mode tty",
|
||||
opts: &CommitsOptions{
|
||||
Browser: &browser.Stub{},
|
||||
Query: query,
|
||||
Searcher: &search.SearcherMock{
|
||||
URLFunc: func(query search.Query) string {
|
||||
return "https://github.com/search?type=commits&q=cli"
|
||||
},
|
||||
},
|
||||
WebMode: true,
|
||||
},
|
||||
tty: true,
|
||||
wantStderr: "Opening github.com/search in your browser.\n",
|
||||
},
|
||||
{
|
||||
name: "opens browser for web mode notty",
|
||||
opts: &CommitsOptions{
|
||||
Browser: &browser.Stub{},
|
||||
Query: query,
|
||||
Searcher: &search.SearcherMock{
|
||||
URLFunc: func(query search.Query) string {
|
||||
return "https://github.com/search?type=commits&q=cli"
|
||||
},
|
||||
},
|
||||
WebMode: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdinTTY(tt.tty)
|
||||
ios.SetStdoutTTY(tt.tty)
|
||||
ios.SetStderrTTY(tt.tty)
|
||||
tt.opts.IO = ios
|
||||
tt.opts.Now = now
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := commitsRun(tt.opts)
|
||||
if tt.wantErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
return
|
||||
} else if err != nil {
|
||||
t.Fatalf("commitsRun unexpected error: %v", err)
|
||||
}
|
||||
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -7,12 +7,12 @@ import (
|
|||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmd/search/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/search"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -158,8 +158,8 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos
|
|||
now = time.Now()
|
||||
}
|
||||
cs := io.ColorScheme()
|
||||
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
|
||||
tp := utils.NewTablePrinter(io)
|
||||
tp := tableprinter.New(io)
|
||||
tp.HeaderRow("Name", "Description", "Visibility", "Updated")
|
||||
for _, repo := range results.Items {
|
||||
tags := []string{visibilityLabel(repo)}
|
||||
if repo.IsFork {
|
||||
|
|
@ -173,15 +173,10 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos
|
|||
if repo.IsPrivate {
|
||||
infoColor = cs.Yellow
|
||||
}
|
||||
tp.AddField(repo.FullName, nil, cs.Bold)
|
||||
description := repo.Description
|
||||
tp.AddField(text.RemoveExcessiveWhitespace(description), nil, nil)
|
||||
tp.AddField(info, nil, infoColor)
|
||||
if tp.IsTTY() {
|
||||
tp.AddField(text.FuzzyAgoAbbr(now, repo.UpdatedAt), nil, cs.Gray)
|
||||
} else {
|
||||
tp.AddField(repo.UpdatedAt.Format(time.RFC3339), nil, nil)
|
||||
}
|
||||
tp.AddField(repo.FullName, tableprinter.WithColor(cs.Bold))
|
||||
tp.AddField(text.RemoveExcessiveWhitespace(repo.Description))
|
||||
tp.AddField(info, tableprinter.WithColor(infoColor))
|
||||
tp.AddTimeField(now, repo.UpdatedAt, cs.Gray)
|
||||
tp.EndRow()
|
||||
}
|
||||
if io.IsStdoutTTY() {
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ func TestReposRun(t *testing.T) {
|
|||
},
|
||||
},
|
||||
tty: true,
|
||||
wantStdout: "\nShowing 3 of 300 repositories\n\ntest/cli of course private, archived Feb 28, 2021\ntest/cliing wow public, fork Feb 28, 2021\ncli/cli so much internal Feb 28, 2021\n",
|
||||
wantStdout: "\nShowing 3 of 300 repositories\n\nNAME DESCRIPTION VISIBILITY UPDATED\ntest/cli of course private, archived about 1 year ago\ntest/cliing wow public, fork about 1 year ago\ncli/cli so much internal about 1 year ago\n",
|
||||
},
|
||||
{
|
||||
name: "displays results notty",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
searchCommitsCmd "github.com/cli/cli/v2/pkg/cmd/search/commits"
|
||||
searchIssuesCmd "github.com/cli/cli/v2/pkg/cmd/search/issues"
|
||||
searchPrsCmd "github.com/cli/cli/v2/pkg/cmd/search/prs"
|
||||
searchReposCmd "github.com/cli/cli/v2/pkg/cmd/search/repos"
|
||||
|
|
@ -16,6 +17,7 @@ func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
|
|||
Long: "Search across all of GitHub.",
|
||||
}
|
||||
|
||||
cmd.AddCommand(searchCommitsCmd.NewCmdCommits(f, nil))
|
||||
cmd.AddCommand(searchIssuesCmd.NewCmdIssues(f, nil))
|
||||
cmd.AddCommand(searchPrsCmd.NewCmdPrs(f, nil))
|
||||
cmd.AddCommand(searchReposCmd.NewCmdRepos(f, nil))
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/search"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
)
|
||||
|
||||
type EntityType int
|
||||
|
|
@ -95,43 +95,46 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType,
|
|||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
isTTY := io.IsStdoutTTY()
|
||||
cs := io.ColorScheme()
|
||||
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
|
||||
tp := utils.NewTablePrinter(io)
|
||||
tp := tableprinter.New(io)
|
||||
if et == Both {
|
||||
tp.HeaderRow("Kind", "Repo", "ID", "Title", "Labels", "Updated")
|
||||
} else {
|
||||
tp.HeaderRow("Repo", "ID", "Title", "Labels", "Updated")
|
||||
}
|
||||
for _, issue := range results.Items {
|
||||
if et == Both {
|
||||
kind := "issue"
|
||||
if issue.IsPullRequest() {
|
||||
kind = "pr"
|
||||
}
|
||||
tp.AddField(kind, nil, nil)
|
||||
tp.AddField(kind)
|
||||
}
|
||||
comp := strings.Split(issue.RepositoryURL, "/")
|
||||
name := comp[len(comp)-2:]
|
||||
tp.AddField(strings.Join(name, "/"), nil, nil)
|
||||
tp.AddField(strings.Join(name, "/"))
|
||||
issueNum := strconv.Itoa(issue.Number)
|
||||
if tp.IsTTY() {
|
||||
if isTTY {
|
||||
issueNum = "#" + issueNum
|
||||
}
|
||||
if issue.IsPullRequest() {
|
||||
tp.AddField(issueNum, nil, cs.ColorFromString(colorForPRState(issue.State())))
|
||||
color := tableprinter.WithColor(cs.ColorFromString(colorForPRState(issue.State())))
|
||||
tp.AddField(issueNum, color)
|
||||
} else {
|
||||
tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State(), issue.StateReason)))
|
||||
color := tableprinter.WithColor(cs.ColorFromString(colorForIssueState(issue.State(), issue.StateReason)))
|
||||
tp.AddField(issueNum, color)
|
||||
}
|
||||
if !tp.IsTTY() {
|
||||
tp.AddField(issue.State(), nil, nil)
|
||||
}
|
||||
tp.AddField(text.RemoveExcessiveWhitespace(issue.Title), nil, nil)
|
||||
tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY()), nil, nil)
|
||||
if tp.IsTTY() {
|
||||
tp.AddField(text.FuzzyAgo(now, issue.UpdatedAt), nil, cs.Gray)
|
||||
} else {
|
||||
tp.AddField(issue.UpdatedAt.String(), nil, nil)
|
||||
if !isTTY {
|
||||
tp.AddField(issue.State())
|
||||
}
|
||||
tp.AddField(text.RemoveExcessiveWhitespace(issue.Title))
|
||||
tp.AddField(listIssueLabels(&issue, cs, isTTY))
|
||||
tp.AddTimeField(now, issue.UpdatedAt, cs.Gray)
|
||||
tp.EndRow()
|
||||
}
|
||||
|
||||
if io.IsStdoutTTY() {
|
||||
if isTTY {
|
||||
var header string
|
||||
switch et {
|
||||
case Both:
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ func TestSearchIssues(t *testing.T) {
|
|||
},
|
||||
},
|
||||
tty: true,
|
||||
wantStdout: "\nShowing 3 of 300 issues\n\ntest/cli #123 something broken bug, p1 about 1 year ago\nwhat/what #456 feature request enhancement about 1 year ago\nblah/test #789 some title about 1 year ago\n",
|
||||
wantStdout: "\nShowing 3 of 300 issues\n\nREPO ID TITLE LABELS UPDATED\ntest/cli #123 something broken bug, p1 about 1 year ago\nwhat/what #456 feature request enhancement about 1 year ago\nblah/test #789 some title about 1 year ago\n",
|
||||
},
|
||||
{
|
||||
name: "displays issues and pull requests tty",
|
||||
|
|
@ -85,7 +85,7 @@ func TestSearchIssues(t *testing.T) {
|
|||
},
|
||||
},
|
||||
tty: true,
|
||||
wantStdout: "\nShowing 2 of 300 issues and pull requests\n\nissue test/cli #123 bug bug, p1 about 1 year ago\npr what/what #456 fix bug fix about 1 year ago\n",
|
||||
wantStdout: "\nShowing 2 of 300 issues and pull requests\n\nKIND REPO ID TITLE LABELS UPDATED\nissue test/cli #123 bug bug, p1 about 1 year ago\npr what/what #456 fix bug fix about 1 year ago\n",
|
||||
},
|
||||
{
|
||||
name: "displays results notty",
|
||||
|
|
@ -106,7 +106,7 @@ func TestSearchIssues(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
wantStdout: "test/cli\t123\topen\tsomething broken\tbug, p1\t2021-02-28 12:30:00 +0000 UTC\nwhat/what\t456\tclosed\tfeature request\tenhancement\t2021-02-28 12:30:00 +0000 UTC\nblah/test\t789\topen\tsome title\t\t2021-02-28 12:30:00 +0000 UTC\n",
|
||||
wantStdout: "test/cli\t123\topen\tsomething broken\tbug, p1\t2021-02-28T12:30:00Z\nwhat/what\t456\tclosed\tfeature request\tenhancement\t2021-02-28T12:30:00Z\nblah/test\t789\topen\tsome title\t\t2021-02-28T12:30:00Z\n",
|
||||
},
|
||||
{
|
||||
name: "displays issues and pull requests notty",
|
||||
|
|
@ -126,7 +126,7 @@ func TestSearchIssues(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
wantStdout: "issue\ttest/cli\t123\topen\tbug\tbug, p1\t2021-02-28 12:30:00 +0000 UTC\npr\twhat/what\t456\topen\tfix bug\tfix\t2021-02-28 12:30:00 +0000 UTC\n",
|
||||
wantStdout: "issue\ttest/cli\t123\topen\tbug\tbug, p1\t2021-02-28T12:30:00Z\npr\twhat/what\t456\topen\tfix bug\tfix\t2021-02-28T12:30:00Z\n",
|
||||
},
|
||||
{
|
||||
name: "displays no results",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import (
|
|||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/opentracing/opentracing-go"
|
||||
)
|
||||
|
|
@ -29,7 +28,6 @@ type logger interface {
|
|||
|
||||
// An Options specifies Live Share connection parameters.
|
||||
type Options struct {
|
||||
ClientName string // ClientName is the name of the connecting client.
|
||||
SessionID string
|
||||
SessionToken string // token for SSH session
|
||||
RelaySAS string
|
||||
|
|
@ -41,9 +39,6 @@ type Options struct {
|
|||
|
||||
// uri returns a websocket URL for the specified options.
|
||||
func (opts *Options) uri(action string) (string, error) {
|
||||
if opts.ClientName == "" {
|
||||
return "", errors.New("ClientName is required")
|
||||
}
|
||||
if opts.SessionID == "" {
|
||||
return "", errors.New("SessionID is required")
|
||||
}
|
||||
|
|
@ -112,11 +107,9 @@ func Connect(ctx context.Context, opts Options) (*Session, error) {
|
|||
s := &Session{
|
||||
ssh: ssh,
|
||||
rpc: rpc,
|
||||
clientName: opts.ClientName,
|
||||
keepAliveReason: make(chan string, 1),
|
||||
logger: opts.Logger,
|
||||
}
|
||||
go s.heartbeat(ctx, 1*time.Minute)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import (
|
|||
|
||||
func TestConnect(t *testing.T) {
|
||||
opts := Options{
|
||||
ClientName: "liveshare-client",
|
||||
SessionID: "session-id",
|
||||
SessionToken: "session-token",
|
||||
RelaySAS: "relay-sas",
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ func checkBadOptions(t *testing.T, opts Options) {
|
|||
|
||||
func TestOptionsURI(t *testing.T) {
|
||||
opts := Options{
|
||||
ClientName: "liveshare-client",
|
||||
SessionID: "sess-id",
|
||||
SessionToken: "sess-token",
|
||||
RelaySAS: "sas",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package liveshare
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/opentracing/opentracing-go"
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
|
@ -23,6 +22,7 @@ type LiveshareSession interface {
|
|||
KeepAlive(string)
|
||||
OpenStreamingChannel(context.Context, ChannelID) (ssh.Channel, error)
|
||||
StartSharing(context.Context, string, int) (ChannelID, error)
|
||||
GetKeepAliveReason() string
|
||||
}
|
||||
|
||||
// A Session represents the session between a connected Live Share client and server.
|
||||
|
|
@ -30,7 +30,6 @@ type Session struct {
|
|||
ssh *sshSession
|
||||
rpc *rpcClient
|
||||
|
||||
clientName string
|
||||
keepAliveReason chan string
|
||||
logger logger
|
||||
}
|
||||
|
|
@ -48,42 +47,17 @@ func (s *Session) Close() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// Fetches the keep alive reason from the channel and returns it.
|
||||
func (s *Session) GetKeepAliveReason() string {
|
||||
return <-s.keepAliveReason
|
||||
}
|
||||
|
||||
// registerRequestHandler registers a handler for the given request type with the RPC
|
||||
// server and returns a callback function to deregister the handler
|
||||
func (s *Session) registerRequestHandler(requestType string, h handler) func() {
|
||||
return s.rpc.register(requestType, h)
|
||||
}
|
||||
|
||||
// heartbeat runs until context cancellation, periodically checking whether there is a
|
||||
// reason to keep the connection alive, and if so, notifying the Live Share host to do so.
|
||||
// Heartbeat ensures it does not send more than one request every "interval" to ratelimit
|
||||
// how many KeepAlives we send at a time.
|
||||
func (s *Session) heartbeat(ctx context.Context, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.logger.Println("Heartbeat tick")
|
||||
reason := <-s.keepAliveReason
|
||||
s.logger.Println("Keep alive reason: " + reason)
|
||||
if err := s.notifyHostOfActivity(ctx, reason); err != nil {
|
||||
s.logger.Printf("Failed to notify host of activity: %s\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notifyHostOfActivity notifies the Live Share host of client activity.
|
||||
func (s *Session) notifyHostOfActivity(ctx context.Context, activity string) error {
|
||||
activities := []string{activity}
|
||||
params := []interface{}{s.clientName, activities}
|
||||
return s.rpc.do(ctx, "ICodespaceHostService.notifyCodespaceOfClientActivity", params, nil)
|
||||
}
|
||||
|
||||
// KeepAlive accepts a reason that is retained if there is no active reason
|
||||
// to send to the server.
|
||||
func (s *Session) KeepAlive(reason string) {
|
||||
|
|
|
|||
|
|
@ -10,14 +10,11 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
livesharetest "github.com/cli/cli/v2/pkg/liveshare/test"
|
||||
"github.com/sourcegraph/jsonrpc2"
|
||||
)
|
||||
|
||||
const mockClientName = "liveshare-client"
|
||||
|
||||
func makeMockSession(opts ...livesharetest.ServerOption) (*livesharetest.Server, *Session, error) {
|
||||
joinWorkspace := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) {
|
||||
return joinWorkspaceResult{1}, nil
|
||||
|
|
@ -34,7 +31,6 @@ func makeMockSession(opts ...livesharetest.ServerOption) (*livesharetest.Server,
|
|||
}
|
||||
|
||||
session, err := Connect(context.Background(), Options{
|
||||
ClientName: mockClientName,
|
||||
SessionID: "session-id",
|
||||
SessionToken: sessionToken,
|
||||
RelayEndpoint: "sb" + strings.TrimPrefix(testServer.URL(), "https"),
|
||||
|
|
@ -254,151 +250,6 @@ func TestKeepAliveNonBlocking(t *testing.T) {
|
|||
// timing out
|
||||
}
|
||||
|
||||
func TestNotifyHostOfActivity(t *testing.T) {
|
||||
notifyHostOfActivity := func(conn *jsonrpc2.Conn, rpcReq *jsonrpc2.Request) (interface{}, error) {
|
||||
var req []interface{}
|
||||
if err := json.Unmarshal(*rpcReq.Params, &req); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal req: %w", err)
|
||||
}
|
||||
if len(req) < 2 {
|
||||
return nil, errors.New("request arguments is less than 2")
|
||||
}
|
||||
|
||||
if clientName, ok := req[0].(string); ok {
|
||||
if clientName != mockClientName {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected clientName param, expected: %q, got: %q", mockClientName, clientName,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("clientName param is not a string")
|
||||
}
|
||||
|
||||
if acs, ok := req[1].([]interface{}); ok {
|
||||
if fmt.Sprintf("%s", acs) != "[input]" {
|
||||
return nil, fmt.Errorf("unexpected activities param, expected: [input], got: %s", acs)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("activities param is not a slice")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
svc := livesharetest.WithService(
|
||||
"ICodespaceHostService.notifyCodespaceOfClientActivity", notifyHostOfActivity,
|
||||
)
|
||||
testServer, session, err := makeMockSession(svc)
|
||||
if err != nil {
|
||||
t.Fatalf("creating mock session: %v", err)
|
||||
}
|
||||
defer testServer.Close()
|
||||
ctx := context.Background()
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
done <- session.notifyHostOfActivity(ctx, "input")
|
||||
}()
|
||||
select {
|
||||
case err := <-testServer.Err():
|
||||
t.Errorf("error from server: %v", err)
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("error from client: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionHeartbeat(t *testing.T) {
|
||||
var (
|
||||
requestsMu sync.Mutex
|
||||
requests int
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
wg.Add(1)
|
||||
notifyHostOfActivity := func(conn *jsonrpc2.Conn, rpcReq *jsonrpc2.Request) (interface{}, error) {
|
||||
defer wg.Done()
|
||||
requestsMu.Lock()
|
||||
requests++
|
||||
requestsMu.Unlock()
|
||||
|
||||
var req []interface{}
|
||||
if err := json.Unmarshal(*rpcReq.Params, &req); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal req: %w", err)
|
||||
}
|
||||
if len(req) < 2 {
|
||||
return nil, errors.New("request arguments is less than 2")
|
||||
}
|
||||
|
||||
if clientName, ok := req[0].(string); ok {
|
||||
if clientName != mockClientName {
|
||||
return nil, fmt.Errorf(
|
||||
"unexpected clientName param, expected: %q, got: %q", mockClientName, clientName,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("clientName param is not a string")
|
||||
}
|
||||
|
||||
if acs, ok := req[1].([]interface{}); ok {
|
||||
if fmt.Sprintf("%s", acs) != "[input]" {
|
||||
return nil, fmt.Errorf("unexpected activities param, expected: [input], got: %s", acs)
|
||||
}
|
||||
} else {
|
||||
return nil, errors.New("activities param is not a slice")
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
svc := livesharetest.WithService(
|
||||
"ICodespaceHostService.notifyCodespaceOfClientActivity", notifyHostOfActivity,
|
||||
)
|
||||
testServer, session, err := makeMockSession(svc)
|
||||
if err != nil {
|
||||
t.Fatalf("creating mock session: %v", err)
|
||||
}
|
||||
defer testServer.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
done := make(chan struct{})
|
||||
|
||||
logger := newMockLogger()
|
||||
session.logger = logger
|
||||
|
||||
go session.heartbeat(ctx, 50*time.Millisecond)
|
||||
go func() {
|
||||
session.KeepAlive("input")
|
||||
wg.Wait()
|
||||
wg.Add(1)
|
||||
session.KeepAlive("input")
|
||||
wg.Wait()
|
||||
done <- struct{}{}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-testServer.Err():
|
||||
t.Errorf("error from server: %v", err)
|
||||
case <-done:
|
||||
activityCount := strings.Count(logger.String(), "input")
|
||||
// by design KeepAlive can drop requests, and therefore there is zero guarantee
|
||||
// that we actually get two requests if the network happened to be slow (rarely)
|
||||
// during testing.
|
||||
if activityCount != 1 && activityCount != 2 {
|
||||
t.Errorf("unexpected number of activities, expected: 1-2, got: %d", activityCount)
|
||||
}
|
||||
|
||||
requestsMu.Lock()
|
||||
rc := requests
|
||||
requestsMu.Unlock()
|
||||
// though this could be also dropped, the sync.WaitGroup above guarantees
|
||||
// that it gets called a second time.
|
||||
if rc != 2 {
|
||||
t.Errorf("unexpected number of requests, expected: 2, got: %d", requests)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
type mockLogger struct {
|
||||
sync.Mutex
|
||||
buf *bytes.Buffer
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
const (
|
||||
KindRepositories = "repositories"
|
||||
KindIssues = "issues"
|
||||
KindCommits = "commits"
|
||||
)
|
||||
|
||||
type Query struct {
|
||||
|
|
@ -27,16 +28,24 @@ type Qualifiers struct {
|
|||
Archived *bool
|
||||
Assignee string
|
||||
Author string
|
||||
AuthorDate string
|
||||
AuthorEmail string
|
||||
AuthorName string
|
||||
Base string
|
||||
Closed string
|
||||
Commenter string
|
||||
Comments string
|
||||
Committer string
|
||||
CommitterDate string
|
||||
CommitterEmail string
|
||||
CommitterName string
|
||||
Created string
|
||||
Draft *bool
|
||||
Followers string
|
||||
Fork string
|
||||
Forks string
|
||||
GoodFirstIssues string
|
||||
Hash string
|
||||
Head string
|
||||
HelpWantedIssues string
|
||||
In []string
|
||||
|
|
@ -47,9 +56,11 @@ type Qualifiers struct {
|
|||
Language string
|
||||
License []string
|
||||
Mentions string
|
||||
Merge *bool
|
||||
Merged string
|
||||
Milestone string
|
||||
No []string
|
||||
Parent string
|
||||
Project string
|
||||
Pushed string
|
||||
Reactions string
|
||||
|
|
@ -65,6 +76,7 @@ type Qualifiers struct {
|
|||
TeamReviewRequested string
|
||||
Topic []string
|
||||
Topics string
|
||||
Tree string
|
||||
Type string
|
||||
Updated string
|
||||
User string
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ func TestQueryString(t *testing.T) {
|
|||
Keywords: []string{"some", "keywords"},
|
||||
Qualifiers: Qualifiers{
|
||||
Archived: &trueBool,
|
||||
AuthorEmail: "foo@example.com",
|
||||
CommitterDate: "2021-02-28",
|
||||
Created: "created",
|
||||
Followers: "1",
|
||||
Fork: "true",
|
||||
|
|
@ -38,7 +40,7 @@ func TestQueryString(t *testing.T) {
|
|||
Is: []string{"public"},
|
||||
},
|
||||
},
|
||||
out: "some keywords archived:true created:created followers:1 fork:true forks:2 good-first-issues:3 help-wanted-issues:4 in:description in:readme is:public language:language license:license pushed:updated size:5 stars:6 topic:topic topics:7 user:user",
|
||||
out: "some keywords archived:true author-email:foo@example.com committer-date:2021-02-28 created:created followers:1 fork:true forks:2 good-first-issues:3 help-wanted-issues:4 in:description in:readme is:public language:language license:license pushed:updated size:5 stars:6 topic:topic topics:7 user:user",
|
||||
},
|
||||
{
|
||||
name: "quotes keywords",
|
||||
|
|
@ -74,6 +76,8 @@ func TestQualifiersMap(t *testing.T) {
|
|||
name: "changes qualifiers to map",
|
||||
qualifiers: Qualifiers{
|
||||
Archived: &trueBool,
|
||||
AuthorEmail: "foo@example.com",
|
||||
CommitterDate: "2021-02-28",
|
||||
Created: "created",
|
||||
Followers: "1",
|
||||
Fork: "true",
|
||||
|
|
@ -93,6 +97,8 @@ func TestQualifiersMap(t *testing.T) {
|
|||
},
|
||||
out: map[string][]string{
|
||||
"archived": {"true"},
|
||||
"author-email": {"foo@example.com"},
|
||||
"committer-date": {"2021-02-28"},
|
||||
"created": {"created"},
|
||||
"followers": {"1"},
|
||||
"fork": {"true"},
|
||||
|
|
|
|||
|
|
@ -6,6 +6,17 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
var CommitFields = []string{
|
||||
"author",
|
||||
"commit",
|
||||
"committer",
|
||||
"sha",
|
||||
"id",
|
||||
"parents",
|
||||
"repository",
|
||||
"url",
|
||||
}
|
||||
|
||||
var RepositoryFields = []string{
|
||||
"createdAt",
|
||||
"defaultBranch",
|
||||
|
|
@ -61,6 +72,12 @@ var PullRequestFields = append(IssueFields,
|
|||
"isDraft",
|
||||
)
|
||||
|
||||
type CommitsResult struct {
|
||||
IncompleteResults bool `json:"incomplete_results"`
|
||||
Items []Commit `json:"items"`
|
||||
Total int `json:"total_count"`
|
||||
}
|
||||
|
||||
type RepositoriesResult struct {
|
||||
IncompleteResults bool `json:"incomplete_results"`
|
||||
Items []Repository `json:"items"`
|
||||
|
|
@ -73,6 +90,40 @@ type IssuesResult struct {
|
|||
Total int `json:"total_count"`
|
||||
}
|
||||
|
||||
type Commit struct {
|
||||
Author User `json:"author"`
|
||||
Committer User `json:"committer"`
|
||||
ID string `json:"node_id"`
|
||||
Info CommitInfo `json:"commit"`
|
||||
Parents []Parent `json:"parents"`
|
||||
Repo Repository `json:"repository"`
|
||||
Sha string `json:"sha"`
|
||||
URL string `json:"html_url"`
|
||||
}
|
||||
|
||||
type CommitInfo struct {
|
||||
Author CommitUser `json:"author"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Committer CommitUser `json:"committer"`
|
||||
Message string `json:"message"`
|
||||
Tree Tree `json:"tree"`
|
||||
}
|
||||
|
||||
type CommitUser struct {
|
||||
Date time.Time `json:"date"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type Tree struct {
|
||||
Sha string `json:"sha"`
|
||||
}
|
||||
|
||||
type Parent struct {
|
||||
Sha string `json:"sha"`
|
||||
URL string `json:"html_url"`
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DefaultBranch string `json:"default_branch"`
|
||||
|
|
@ -120,13 +171,6 @@ type User struct {
|
|||
URL string `json:"html_url"`
|
||||
}
|
||||
|
||||
func (u *User) IsBot() bool {
|
||||
// copied from api/queries_issue.go
|
||||
// would ideally be shared, but it would require coordinating a "user"
|
||||
// abstraction in a bunch of places.
|
||||
return u.ID == ""
|
||||
}
|
||||
|
||||
type Issue struct {
|
||||
Assignees []User `json:"assignees"`
|
||||
Author User `json:"user"`
|
||||
|
|
@ -157,18 +201,6 @@ type PullRequest struct {
|
|||
MergedAt time.Time `json:"merged_at"`
|
||||
}
|
||||
|
||||
// the state of an issue or a pull request,
|
||||
// may be either open or closed.
|
||||
// for a pull request, the "merged" state is
|
||||
// inferred from a value for merged_at and
|
||||
// which we take return instead of the "closed" state.
|
||||
func (issue Issue) State() string {
|
||||
if !issue.PullRequest.MergedAt.IsZero() {
|
||||
return "merged"
|
||||
}
|
||||
return issue.StateInternal
|
||||
}
|
||||
|
||||
type Label struct {
|
||||
Color string `json:"color"`
|
||||
Description string `json:"description"`
|
||||
|
|
@ -176,6 +208,83 @@ type Label struct {
|
|||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (u User) IsBot() bool {
|
||||
// copied from api/queries_issue.go
|
||||
// would ideally be shared, but it would require coordinating a "user"
|
||||
// abstraction in a bunch of places.
|
||||
return u.ID == ""
|
||||
}
|
||||
|
||||
func (u User) ExportData() map[string]interface{} {
|
||||
isBot := u.IsBot()
|
||||
login := u.Login
|
||||
if isBot {
|
||||
login = "app/" + login
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"id": u.ID,
|
||||
"login": login,
|
||||
"type": u.Type,
|
||||
"url": u.URL,
|
||||
"is_bot": isBot,
|
||||
}
|
||||
}
|
||||
|
||||
func (commit Commit) ExportData(fields []string) map[string]interface{} {
|
||||
v := reflect.ValueOf(commit)
|
||||
data := map[string]interface{}{}
|
||||
for _, f := range fields {
|
||||
switch f {
|
||||
case "author":
|
||||
data[f] = commit.Author.ExportData()
|
||||
case "commit":
|
||||
info := commit.Info
|
||||
data[f] = map[string]interface{}{
|
||||
"author": map[string]interface{}{
|
||||
"date": info.Author.Date,
|
||||
"email": info.Author.Email,
|
||||
"name": info.Author.Name,
|
||||
},
|
||||
"committer": map[string]interface{}{
|
||||
"date": info.Committer.Date,
|
||||
"email": info.Committer.Email,
|
||||
"name": info.Committer.Name,
|
||||
},
|
||||
"comment_count": info.CommentCount,
|
||||
"message": info.Message,
|
||||
"tree": map[string]interface{}{"sha": info.Tree.Sha},
|
||||
}
|
||||
case "committer":
|
||||
data[f] = commit.Committer.ExportData()
|
||||
case "parents":
|
||||
parents := make([]interface{}, 0, len(commit.Parents))
|
||||
for _, parent := range commit.Parents {
|
||||
parents = append(parents, map[string]interface{}{
|
||||
"sha": parent.Sha,
|
||||
"url": parent.URL,
|
||||
})
|
||||
}
|
||||
data[f] = parents
|
||||
case "repository":
|
||||
repo := commit.Repo
|
||||
data[f] = map[string]interface{}{
|
||||
"description": repo.Description,
|
||||
"fullName": repo.FullName,
|
||||
"name": repo.Name,
|
||||
"id": repo.ID,
|
||||
"isFork": repo.IsFork,
|
||||
"isPrivate": repo.IsPrivate,
|
||||
"owner": repo.Owner.ExportData(),
|
||||
"url": repo.URL,
|
||||
}
|
||||
default:
|
||||
sf := fieldByName(v, f)
|
||||
data[f] = sf.Interface()
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func (repo Repository) ExportData(fields []string) map[string]interface{} {
|
||||
v := reflect.ValueOf(repo)
|
||||
data := map[string]interface{}{}
|
||||
|
|
@ -188,12 +297,7 @@ func (repo Repository) ExportData(fields []string) map[string]interface{} {
|
|||
"url": repo.License.URL,
|
||||
}
|
||||
case "owner":
|
||||
data[f] = map[string]interface{}{
|
||||
"id": repo.Owner.ID,
|
||||
"login": repo.Owner.Login,
|
||||
"type": repo.Owner.Type,
|
||||
"url": repo.Owner.URL,
|
||||
}
|
||||
data[f] = repo.Owner.ExportData()
|
||||
default:
|
||||
sf := fieldByName(v, f)
|
||||
data[f] = sf.Interface()
|
||||
|
|
@ -202,6 +306,16 @@ func (repo Repository) ExportData(fields []string) map[string]interface{} {
|
|||
return data
|
||||
}
|
||||
|
||||
// The state of an issue or a pull request, may be either open or closed.
|
||||
// For a pull request, the "merged" state is inferred from a value for merged_at and
|
||||
// which we take return instead of the "closed" state.
|
||||
func (issue Issue) State() string {
|
||||
if !issue.PullRequest.MergedAt.IsZero() {
|
||||
return "merged"
|
||||
}
|
||||
return issue.StateInternal
|
||||
}
|
||||
|
||||
func (issue Issue) IsPullRequest() bool {
|
||||
return issue.PullRequest.URL != ""
|
||||
}
|
||||
|
|
@ -214,31 +328,11 @@ func (issue Issue) ExportData(fields []string) map[string]interface{} {
|
|||
case "assignees":
|
||||
assignees := make([]interface{}, 0, len(issue.Assignees))
|
||||
for _, assignee := range issue.Assignees {
|
||||
isBot := assignee.IsBot()
|
||||
login := assignee.Login
|
||||
if isBot {
|
||||
login = "app/" + login
|
||||
}
|
||||
assignees = append(assignees, map[string]interface{}{
|
||||
"id": assignee.ID,
|
||||
"login": login,
|
||||
"type": assignee.Type,
|
||||
"is_bot": isBot,
|
||||
})
|
||||
assignees = append(assignees, assignee.ExportData())
|
||||
}
|
||||
data[f] = assignees
|
||||
case "author":
|
||||
isBot := issue.Author.IsBot()
|
||||
login := issue.Author.Login
|
||||
if isBot {
|
||||
login = "app/" + login
|
||||
}
|
||||
data[f] = map[string]interface{}{
|
||||
"id": issue.Author.ID,
|
||||
"login": login,
|
||||
"type": issue.Author.Type,
|
||||
"is_bot": isBot,
|
||||
}
|
||||
data[f] = issue.Author.ExportData()
|
||||
case "isPullRequest":
|
||||
data[f] = issue.IsPullRequest()
|
||||
case "labels":
|
||||
|
|
|
|||
|
|
@ -11,6 +11,42 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCommitExportData(t *testing.T) {
|
||||
var authoredAt = time.Date(2021, 2, 27, 11, 30, 0, 0, time.UTC)
|
||||
var committedAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
name string
|
||||
fields []string
|
||||
commit Commit
|
||||
output string
|
||||
}{
|
||||
{
|
||||
name: "exports requested fields",
|
||||
fields: []string{"author", "commit", "committer", "sha"},
|
||||
commit: Commit{
|
||||
Author: User{Login: "foo"},
|
||||
Committer: User{Login: "bar", ID: "123"},
|
||||
Info: CommitInfo{
|
||||
Author: CommitUser{Date: authoredAt, Name: "Foo"},
|
||||
Committer: CommitUser{Date: committedAt, Name: "Bar"},
|
||||
Message: "test message",
|
||||
},
|
||||
Sha: "8dd03144ffdc6c0d",
|
||||
},
|
||||
output: `{"author":{"id":"","is_bot":true,"login":"app/foo","type":"","url":""},"commit":{"author":{"date":"2021-02-27T11:30:00Z","email":"","name":"Foo"},"comment_count":0,"committer":{"date":"2021-02-28T12:30:00Z","email":"","name":"Bar"},"message":"test message","tree":{"sha":""}},"committer":{"id":"123","is_bot":false,"login":"bar","type":"","url":""},"sha":"8dd03144ffdc6c0d"}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
exported := tt.commit.ExportData(tt.fields)
|
||||
buf := bytes.Buffer{}
|
||||
enc := json.NewEncoder(&buf)
|
||||
require.NoError(t, enc.Encode(exported))
|
||||
assert.Equal(t, tt.output, strings.TrimSpace(buf.String()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryExportData(t *testing.T) {
|
||||
var createdAt = time.Date(2021, 2, 28, 12, 30, 0, 0, time.UTC)
|
||||
tests := []struct {
|
||||
|
|
@ -67,7 +103,7 @@ func TestIssueExportData(t *testing.T) {
|
|||
Title: "title",
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
output: `{"assignees":[{"id":"123","is_bot":false,"login":"test","type":""},{"id":"","is_bot":true,"login":"app/foo","type":""}],"body":"body","commentsCount":1,"isLocked":true,"labels":[{"color":"","description":"","id":"","name":"label1"},{"color":"","description":"","id":"","name":"label2"}],"repository":{"name":"repo","nameWithOwner":"owner/repo"},"title":"title","updatedAt":"2021-02-28T12:30:00Z"}`,
|
||||
output: `{"assignees":[{"id":"123","is_bot":false,"login":"test","type":"","url":""},{"id":"","is_bot":true,"login":"app/foo","type":"","url":""}],"body":"body","commentsCount":1,"isLocked":true,"labels":[{"color":"","description":"","id":"","name":"label1"},{"color":"","description":"","id":"","name":"label2"}],"repository":{"name":"repo","nameWithOwner":"owner/repo"},"title":"title","updatedAt":"2021-02-28T12:30:00Z"}`,
|
||||
},
|
||||
{
|
||||
name: "state when issue",
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
|
|||
|
||||
//go:generate moq -rm -out searcher_mock.go . Searcher
|
||||
type Searcher interface {
|
||||
Commits(Query) (CommitsResult, error)
|
||||
Repositories(Query) (RepositoriesResult, error)
|
||||
Issues(Query) (IssuesResult, error)
|
||||
URL(Query) string
|
||||
|
|
@ -56,6 +57,30 @@ func NewSearcher(client *http.Client, host string) Searcher {
|
|||
}
|
||||
}
|
||||
|
||||
func (s searcher) Commits(query Query) (CommitsResult, error) {
|
||||
result := CommitsResult{}
|
||||
toRetrieve := query.Limit
|
||||
var resp *http.Response
|
||||
var err error
|
||||
for toRetrieve > 0 {
|
||||
query.Limit = min(toRetrieve, maxPerPage)
|
||||
query.Page = nextPage(resp)
|
||||
if query.Page == 0 {
|
||||
break
|
||||
}
|
||||
page := CommitsResult{}
|
||||
resp, err = s.search(query, &page)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.IncompleteResults = page.IncompleteResults
|
||||
result.Total = page.Total
|
||||
result.Items = append(result.Items, page.Items...)
|
||||
toRetrieve = toRetrieve - len(page.Items)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s searcher) Repositories(query Query) (RepositoriesResult, error) {
|
||||
result := RepositoriesResult{}
|
||||
toRetrieve := query.Limit
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ var _ Searcher = &SearcherMock{}
|
|||
//
|
||||
// // make and configure a mocked Searcher
|
||||
// mockedSearcher := &SearcherMock{
|
||||
// CommitsFunc: func(query Query) (CommitsResult, error) {
|
||||
// panic("mock out the Commits method")
|
||||
// },
|
||||
// IssuesFunc: func(query Query) (IssuesResult, error) {
|
||||
// panic("mock out the Issues method")
|
||||
// },
|
||||
|
|
@ -33,6 +36,9 @@ var _ Searcher = &SearcherMock{}
|
|||
//
|
||||
// }
|
||||
type SearcherMock struct {
|
||||
// CommitsFunc mocks the Commits method.
|
||||
CommitsFunc func(query Query) (CommitsResult, error)
|
||||
|
||||
// IssuesFunc mocks the Issues method.
|
||||
IssuesFunc func(query Query) (IssuesResult, error)
|
||||
|
||||
|
|
@ -44,6 +50,11 @@ type SearcherMock struct {
|
|||
|
||||
// calls tracks calls to the methods.
|
||||
calls struct {
|
||||
// Commits holds details about calls to the Commits method.
|
||||
Commits []struct {
|
||||
// Query is the query argument value.
|
||||
Query Query
|
||||
}
|
||||
// Issues holds details about calls to the Issues method.
|
||||
Issues []struct {
|
||||
// Query is the query argument value.
|
||||
|
|
@ -60,11 +71,44 @@ type SearcherMock struct {
|
|||
Query Query
|
||||
}
|
||||
}
|
||||
lockCommits sync.RWMutex
|
||||
lockIssues sync.RWMutex
|
||||
lockRepositories sync.RWMutex
|
||||
lockURL sync.RWMutex
|
||||
}
|
||||
|
||||
// Commits calls CommitsFunc.
|
||||
func (mock *SearcherMock) Commits(query Query) (CommitsResult, error) {
|
||||
if mock.CommitsFunc == nil {
|
||||
panic("SearcherMock.CommitsFunc: method is nil but Searcher.Commits was just called")
|
||||
}
|
||||
callInfo := struct {
|
||||
Query Query
|
||||
}{
|
||||
Query: query,
|
||||
}
|
||||
mock.lockCommits.Lock()
|
||||
mock.calls.Commits = append(mock.calls.Commits, callInfo)
|
||||
mock.lockCommits.Unlock()
|
||||
return mock.CommitsFunc(query)
|
||||
}
|
||||
|
||||
// CommitsCalls gets all the calls that were made to Commits.
|
||||
// Check the length with:
|
||||
//
|
||||
// len(mockedSearcher.CommitsCalls())
|
||||
func (mock *SearcherMock) CommitsCalls() []struct {
|
||||
Query Query
|
||||
} {
|
||||
var calls []struct {
|
||||
Query Query
|
||||
}
|
||||
mock.lockCommits.RLock()
|
||||
calls = mock.calls.Commits
|
||||
mock.lockCommits.RUnlock()
|
||||
return calls
|
||||
}
|
||||
|
||||
// Issues calls IssuesFunc.
|
||||
func (mock *SearcherMock) Issues(query Query) (IssuesResult, error) {
|
||||
if mock.IssuesFunc == nil {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,163 @@ import (
|
|||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSearcherCommits(t *testing.T) {
|
||||
query := Query{
|
||||
Keywords: []string{"keyword"},
|
||||
Kind: "commits",
|
||||
Limit: 30,
|
||||
Order: "desc",
|
||||
Sort: "committer-date",
|
||||
Qualifiers: Qualifiers{
|
||||
Author: "foobar",
|
||||
CommitterDate: ">2021-02-28",
|
||||
},
|
||||
}
|
||||
|
||||
values := url.Values{
|
||||
"page": []string{"1"},
|
||||
"per_page": []string{"30"},
|
||||
"order": []string{"desc"},
|
||||
"sort": []string{"committer-date"},
|
||||
"q": []string{"keyword author:foobar committer-date:>2021-02-28"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
host string
|
||||
query Query
|
||||
result CommitsResult
|
||||
wantErr bool
|
||||
errMsg string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
}{
|
||||
{
|
||||
name: "searches commits",
|
||||
query: query,
|
||||
result: CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []Commit{{Sha: "abc"}},
|
||||
Total: 1,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "search/commits", values),
|
||||
httpmock.JSONResponse(CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []Commit{{Sha: "abc"}},
|
||||
Total: 1,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "searches commits for enterprise host",
|
||||
host: "enterprise.com",
|
||||
query: query,
|
||||
result: CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []Commit{{Sha: "abc"}},
|
||||
Total: 1,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "api/v3/search/commits", values),
|
||||
httpmock.JSONResponse(CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []Commit{{Sha: "abc"}},
|
||||
Total: 1,
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "paginates results",
|
||||
query: query,
|
||||
result: CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []Commit{{Sha: "abc"}, {Sha: "def"}},
|
||||
Total: 2,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
firstReq := httpmock.QueryMatcher("GET", "search/commits", values)
|
||||
firstRes := httpmock.JSONResponse(CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []Commit{{Sha: "abc"}},
|
||||
Total: 2,
|
||||
},
|
||||
)
|
||||
firstRes = httpmock.WithHeader(firstRes, "Link", `<https://api.github.com/search/commits?page=2&per_page=100&q=org%3Agithub>; rel="next"`)
|
||||
secondReq := httpmock.QueryMatcher("GET", "search/commits", url.Values{
|
||||
"page": []string{"2"},
|
||||
"per_page": []string{"29"},
|
||||
"order": []string{"desc"},
|
||||
"sort": []string{"committer-date"},
|
||||
"q": []string{"keyword author:foobar committer-date:>2021-02-28"},
|
||||
},
|
||||
)
|
||||
secondRes := httpmock.JSONResponse(CommitsResult{
|
||||
IncompleteResults: false,
|
||||
Items: []Commit{{Sha: "def"}},
|
||||
Total: 2,
|
||||
},
|
||||
)
|
||||
reg.Register(firstReq, firstRes)
|
||||
reg.Register(secondReq, secondRes)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handles search errors",
|
||||
query: query,
|
||||
wantErr: true,
|
||||
errMsg: heredoc.Doc(`
|
||||
Invalid search query "keyword author:foobar committer-date:>2021-02-28".
|
||||
"blah" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.`),
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "search/commits", values),
|
||||
httpmock.WithHeader(
|
||||
httpmock.StatusStringResponse(422,
|
||||
`{
|
||||
"message":"Validation Failed",
|
||||
"errors":[
|
||||
{
|
||||
"message":"\"blah\" is not a recognized date/time format. Please provide an ISO 8601 date/time value, such as YYYY-MM-DD.",
|
||||
"resource":"Search",
|
||||
"field":"q",
|
||||
"code":"invalid"
|
||||
}
|
||||
],
|
||||
"documentation_url":"https://docs.github.com/v3/search/"
|
||||
}`,
|
||||
), "Content-Type", "application/json"),
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
client := &http.Client{Transport: reg}
|
||||
if tt.host == "" {
|
||||
tt.host = "github.com"
|
||||
}
|
||||
searcher := NewSearcher(client, tt.host)
|
||||
result, err := searcher.Commits(tt.query)
|
||||
if tt.wantErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.result, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearcherRepositories(t *testing.T) {
|
||||
query := Query{
|
||||
Keywords: []string{"keyword"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue