Merge branch 'trunk' into jungaretti/fix-jupyter-spinner
This commit is contained in:
commit
a388358348
136 changed files with 4834 additions and 1003 deletions
2
.github/workflows/releases.yml
vendored
2
.github/workflows/releases.yml
vendored
|
|
@ -149,7 +149,7 @@ jobs:
|
||||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||||
- name: Prepare PATH
|
- name: Prepare PATH
|
||||||
id: setupmsbuild
|
id: setupmsbuild
|
||||||
uses: microsoft/setup-msbuild@v1.1.3
|
uses: microsoft/setup-msbuild@v1.3.1
|
||||||
- name: Build MSI
|
- name: Build MSI
|
||||||
id: buildmsi
|
id: buildmsi
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ builds:
|
||||||
main: ./cmd/gh
|
main: ./cmd/gh
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
|
- -s -w -X github.com/cli/cli/v2/internal/build.Version={{.Version}} -X github.com/cli/cli/v2/internal/build.Date={{time "2006-01-02"}}
|
||||||
- -X main.updaterEnabled=cli/cli
|
|
||||||
id: macos
|
id: macos
|
||||||
goos: [darwin]
|
goos: [darwin]
|
||||||
goarch: [amd64]
|
goarch: [amd64]
|
||||||
|
|
|
||||||
|
|
@ -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 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 -->
|
<!-- this anchor is linked to from elsewhere, so avoid renaming it -->
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|
@ -128,3 +130,4 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
|
||||||
[contributing]: ./.github/CONTRIBUTING.md
|
[contributing]: ./.github/CONTRIBUTING.md
|
||||||
[gh-vs-hub]: ./docs/gh-vs-hub.md
|
[gh-vs-hub]: ./docs/gh-vs-hub.md
|
||||||
[build from source]: ./docs/source.md
|
[build from source]: ./docs/source.md
|
||||||
|
[intake-doc]: ./docs/working-with-us.md
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,8 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
|
||||||
client.Transport = AddAuthTokenHeader(client.Transport, opts.Config)
|
client.Transport = AddAuthTokenHeader(client.Transport, opts.Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.Transport = AddASCIISanitizer(client.Transport)
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ type Issue struct {
|
||||||
Assignees Assignees
|
Assignees Assignees
|
||||||
Labels Labels
|
Labels Labels
|
||||||
ProjectCards ProjectCards
|
ProjectCards ProjectCards
|
||||||
|
ProjectItems ProjectItems
|
||||||
Milestone *Milestone
|
Milestone *Milestone
|
||||||
ReactionGroups ReactionGroups
|
ReactionGroups ReactionGroups
|
||||||
IsPinned bool
|
IsPinned bool
|
||||||
|
|
@ -86,6 +87,10 @@ type ProjectCards struct {
|
||||||
TotalCount int
|
TotalCount int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectItems struct {
|
||||||
|
Nodes []*ProjectV2Item
|
||||||
|
}
|
||||||
|
|
||||||
type ProjectInfo struct {
|
type ProjectInfo struct {
|
||||||
Project struct {
|
Project struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -95,6 +100,14 @@ type ProjectInfo struct {
|
||||||
} `json:"column"`
|
} `json:"column"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectV2Item struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Project struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (p ProjectCards) ProjectNames() []string {
|
func (p ProjectCards) ProjectNames() []string {
|
||||||
names := make([]string, len(p.Nodes))
|
names := make([]string, len(p.Nodes))
|
||||||
for i, c := range p.Nodes {
|
for i, c := range p.Nodes {
|
||||||
|
|
@ -103,6 +116,14 @@ func (p ProjectCards) ProjectNames() []string {
|
||||||
return names
|
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 {
|
type Milestone struct {
|
||||||
Number int `json:"number"`
|
Number int `json:"number"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
@ -158,6 +179,7 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
||||||
mutation IssueCreate($input: CreateIssueInput!) {
|
mutation IssueCreate($input: CreateIssueInput!) {
|
||||||
createIssue(input: $input) {
|
createIssue(input: $input) {
|
||||||
issue {
|
issue {
|
||||||
|
id
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -167,7 +189,13 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
||||||
"repositoryId": repo.ID,
|
"repositoryId": repo.ID,
|
||||||
}
|
}
|
||||||
for key, val := range params {
|
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{}{
|
variables := map[string]interface{}{
|
||||||
"input": inputParams,
|
"input": inputParams,
|
||||||
|
|
@ -183,8 +211,23 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
type IssueStatusOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"github.com/shurcooL/githubv4"
|
"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) {
|
func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
|
||||||
type responseData struct {
|
type responseData struct {
|
||||||
Organization struct {
|
Organization struct {
|
||||||
|
|
@ -42,6 +42,45 @@ func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject,
|
||||||
return projects, nil
|
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 {
|
type OrgTeam struct {
|
||||||
ID string
|
ID string
|
||||||
Slug string
|
Slug string
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ type PullRequest struct {
|
||||||
Assignees Assignees
|
Assignees Assignees
|
||||||
Labels Labels
|
Labels Labels
|
||||||
ProjectCards ProjectCards
|
ProjectCards ProjectCards
|
||||||
|
ProjectItems ProjectItems
|
||||||
Milestone *Milestone
|
Milestone *Milestone
|
||||||
Comments Comments
|
Comments Comments
|
||||||
ReactionGroups ReactionGroups
|
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
|
return pr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ type PullRequestReviews struct {
|
||||||
|
|
||||||
type PullRequestReview struct {
|
type PullRequestReview struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Author Author `json:"author"`
|
Author CommentAuthor `json:"author"`
|
||||||
AuthorAssociation string `json:"authorAssociation"`
|
AuthorAssociation string `json:"authorAssociation"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
SubmittedAt *time.Time `json:"submittedAt"`
|
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
|
||||||
|
}
|
||||||
266
api/queries_projects_v2_test.go
Normal file
266
api/queries_projects_v2_test.go
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"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 }
|
||||||
|
}`
|
||||||
|
assert.Equal(t, stripSpace(expectedMutations), stripSpace(mutations))
|
||||||
|
if len(inputs) != 4 {
|
||||||
|
t.Fatalf("expected 4 inputs, got %d", len(inputs))
|
||||||
|
}
|
||||||
|
i0 := inputs["input_000"].(map[string]interface{})
|
||||||
|
i1 := inputs["input_001"].(map[string]interface{})
|
||||||
|
i2 := inputs["input_002"].(map[string]interface{})
|
||||||
|
i3 := inputs["input_003"].(map[string]interface{})
|
||||||
|
adds := []string{
|
||||||
|
fmt.Sprintf("%v -> %v", i0["contentId"], i0["projectId"]),
|
||||||
|
fmt.Sprintf("%v -> %v", i1["contentId"], i1["projectId"]),
|
||||||
|
}
|
||||||
|
removes := []string{
|
||||||
|
fmt.Sprintf("%v x %v", i2["itemId"], i2["projectId"]),
|
||||||
|
fmt.Sprintf("%v x %v", i3["itemId"], i3["projectId"]),
|
||||||
|
}
|
||||||
|
sort.Strings(adds)
|
||||||
|
sort.Strings(removes)
|
||||||
|
assert.Equal(t, []string{"item1 -> project1", "item2 -> project2"}, adds)
|
||||||
|
assert.Equal(t, []string{"item3 x project3", "item4 x project4"}, removes)
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -12,6 +13,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/internal/ghinstance"
|
"github.com/cli/cli/v2/internal/ghinstance"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/internal/ghrepo"
|
"github.com/cli/cli/v2/internal/ghrepo"
|
||||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||||
|
|
@ -43,6 +45,7 @@ type Repository struct {
|
||||||
IsSecurityPolicyEnabled bool
|
IsSecurityPolicyEnabled bool
|
||||||
HasIssuesEnabled bool
|
HasIssuesEnabled bool
|
||||||
HasProjectsEnabled bool
|
HasProjectsEnabled bool
|
||||||
|
HasDiscussionsEnabled bool
|
||||||
HasWikiEnabled bool
|
HasWikiEnabled bool
|
||||||
MergeCommitAllowed bool
|
MergeCommitAllowed bool
|
||||||
SquashMergeAllowed bool
|
SquashMergeAllowed bool
|
||||||
|
|
@ -501,7 +504,7 @@ type repositoryV3 struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForkRepo forks the repository on GitHub and returns the new repository
|
// ForkRepo forks the repository on GitHub and returns the new repository
|
||||||
func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string) (*Repository, error) {
|
func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string, defaultBranchOnly bool) (*Repository, error) {
|
||||||
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
|
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
|
||||||
|
|
||||||
params := map[string]interface{}{}
|
params := map[string]interface{}{}
|
||||||
|
|
@ -511,6 +514,9 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string) (*Repo
|
||||||
if newName != "" {
|
if newName != "" {
|
||||||
params["name"] = newName
|
params["name"] = newName
|
||||||
}
|
}
|
||||||
|
if defaultBranchOnly {
|
||||||
|
params["default_branch_only"] = true
|
||||||
|
}
|
||||||
|
|
||||||
body := &bytes.Buffer{}
|
body := &bytes.Buffer{}
|
||||||
enc := json.NewEncoder(body)
|
enc := json.NewEncoder(body)
|
||||||
|
|
@ -647,6 +653,7 @@ type RepoMetadataResult struct {
|
||||||
AssignableUsers []RepoAssignee
|
AssignableUsers []RepoAssignee
|
||||||
Labels []RepoLabel
|
Labels []RepoLabel
|
||||||
Projects []RepoProject
|
Projects []RepoProject
|
||||||
|
ProjectsV2 []RepoProjectV2
|
||||||
Milestones []RepoMilestone
|
Milestones []RepoMilestone
|
||||||
Teams []OrgTeam
|
Teams []OrgTeam
|
||||||
}
|
}
|
||||||
|
|
@ -706,25 +713,52 @@ func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) {
|
||||||
return ids, nil
|
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 ids []string
|
||||||
|
var idsV2 []string
|
||||||
for _, projectName := range names {
|
for _, projectName := range names {
|
||||||
found := false
|
id, found := m.projectNameToID(projectName)
|
||||||
for _, p := range m.Projects {
|
if found {
|
||||||
if strings.EqualFold(projectName, p.Name) {
|
ids = append(ids, id)
|
||||||
ids = append(ids, p.ID)
|
continue
|
||||||
found = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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
|
var paths []string
|
||||||
for _, projectName := range names {
|
for _, projectName := range names {
|
||||||
found := false
|
found := false
|
||||||
|
|
@ -744,6 +778,25 @@ func ProjectsToPaths(projects []RepoProject, names []string) ([]string, error) {
|
||||||
break
|
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 {
|
if !found {
|
||||||
return nil, fmt.Errorf("'%s' not found", projectName)
|
return nil, fmt.Errorf("'%s' not found", projectName)
|
||||||
}
|
}
|
||||||
|
|
@ -854,6 +907,18 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
|
||||||
errc <- nil
|
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 {
|
if input.Milestones {
|
||||||
count++
|
count++
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -985,7 +1050,15 @@ type RepoProject struct {
|
||||||
ResourcePath string `json:"resourcePath"`
|
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) {
|
func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
|
||||||
type responseData struct {
|
type responseData struct {
|
||||||
Repository struct {
|
Repository struct {
|
||||||
|
|
@ -1023,23 +1096,87 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
|
||||||
return projects, nil
|
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) {
|
func RepoAndOrgProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
|
||||||
projects, err := RepoProjects(client, repo)
|
projects, err := RepoProjects(client, repo)
|
||||||
if err != nil {
|
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)
|
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") {
|
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...)
|
projects = append(projects, orgProjects...)
|
||||||
|
|
||||||
return projects, nil
|
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 {
|
type RepoAssignee struct {
|
||||||
ID string
|
ID string
|
||||||
Login string
|
Login string
|
||||||
|
|
@ -1192,12 +1329,27 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
|
func ProjectNamesToPaths(client *Client, repo ghrepo.Interface, projectNames []string) ([]string, error) {
|
||||||
var paths []string
|
g, _ := errgroup.WithContext(context.Background())
|
||||||
projects, err := RepoAndOrgProjects(client, repo)
|
var projects []RepoProject
|
||||||
if err != nil {
|
var projectsV2 []RepoProjectV2
|
||||||
return paths, err
|
|
||||||
|
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) {
|
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 }
|
"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(
|
http.Register(
|
||||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||||
httpmock.StringResponse(`
|
httpmock.StringResponse(`
|
||||||
|
|
@ -99,6 +110,16 @@ func Test_RepoMetadata(t *testing.T) {
|
||||||
"pageInfo": { "hasNextPage": false }
|
"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(
|
http.Register(
|
||||||
httpmock.GraphQL(`query OrganizationTeamList\b`),
|
httpmock.GraphQL(`query OrganizationTeamList\b`),
|
||||||
httpmock.StringResponse(`
|
httpmock.StringResponse(`
|
||||||
|
|
@ -149,13 +170,17 @@ func Test_RepoMetadata(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedProjectIDs := []string{"TRIAGEID", "ROADMAPID"}
|
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 {
|
if err != nil {
|
||||||
t.Errorf("error resolving projects: %v", err)
|
t.Errorf("error resolving projects: %v", err)
|
||||||
}
|
}
|
||||||
if !sliceEqual(projectIDs, expectedProjectIDs) {
|
if !sliceEqual(projectIDs, expectedProjectIDs) {
|
||||||
t.Errorf("expected projects %v, got %v", expectedProjectIDs, projectIDs)
|
t.Errorf("expected projects %v, got %v", expectedProjectIDs, projectIDs)
|
||||||
}
|
}
|
||||||
|
if !sliceEqual(projectV2IDs, expectedProjectV2IDs) {
|
||||||
|
t.Errorf("expected projectsV2 %v, got %v", expectedProjectV2IDs, projectV2IDs)
|
||||||
|
}
|
||||||
|
|
||||||
expectedMilestoneID := "BIGONEID"
|
expectedMilestoneID := "BIGONEID"
|
||||||
milestoneID, err := result.MilestoneToID("big one.oh")
|
milestoneID, err := result.MilestoneToID("big one.oh")
|
||||||
|
|
@ -173,15 +198,19 @@ func Test_RepoMetadata(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Test_ProjectsToPaths(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{
|
projects := []RepoProject{
|
||||||
{ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"},
|
{ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"},
|
||||||
{ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/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"},
|
{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 {
|
if err != nil {
|
||||||
t.Errorf("error resolving projects: %v", err)
|
t.Errorf("error resolving projects: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -210,20 +239,41 @@ func Test_ProjectNamesToPaths(t *testing.T) {
|
||||||
http.Register(
|
http.Register(
|
||||||
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
httpmock.GraphQL(`query OrganizationProjectList\b`),
|
||||||
httpmock.StringResponse(`
|
httpmock.StringResponse(`
|
||||||
{ "data": { "organization": { "projects": {
|
{ "data": { "organization": { "projects": {
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
|
{ "name": "Triage", "id": "TRIAGEID", "resourcePath": "/orgs/ORG/projects/1" }
|
||||||
],
|
],
|
||||||
"pageInfo": { "hasNextPage": false }
|
"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 {
|
if err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
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) {
|
if !sliceEqual(projectPaths, expectedProjectPaths) {
|
||||||
t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
|
t.Errorf("expected projects paths %v, got %v", expectedProjectPaths, projectPaths)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ func IssueGraphQL(fields []string) string {
|
||||||
case "author":
|
case "author":
|
||||||
q = append(q, `author{login,...on User{id,name}}`)
|
q = append(q, `author{login,...on User{id,name}}`)
|
||||||
case "mergedBy":
|
case "mergedBy":
|
||||||
q = append(q, `mergedBy{login}`)
|
q = append(q, `mergedBy{login,...on User{id,name}}`)
|
||||||
case "headRepositoryOwner":
|
case "headRepositoryOwner":
|
||||||
q = append(q, `headRepositoryOwner{id,login,...on User{name}}`)
|
q = append(q, `headRepositoryOwner{id,login,...on User{name}}`)
|
||||||
case "headRepository":
|
case "headRepository":
|
||||||
|
|
@ -274,6 +274,8 @@ func IssueGraphQL(fields []string) string {
|
||||||
q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`)
|
q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`)
|
||||||
case "projectCards":
|
case "projectCards":
|
||||||
q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`)
|
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":
|
case "milestone":
|
||||||
q = append(q, `milestone{number,title,description,dueOn}`)
|
q = append(q, `milestone{number,title,description,dueOn}`)
|
||||||
case "reactionGroups":
|
case "reactionGroups":
|
||||||
|
|
@ -346,6 +348,7 @@ var RepositoryFields = []string{
|
||||||
"hasIssuesEnabled",
|
"hasIssuesEnabled",
|
||||||
"hasProjectsEnabled",
|
"hasProjectsEnabled",
|
||||||
"hasWikiEnabled",
|
"hasWikiEnabled",
|
||||||
|
"hasDiscussionsEnabled",
|
||||||
"mergeCommitAllowed",
|
"mergeCommitAllowed",
|
||||||
"squashMergeAllowed",
|
"squashMergeAllowed",
|
||||||
"rebaseMergeAllowed",
|
"rebaseMergeAllowed",
|
||||||
|
|
|
||||||
195
api/sanitize_ascii.go
Normal file
195
api/sanitize_ascii.go
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
|
||||||
|
|
||||||
|
// GitHub servers return non-printable characters as their unicode code point values.
|
||||||
|
// The values of \u0000 to \u001F represent C0 ASCII control characters and
|
||||||
|
// the values of \u0080 to \u009F represent C1 ASCII control characters. These control
|
||||||
|
// characters will be interpreted by the terminal, this behaviour can be used maliciously
|
||||||
|
// as an attack vector, especially the control character \u001B. This function wraps
|
||||||
|
// JSON response bodies in a ReadCloser that transforms C0 and C1 control characters
|
||||||
|
// to their caret and hex notations respectively so that the terminal will not interpret them.
|
||||||
|
func AddASCIISanitizer(rt http.RoundTripper) http.RoundTripper {
|
||||||
|
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||||
|
res, err := rt.RoundTrip(req)
|
||||||
|
if err != nil || !jsonTypeRE.MatchString(res.Header.Get("Content-Type")) {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
res.Body = &sanitizeASCIIReadCloser{ReadCloser: res.Body}
|
||||||
|
return res, err
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeASCIIReadCloser implements the ReadCloser interface.
|
||||||
|
type sanitizeASCIIReadCloser struct {
|
||||||
|
io.ReadCloser
|
||||||
|
addEscape bool
|
||||||
|
remainder []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read uses a sliding window alogorithm to detect C0 and C1
|
||||||
|
// ASCII control sequences as they are read and replaces them
|
||||||
|
// with equivelent inert characters. Characters that are not part
|
||||||
|
// of a control sequence not modified.
|
||||||
|
func (s *sanitizeASCIIReadCloser) Read(out []byte) (int, error) {
|
||||||
|
var bufIndex, outIndex int
|
||||||
|
outLen := len(out)
|
||||||
|
buf := make([]byte, outLen)
|
||||||
|
|
||||||
|
bufLen, readErr := s.ReadCloser.Read(buf)
|
||||||
|
if readErr != nil && !errors.Is(readErr, io.EOF) {
|
||||||
|
if bufLen > 0 {
|
||||||
|
// Do not sanitize if there was a read error that is not EOF.
|
||||||
|
bufLen = copy(out, buf)
|
||||||
|
}
|
||||||
|
return bufLen, readErr
|
||||||
|
}
|
||||||
|
buf = buf[:bufLen]
|
||||||
|
|
||||||
|
if s.remainder != nil {
|
||||||
|
buf = append(s.remainder, buf...)
|
||||||
|
bufLen += len(s.remainder)
|
||||||
|
s.remainder = s.remainder[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
for bufIndex < bufLen-6 && outIndex < outLen {
|
||||||
|
window := buf[bufIndex : bufIndex+6]
|
||||||
|
|
||||||
|
if bytes.HasPrefix(window, []byte(`\u00`)) {
|
||||||
|
repl, _ := mapControlCharacterToCaret(window)
|
||||||
|
if s.addEscape {
|
||||||
|
repl = append([]byte{'\\'}, repl...)
|
||||||
|
s.addEscape = false
|
||||||
|
}
|
||||||
|
for j := 0; j < len(repl); j++ {
|
||||||
|
if outIndex < outLen {
|
||||||
|
out[outIndex] = repl[j]
|
||||||
|
outIndex++
|
||||||
|
} else {
|
||||||
|
s.remainder = append(s.remainder, repl[j])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bufIndex += 6
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if window[0] == '\\' {
|
||||||
|
s.addEscape = !s.addEscape
|
||||||
|
} else {
|
||||||
|
s.addEscape = false
|
||||||
|
}
|
||||||
|
|
||||||
|
out[outIndex] = buf[bufIndex]
|
||||||
|
outIndex++
|
||||||
|
bufIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
if readErr != nil && errors.Is(readErr, io.EOF) {
|
||||||
|
remaining := bufLen - bufIndex
|
||||||
|
for j := 0; j < remaining; j++ {
|
||||||
|
if outIndex < outLen {
|
||||||
|
out[outIndex] = buf[bufIndex]
|
||||||
|
outIndex++
|
||||||
|
bufIndex++
|
||||||
|
} else {
|
||||||
|
s.remainder = append(s.remainder, buf[bufIndex])
|
||||||
|
bufIndex++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if bufIndex < bufLen {
|
||||||
|
s.remainder = append(s.remainder, buf[bufIndex:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(s.remainder) != 0 {
|
||||||
|
readErr = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return outIndex, readErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// mapControlCharacterToCaret maps C0 control sequences to caret notation
|
||||||
|
// and C1 control sequences to hex notation. C1 control sequences do not
|
||||||
|
// have caret notation representation.
|
||||||
|
func mapControlCharacterToCaret(b []byte) ([]byte, bool) {
|
||||||
|
m := map[string]string{
|
||||||
|
`\u0000`: `^@`,
|
||||||
|
`\u0001`: `^A`,
|
||||||
|
`\u0002`: `^B`,
|
||||||
|
`\u0003`: `^C`,
|
||||||
|
`\u0004`: `^D`,
|
||||||
|
`\u0005`: `^E`,
|
||||||
|
`\u0006`: `^F`,
|
||||||
|
`\u0007`: `^G`,
|
||||||
|
`\u0008`: `^H`,
|
||||||
|
`\u0009`: `^I`,
|
||||||
|
`\u000a`: `^J`,
|
||||||
|
`\u000b`: `^K`,
|
||||||
|
`\u000c`: `^L`,
|
||||||
|
`\u000d`: `^M`,
|
||||||
|
`\u000e`: `^N`,
|
||||||
|
`\u000f`: `^O`,
|
||||||
|
`\u0010`: `^P`,
|
||||||
|
`\u0011`: `^Q`,
|
||||||
|
`\u0012`: `^R`,
|
||||||
|
`\u0013`: `^S`,
|
||||||
|
`\u0014`: `^T`,
|
||||||
|
`\u0015`: `^U`,
|
||||||
|
`\u0016`: `^V`,
|
||||||
|
`\u0017`: `^W`,
|
||||||
|
`\u0018`: `^X`,
|
||||||
|
`\u0019`: `^Y`,
|
||||||
|
`\u001a`: `^Z`,
|
||||||
|
`\u001b`: `^[`,
|
||||||
|
`\u001c`: `^\\`,
|
||||||
|
`\u001d`: `^]`,
|
||||||
|
`\u001e`: `^^`,
|
||||||
|
`\u001f`: `^_`,
|
||||||
|
`\u0080`: `\\200`,
|
||||||
|
`\u0081`: `\\201`,
|
||||||
|
`\u0082`: `\\202`,
|
||||||
|
`\u0083`: `\\203`,
|
||||||
|
`\u0084`: `\\204`,
|
||||||
|
`\u0085`: `\\205`,
|
||||||
|
`\u0086`: `\\206`,
|
||||||
|
`\u0087`: `\\207`,
|
||||||
|
`\u0088`: `\\210`,
|
||||||
|
`\u0089`: `\\211`,
|
||||||
|
`\u008a`: `\\212`,
|
||||||
|
`\u008b`: `\\213`,
|
||||||
|
`\u008c`: `\\214`,
|
||||||
|
`\u008d`: `\\215`,
|
||||||
|
`\u008e`: `\\216`,
|
||||||
|
`\u008f`: `\\217`,
|
||||||
|
`\u0090`: `\\220`,
|
||||||
|
`\u0091`: `\\221`,
|
||||||
|
`\u0092`: `\\222`,
|
||||||
|
`\u0093`: `\\223`,
|
||||||
|
`\u0094`: `\\224`,
|
||||||
|
`\u0095`: `\\225`,
|
||||||
|
`\u0096`: `\\226`,
|
||||||
|
`\u0097`: `\\227`,
|
||||||
|
`\u0098`: `\\230`,
|
||||||
|
`\u0099`: `\\231`,
|
||||||
|
`\u009a`: `\\232`,
|
||||||
|
`\u009b`: `\\233`,
|
||||||
|
`\u009c`: `\\234`,
|
||||||
|
`\u009d`: `\\235`,
|
||||||
|
`\u009e`: `\\236`,
|
||||||
|
`\u009f`: `\\237`,
|
||||||
|
}
|
||||||
|
if c, ok := m[strings.ToLower(string(b))]; ok {
|
||||||
|
return []byte(c), true
|
||||||
|
}
|
||||||
|
return b, false
|
||||||
|
}
|
||||||
62
api/sanitize_ascii_test.go
Normal file
62
api/sanitize_ascii_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"testing/iotest"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHTTPClient_SanitizeASCIIControlCharacters(t *testing.T) {
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
issue := Issue{
|
||||||
|
Title: "\u001B[31mRed Title\u001B[0m",
|
||||||
|
Body: "1\u0001 2\u0002 3\u0003 4\u0004 5\u0005 6\u0006 7\u0007 8\u0008 9\t A\r\n B\u000b C\u000c D\r\n E\u000e F\u000f",
|
||||||
|
Author: Author{
|
||||||
|
ID: "1",
|
||||||
|
Name: "10\u0010 11\u0011 12\u0012 13\u0013 14\u0014 15\u0015 16\u0016 17\u0017 18\u0018 19\u0019 1A\u001a 1B\u001b 1C\u001c 1D\u001d 1E\u001e 1F\u001f",
|
||||||
|
Login: "monalisa",
|
||||||
|
},
|
||||||
|
ActiveLockReason: "Escaped \u001B \\u001B \\\u001B \\\\u001B",
|
||||||
|
}
|
||||||
|
responseData, _ := json.Marshal(issue)
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
fmt.Fprint(w, string(responseData))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client, err := NewHTTPClient(HTTPClientOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
req, err := http.NewRequest("GET", ts.URL, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res, err := client.Do(req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
require.NoError(t, err)
|
||||||
|
var issue Issue
|
||||||
|
err = json.Unmarshal(body, &issue)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "^[[31mRed Title^[[0m", issue.Title)
|
||||||
|
assert.Equal(t, "1^A 2^B 3^C 4^D 5^E 6^F 7^G 8^H 9\t A\r\n B^K C^L D\r\n E^N F^O", issue.Body)
|
||||||
|
assert.Equal(t, "10^P 11^Q 12^R 13^S 14^T 15^U 16^V 17^W 18^X 19^Y 1A^Z 1B^[ 1C^\\ 1D^] 1E^^ 1F^_", issue.Author.Name)
|
||||||
|
assert.Equal(t, "monalisa", issue.Author.Login)
|
||||||
|
assert.Equal(t, "Escaped ^[ \\^[ \\^[ \\\\^[", issue.ActiveLockReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeASCIIReadCloser(t *testing.T) {
|
||||||
|
data := []byte(`"Assign},"L`)
|
||||||
|
var r io.Reader = bytes.NewReader(data)
|
||||||
|
r = &sanitizeASCIIReadCloser{ReadCloser: io.NopCloser(r)}
|
||||||
|
r = iotest.OneByteReader(r)
|
||||||
|
out, err := io.ReadAll(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, data, out)
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
|
@ -53,17 +54,24 @@ func main() {
|
||||||
func mainRun() exitCode {
|
func mainRun() exitCode {
|
||||||
buildDate := build.Date
|
buildDate := build.Date
|
||||||
buildVersion := build.Version
|
buildVersion := build.Version
|
||||||
|
|
||||||
updateMessageChan := make(chan *update.ReleaseInfo)
|
|
||||||
go func() {
|
|
||||||
rel, _ := checkForUpdate(buildVersion)
|
|
||||||
updateMessageChan <- rel
|
|
||||||
}()
|
|
||||||
|
|
||||||
hasDebug, _ := utils.IsDebugEnabled()
|
hasDebug, _ := utils.IsDebugEnabled()
|
||||||
|
|
||||||
cmdFactory := factory.New(buildVersion)
|
cmdFactory := factory.New(buildVersion)
|
||||||
stderr := cmdFactory.IOStreams.ErrOut
|
stderr := cmdFactory.IOStreams.ErrOut
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
updateCtx, updateCancel := context.WithCancel(ctx)
|
||||||
|
defer updateCancel()
|
||||||
|
updateMessageChan := make(chan *update.ReleaseInfo)
|
||||||
|
go func() {
|
||||||
|
rel, err := checkForUpdate(updateCtx, cmdFactory, buildVersion)
|
||||||
|
if err != nil && hasDebug {
|
||||||
|
fmt.Fprintf(stderr, "warning: checking for update failed: %v", err)
|
||||||
|
}
|
||||||
|
updateMessageChan <- rel
|
||||||
|
}()
|
||||||
|
|
||||||
if !cmdFactory.IOStreams.ColorEnabled() {
|
if !cmdFactory.IOStreams.ColorEnabled() {
|
||||||
surveyCore.DisableColor = true
|
surveyCore.DisableColor = true
|
||||||
ansi.DisableColors(true)
|
ansi.DisableColors(true)
|
||||||
|
|
@ -209,7 +217,7 @@ func mainRun() exitCode {
|
||||||
|
|
||||||
rootCmd.SetArgs(expandedArgs)
|
rootCmd.SetArgs(expandedArgs)
|
||||||
|
|
||||||
if cmd, err := rootCmd.ExecuteC(); err != nil {
|
if cmd, err := rootCmd.ExecuteContextC(ctx); err != nil {
|
||||||
var pagerPipeError *iostreams.ErrClosedPagerPipe
|
var pagerPipeError *iostreams.ErrClosedPagerPipe
|
||||||
var noResultsError cmdutil.NoResultsError
|
var noResultsError cmdutil.NoResultsError
|
||||||
if err == cmdutil.SilentError {
|
if err == cmdutil.SilentError {
|
||||||
|
|
@ -257,6 +265,7 @@ func mainRun() exitCode {
|
||||||
return exitError
|
return exitError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCancel() // if the update checker hasn't completed by now, abort it
|
||||||
newRelease := <-updateMessageChan
|
newRelease := <-updateMessageChan
|
||||||
if newRelease != nil {
|
if newRelease != nil {
|
||||||
isHomebrew := isUnderHomebrew(cmdFactory.Executable())
|
isHomebrew := isUnderHomebrew(cmdFactory.Executable())
|
||||||
|
|
@ -348,21 +357,17 @@ func isCI() bool {
|
||||||
os.Getenv("RUN_ID") != "" // TaskCluster, dsari
|
os.Getenv("RUN_ID") != "" // TaskCluster, dsari
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
|
func checkForUpdate(ctx context.Context, f *cmdutil.Factory, currentVersion string) (*update.ReleaseInfo, error) {
|
||||||
if !shouldCheckForUpdate() {
|
if !shouldCheckForUpdate() {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
httpClient, err := api.NewHTTPClient(api.HTTPClientOptions{
|
httpClient, err := f.HttpClient()
|
||||||
AppVersion: currentVersion,
|
|
||||||
Log: os.Stderr,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
client := api.NewClientFromHTTP(httpClient)
|
|
||||||
repo := updaterEnabled
|
repo := updaterEnabled
|
||||||
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
|
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
|
||||||
return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
|
return update.CheckForUpdate(ctx, httpClient, stateFilePath, repo, currentVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isRecentRelease(publishedAt time.Time) bool {
|
func isRecentRelease(publishedAt time.Time) bool {
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ import (
|
||||||
"github.com/cli/cli/v2/pkg/iostreams"
|
"github.com/cli/cli/v2/pkg/iostreams"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cap the number of git remotes looked up, since the user might have an
|
// Cap the number of git remotes to look up, since the user might have an
|
||||||
// unusually large number of git remotes
|
// unusually large number of git remotes.
|
||||||
const maxRemotesForLookup = 5
|
const defaultRemotesForLookup = 5
|
||||||
|
|
||||||
func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (*ResolvedRemotes, error) {
|
func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (*ResolvedRemotes, error) {
|
||||||
sort.Stable(remotes)
|
sort.Stable(remotes)
|
||||||
|
|
@ -36,11 +36,11 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (*R
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveNetwork(result *ResolvedRemotes) error {
|
func resolveNetwork(result *ResolvedRemotes, remotesForLookup int) error {
|
||||||
var repos []ghrepo.Interface
|
var repos []ghrepo.Interface
|
||||||
for _, r := range result.remotes {
|
for _, r := range result.remotes {
|
||||||
repos = append(repos, r)
|
repos = append(repos, r)
|
||||||
if len(repos) == maxRemotesForLookup {
|
if len(repos) == remotesForLookup {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +84,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
|
||||||
return r.remotes[0], nil
|
return r.remotes[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
repos, err := r.NetworkRepos()
|
repos, err := r.NetworkRepos(defaultRemotesForLookup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -109,7 +109,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
|
||||||
|
|
||||||
func (r *ResolvedRemotes) HeadRepos() ([]*api.Repository, error) {
|
func (r *ResolvedRemotes) HeadRepos() ([]*api.Repository, error) {
|
||||||
if r.network == nil {
|
if r.network == nil {
|
||||||
err := resolveNetwork(r)
|
err := resolveNetwork(r, defaultRemotesForLookup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -124,9 +124,11 @@ func (r *ResolvedRemotes) HeadRepos() ([]*api.Repository, error) {
|
||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *ResolvedRemotes) NetworkRepos() ([]*api.Repository, error) {
|
// NetworkRepos fetches info about remotes for the network of repos.
|
||||||
|
// Pass a value of 0 to fetch info on all remotes.
|
||||||
|
func (r *ResolvedRemotes) NetworkRepos(remotesForLookup int) ([]*api.Repository, error) {
|
||||||
if r.network == nil {
|
if r.network == nil {
|
||||||
err := resolveNetwork(r)
|
err := resolveNetwork(r, remotesForLookup)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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)\)`)
|
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
|
||||||
|
|
||||||
|
type errWithExitCode interface {
|
||||||
|
ExitCode() int
|
||||||
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
GhPath string
|
GhPath string
|
||||||
RepoDir string
|
RepoDir string
|
||||||
|
|
@ -390,7 +394,10 @@ func (c *Client) revParse(ctx context.Context, args ...string) ([]byte, error) {
|
||||||
// Below are commands that make network calls and need authentication credentials supplied from gh.
|
// Below are commands that make network calls and need authentication credentials supplied from gh.
|
||||||
|
|
||||||
func (c *Client) Fetch(ctx context.Context, remote string, refspec string, mods ...CommandModifier) error {
|
func (c *Client) Fetch(ctx context.Context, remote string, refspec string, mods ...CommandModifier) error {
|
||||||
args := []string{"fetch", remote, refspec}
|
args := []string{"fetch", remote}
|
||||||
|
if refspec != "" {
|
||||||
|
args = append(args, refspec)
|
||||||
|
}
|
||||||
cmd, err := c.AuthenticatedCommand(ctx, args...)
|
cmd, err := c.AuthenticatedCommand(ctx, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -458,8 +465,8 @@ func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBra
|
||||||
for _, branch := range trackingBranches {
|
for _, branch := range trackingBranches {
|
||||||
args = append(args, "-t", branch)
|
args = append(args, "-t", branch)
|
||||||
}
|
}
|
||||||
args = append(args, "-f", name, urlStr)
|
args = append(args, name, urlStr)
|
||||||
cmd, err := c.AuthenticatedCommand(ctx, args...)
|
cmd, err := c.Command(ctx, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -489,21 +496,16 @@ func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBra
|
||||||
return remote, nil
|
return remote, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) InGitDirectory(ctx context.Context) bool {
|
func (c *Client) IsLocalGitRepo(ctx context.Context) (bool, error) {
|
||||||
showCmd, err := c.Command(ctx, "rev-parse", "--is-inside-work-tree")
|
_, err := c.GitDir(ctx)
|
||||||
if err != nil {
|
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()
|
return true, nil
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
split := strings.Split(string(out), "\n")
|
|
||||||
if len(split) > 0 {
|
|
||||||
return split[0] == "true"
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error {
|
func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error {
|
||||||
|
|
|
||||||
|
|
@ -1089,7 +1089,7 @@ func TestClientAddRemote(t *testing.T) {
|
||||||
url: "URL",
|
url: "URL",
|
||||||
dir: "DIRECTORY",
|
dir: "DIRECTORY",
|
||||||
branches: []string{},
|
branches: []string{},
|
||||||
wantCmdArgs: `path/to/git -C DIRECTORY -c credential.helper= -c credential.helper=!"gh" auth git-credential remote add -f test URL`,
|
wantCmdArgs: `path/to/git -C DIRECTORY remote add test URL`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "fetch specific branches only",
|
title: "fetch specific branches only",
|
||||||
|
|
@ -1097,7 +1097,7 @@ func TestClientAddRemote(t *testing.T) {
|
||||||
url: "URL",
|
url: "URL",
|
||||||
dir: "DIRECTORY",
|
dir: "DIRECTORY",
|
||||||
branches: []string{"trunk", "dev"},
|
branches: []string{"trunk", "dev"},
|
||||||
wantCmdArgs: `path/to/git -C DIRECTORY -c credential.helper= -c credential.helper=!"gh" auth git-credential remote add -t trunk -t dev -f test URL`,
|
wantCmdArgs: `path/to/git -C DIRECTORY remote add -t trunk -t dev test URL`,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
6
go.mod
6
go.mod
|
|
@ -10,7 +10,7 @@ require (
|
||||||
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
|
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
|
||||||
github.com/charmbracelet/lipgloss v0.5.0
|
github.com/charmbracelet/lipgloss v0.5.0
|
||||||
github.com/cli/go-gh v1.0.0
|
github.com/cli/go-gh v1.0.0
|
||||||
github.com/cli/oauth v0.9.0
|
github.com/cli/oauth v1.0.1
|
||||||
github.com/cli/safeexec v1.0.1
|
github.com/cli/safeexec v1.0.1
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.2
|
github.com/cpuguy83/go-md2man/v2 v2.0.2
|
||||||
github.com/creack/pty v1.1.18
|
github.com/creack/pty v1.1.18
|
||||||
|
|
@ -22,7 +22,7 @@ require (
|
||||||
github.com/hashicorp/go-multierror v1.1.1
|
github.com/hashicorp/go-multierror v1.1.1
|
||||||
github.com/hashicorp/go-version v1.3.0
|
github.com/hashicorp/go-version v1.3.0
|
||||||
github.com/henvic/httpretty v0.0.6
|
github.com/henvic/httpretty v0.0.6
|
||||||
github.com/joho/godotenv v1.4.0
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||||
github.com/mattn/go-colorable v0.1.13
|
github.com/mattn/go-colorable v0.1.13
|
||||||
github.com/mattn/go-isatty v0.0.17
|
github.com/mattn/go-isatty v0.0.17
|
||||||
|
|
@ -81,3 +81,5 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
replace golang.org/x/crypto => github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03
|
replace golang.org/x/crypto => github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03
|
||||||
|
|
||||||
|
replace github.com/henvic/httpretty v0.0.6 => github.com/mislav/httpretty v0.1.1-0.20230202151216-d31343e0d884
|
||||||
|
|
|
||||||
19
go.sum
19
go.sum
|
|
@ -62,8 +62,8 @@ github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai
|
||||||
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
github.com/cli/go-gh v1.0.0 h1:zE1YUAUYqGXNZuICEBeOkIMJ5F50BS0ftvtoWGlsEFI=
|
github.com/cli/go-gh v1.0.0 h1:zE1YUAUYqGXNZuICEBeOkIMJ5F50BS0ftvtoWGlsEFI=
|
||||||
github.com/cli/go-gh v1.0.0/go.mod h1:bqxLdCoTZ73BuiPEJx4olcO/XKhVZaFDchFagYRBweE=
|
github.com/cli/go-gh v1.0.0/go.mod h1:bqxLdCoTZ73BuiPEJx4olcO/XKhVZaFDchFagYRBweE=
|
||||||
github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc=
|
github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA=
|
||||||
github.com/cli/oauth v0.9.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
|
github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
|
||||||
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
||||||
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
|
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
|
||||||
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
||||||
|
|
@ -164,8 +164,6 @@ github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04
|
||||||
github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs=
|
|
||||||
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
|
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||||
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
|
|
@ -175,8 +173,8 @@ github.com/itchyny/gojq v0.12.8 h1:Zxcwq8w4IeR8JJYEtoG2MWJZUv0RGY6QqJcO1cqV8+A=
|
||||||
github.com/itchyny/gojq v0.12.8/go.mod h1:gE2kZ9fVRU0+JAksaTzjIlgnCa2akU+a1V0WXgJQN5c=
|
github.com/itchyny/gojq v0.12.8/go.mod h1:gE2kZ9fVRU0+JAksaTzjIlgnCa2akU+a1V0WXgJQN5c=
|
||||||
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
|
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
|
||||||
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
|
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
|
||||||
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
github.com/joho/godotenv v1.4.0/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||||
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
|
@ -209,6 +207,8 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyex
|
||||||
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
|
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
|
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
|
||||||
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
|
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
|
||||||
|
github.com/mislav/httpretty v0.1.1-0.20230202151216-d31343e0d884 h1:JQp1j1IWuMQZc2tyDQ9KmksjQbw5MhUOzWzZZn7WyU0=
|
||||||
|
github.com/mislav/httpretty v0.1.1-0.20230202151216-d31343e0d884/go.mod h1:ViEsly7wgdugYtymX54pYp6Vv2wqZmNHayJ6q8tlKCc=
|
||||||
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
|
||||||
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
|
||||||
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
|
||||||
|
|
@ -259,6 +259,7 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
|
github.com/yuin/goldmark v1.4.4/go.mod h1:rmuwmfZ0+bvzB24eSC//bk1R1Zp3hM0OXYv/G2LIilg=
|
||||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
|
@ -299,6 +300,7 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
|
||||||
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
|
@ -327,6 +329,7 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
|
||||||
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||||
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
|
@ -347,6 +350,7 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
|
||||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|
@ -382,6 +386,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|
@ -446,6 +451,8 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
|
||||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
|
golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||||
|
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
|
@ -70,7 +71,6 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session
|
||||||
defer progress.StopProgressIndicator()
|
defer progress.StopProgressIndicator()
|
||||||
|
|
||||||
return liveshare.Connect(ctx, liveshare.Options{
|
return liveshare.Connect(ctx, liveshare.Options{
|
||||||
ClientName: "gh",
|
|
||||||
SessionID: codespace.Connection.SessionID,
|
SessionID: codespace.Connection.SessionID,
|
||||||
SessionToken: codespace.Connection.SessionToken,
|
SessionToken: codespace.Connection.SessionToken,
|
||||||
RelaySAS: codespace.Connection.RelaySAS,
|
RelaySAS: codespace.Connection.RelaySAS,
|
||||||
|
|
@ -79,3 +79,18 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session
|
||||||
Logger: sessionLogger,
|
Logger: sessionLogger,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListenTCP starts a localhost tcp listener and returns the listener and bound port
|
||||||
|
func ListenTCP(port int) (*net.TCPListener, int, error) {
|
||||||
|
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to build tcp address: %w", err)
|
||||||
|
}
|
||||||
|
listener, err := net.ListenTCP("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, fmt.Errorf("failed to listen to local port over tcp: %w", err)
|
||||||
|
}
|
||||||
|
port = listener.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
return listener, port, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.28.0
|
// protoc-gen-go v1.28.1
|
||||||
// protoc v3.21.12
|
// protoc v3.21.12
|
||||||
// source: codespace/codespace_host_service.v1.proto
|
// source: codespace/codespace_host_service.v1.proto
|
||||||
|
|
||||||
|
|
@ -20,6 +20,116 @@ const (
|
||||||
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
_ = 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 {
|
type RebuildContainerRequest struct {
|
||||||
state protoimpl.MessageState
|
state protoimpl.MessageState
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
|
|
@ -31,7 +141,7 @@ type RebuildContainerRequest struct {
|
||||||
func (x *RebuildContainerRequest) Reset() {
|
func (x *RebuildContainerRequest) Reset() {
|
||||||
*x = RebuildContainerRequest{}
|
*x = RebuildContainerRequest{}
|
||||||
if protoimpl.UnsafeEnabled {
|
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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
|
@ -44,7 +154,7 @@ func (x *RebuildContainerRequest) String() string {
|
||||||
func (*RebuildContainerRequest) ProtoMessage() {}
|
func (*RebuildContainerRequest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *RebuildContainerRequest) ProtoReflect() protoreflect.Message {
|
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 {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
|
@ -57,7 +167,7 @@ func (x *RebuildContainerRequest) ProtoReflect() protoreflect.Message {
|
||||||
|
|
||||||
// Deprecated: Use RebuildContainerRequest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use RebuildContainerRequest.ProtoReflect.Descriptor instead.
|
||||||
func (*RebuildContainerRequest) Descriptor() ([]byte, []int) {
|
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 {
|
func (x *RebuildContainerRequest) GetIncremental() bool {
|
||||||
|
|
@ -78,7 +188,7 @@ type RebuildContainerResponse struct {
|
||||||
func (x *RebuildContainerResponse) Reset() {
|
func (x *RebuildContainerResponse) Reset() {
|
||||||
*x = RebuildContainerResponse{}
|
*x = RebuildContainerResponse{}
|
||||||
if protoimpl.UnsafeEnabled {
|
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 := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +201,7 @@ func (x *RebuildContainerResponse) String() string {
|
||||||
func (*RebuildContainerResponse) ProtoMessage() {}
|
func (*RebuildContainerResponse) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *RebuildContainerResponse) ProtoReflect() protoreflect.Message {
|
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 {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
|
@ -104,7 +214,7 @@ func (x *RebuildContainerResponse) ProtoReflect() protoreflect.Message {
|
||||||
|
|
||||||
// Deprecated: Use RebuildContainerResponse.ProtoReflect.Descriptor instead.
|
// Deprecated: Use RebuildContainerResponse.ProtoReflect.Descriptor instead.
|
||||||
func (*RebuildContainerResponse) Descriptor() ([]byte, []int) {
|
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 {
|
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,
|
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, 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, 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,
|
0x65, 0x2e, 0x76, 0x31, 0x22, 0x70, 0x0a, 0x26, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x43, 0x6f,
|
||||||
0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x66, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41,
|
||||||
0x25, 0x0a, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x18, 0x01,
|
0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a,
|
||||||
0x20, 0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e,
|
0x0a, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||||
0x74, 0x61, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x49, 0x6e, 0x63, 0x72, 0x65,
|
0x52, 0x08, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2a, 0x0a, 0x10, 0x43, 0x6c,
|
||||||
0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x22, 0x46, 0x0a, 0x18, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c,
|
0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x02,
|
||||||
0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x41, 0x63, 0x74, 0x69,
|
||||||
0x73, 0x65, 0x12, 0x2a, 0x0a, 0x10, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e,
|
0x76, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x5b, 0x0a, 0x27, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79,
|
||||||
0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x52, 0x65,
|
0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x4f, 0x66, 0x43, 0x6c, 0x69, 0x65, 0x6e,
|
||||||
0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x32, 0xae,
|
0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||||
0x01, 0x0a, 0x0d, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74,
|
0x65, 0x12, 0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||||
0x12, 0x9c, 0x01, 0x0a, 0x15, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74,
|
0x08, 0x52, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73,
|
||||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x41, 0x73, 0x79, 0x6e, 0x63, 0x12, 0x40, 0x2e, 0x43, 0x6f, 0x64,
|
0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73,
|
||||||
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x43, 0x6f, 0x64,
|
0x61, 0x67, 0x65, 0x22, 0x50, 0x0a, 0x17, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f,
|
||||||
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
|
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x25,
|
||||||
0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74,
|
0x0a, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x6c, 0x18, 0x01, 0x20,
|
||||||
0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x41, 0x2e, 0x43,
|
0x01, 0x28, 0x08, 0x48, 0x00, 0x52, 0x0b, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||||
0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x43,
|
0x61, 0x6c, 0x88, 0x01, 0x01, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x49, 0x6e, 0x63, 0x72, 0x65, 0x6d,
|
||||||
0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76,
|
0x65, 0x6e, 0x74, 0x61, 0x6c, 0x22, 0x46, 0x0a, 0x18, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64,
|
||||||
0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f,
|
0x43, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||||
0x6e, 0x74, 0x61, 0x69, 0x6e, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42,
|
0x65, 0x12, 0x2a, 0x0a, 0x10, 0x52, 0x65, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x6f, 0x6e, 0x74,
|
||||||
0x0d, 0x5a, 0x0b, 0x2e, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x62, 0x06,
|
0x61, 0x69, 0x6e, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x52, 0x65, 0x62,
|
||||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
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 (
|
var (
|
||||||
|
|
@ -159,16 +294,20 @@ func file_codespace_codespace_host_service_v1_proto_rawDescGZIP() []byte {
|
||||||
return file_codespace_codespace_host_service_v1_proto_rawDescData
|
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{}{
|
var file_codespace_codespace_host_service_v1_proto_goTypes = []interface{}{
|
||||||
(*RebuildContainerRequest)(nil), // 0: Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerRequest
|
(*NotifyCodespaceOfClientActivityRequest)(nil), // 0: Codespaces.Grpc.CodespaceHostService.v1.NotifyCodespaceOfClientActivityRequest
|
||||||
(*RebuildContainerResponse)(nil), // 1: Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerResponse
|
(*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{
|
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
|
0, // 0: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.NotifyCodespaceOfClientActivity:input_type -> Codespaces.Grpc.CodespaceHostService.v1.NotifyCodespaceOfClientActivityRequest
|
||||||
1, // 1: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.RebuildContainerAsync:output_type -> Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerResponse
|
2, // 1: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.RebuildContainerAsync:input_type -> Codespaces.Grpc.CodespaceHostService.v1.RebuildContainerRequest
|
||||||
1, // [1:2] is the sub-list for method output_type
|
1, // 2: Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost.NotifyCodespaceOfClientActivity:output_type -> Codespaces.Grpc.CodespaceHostService.v1.NotifyCodespaceOfClientActivityResponse
|
||||||
0, // [0:1] is the sub-list for method input_type
|
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 type_name
|
||||||
0, // [0:0] is the sub-list for extension extendee
|
0, // [0:0] is the sub-list for extension extendee
|
||||||
0, // [0:0] is the sub-list for field type_name
|
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 {
|
if !protoimpl.UnsafeEnabled {
|
||||||
file_codespace_codespace_host_service_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
|
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:
|
case 0:
|
||||||
return &v.state
|
return &v.state
|
||||||
case 1:
|
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{} {
|
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 {
|
switch v := v.(*RebuildContainerResponse); i {
|
||||||
case 0:
|
case 0:
|
||||||
return &v.state
|
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{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: file_codespace_codespace_host_service_v1_proto_rawDesc,
|
RawDescriptor: file_codespace_codespace_host_service_v1_proto_rawDesc,
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 2,
|
NumMessages: 4,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,19 @@ option go_package = "./codespace";
|
||||||
package Codespaces.Grpc.CodespaceHostService.v1;
|
package Codespaces.Grpc.CodespaceHostService.v1;
|
||||||
|
|
||||||
service CodespaceHost {
|
service CodespaceHost {
|
||||||
|
rpc NotifyCodespaceOfClientActivity (NotifyCodespaceOfClientActivityRequest) returns (NotifyCodespaceOfClientActivityResponse);
|
||||||
rpc RebuildContainerAsync (RebuildContainerRequest) returns (RebuildContainerResponse);
|
rpc RebuildContainerAsync (RebuildContainerRequest) returns (RebuildContainerResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message NotifyCodespaceOfClientActivityRequest {
|
||||||
|
string ClientId = 1;
|
||||||
|
repeated string ClientActivities = 2;
|
||||||
|
}
|
||||||
|
message NotifyCodespaceOfClientActivityResponse {
|
||||||
|
bool Result = 1;
|
||||||
|
string Message = 2;
|
||||||
|
}
|
||||||
|
|
||||||
message RebuildContainerRequest {
|
message RebuildContainerRequest {
|
||||||
optional bool Incremental = 1;
|
optional bool Incremental = 1;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
// Code generated by moq; DO NOT EDIT.
|
||||||
|
// github.com/matryer/moq
|
||||||
|
|
||||||
|
package codespace
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure, that CodespaceHostServerMock does implement CodespaceHostServer.
|
||||||
|
// If this is not the case, regenerate this file with moq.
|
||||||
|
var _ CodespaceHostServer = &CodespaceHostServerMock{}
|
||||||
|
|
||||||
|
// CodespaceHostServerMock is a mock implementation of CodespaceHostServer.
|
||||||
|
//
|
||||||
|
// func TestSomethingThatUsesCodespaceHostServer(t *testing.T) {
|
||||||
|
//
|
||||||
|
// // make and configure a mocked CodespaceHostServer
|
||||||
|
// mockedCodespaceHostServer := &CodespaceHostServerMock{
|
||||||
|
// NotifyCodespaceOfClientActivityFunc: func(contextMoqParam context.Context, notifyCodespaceOfClientActivityRequest *NotifyCodespaceOfClientActivityRequest) (*NotifyCodespaceOfClientActivityResponse, error) {
|
||||||
|
// panic("mock out the NotifyCodespaceOfClientActivity method")
|
||||||
|
// },
|
||||||
|
// RebuildContainerAsyncFunc: func(contextMoqParam context.Context, rebuildContainerRequest *RebuildContainerRequest) (*RebuildContainerResponse, error) {
|
||||||
|
// panic("mock out the RebuildContainerAsync method")
|
||||||
|
// },
|
||||||
|
// mustEmbedUnimplementedCodespaceHostServerFunc: func() {
|
||||||
|
// panic("mock out the mustEmbedUnimplementedCodespaceHostServer method")
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // use mockedCodespaceHostServer in code that requires CodespaceHostServer
|
||||||
|
// // and then make assertions.
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
type CodespaceHostServerMock struct {
|
||||||
|
// NotifyCodespaceOfClientActivityFunc mocks the NotifyCodespaceOfClientActivity method.
|
||||||
|
NotifyCodespaceOfClientActivityFunc func(contextMoqParam context.Context, notifyCodespaceOfClientActivityRequest *NotifyCodespaceOfClientActivityRequest) (*NotifyCodespaceOfClientActivityResponse, error)
|
||||||
|
|
||||||
|
// RebuildContainerAsyncFunc mocks the RebuildContainerAsync method.
|
||||||
|
RebuildContainerAsyncFunc func(contextMoqParam context.Context, rebuildContainerRequest *RebuildContainerRequest) (*RebuildContainerResponse, error)
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedCodespaceHostServerFunc mocks the mustEmbedUnimplementedCodespaceHostServer method.
|
||||||
|
mustEmbedUnimplementedCodespaceHostServerFunc func()
|
||||||
|
|
||||||
|
// calls tracks calls to the methods.
|
||||||
|
calls struct {
|
||||||
|
// NotifyCodespaceOfClientActivity holds details about calls to the NotifyCodespaceOfClientActivity method.
|
||||||
|
NotifyCodespaceOfClientActivity []struct {
|
||||||
|
// ContextMoqParam is the contextMoqParam argument value.
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
// NotifyCodespaceOfClientActivityRequest is the notifyCodespaceOfClientActivityRequest argument value.
|
||||||
|
NotifyCodespaceOfClientActivityRequest *NotifyCodespaceOfClientActivityRequest
|
||||||
|
}
|
||||||
|
// RebuildContainerAsync holds details about calls to the RebuildContainerAsync method.
|
||||||
|
RebuildContainerAsync []struct {
|
||||||
|
// ContextMoqParam is the contextMoqParam argument value.
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
// RebuildContainerRequest is the rebuildContainerRequest argument value.
|
||||||
|
RebuildContainerRequest *RebuildContainerRequest
|
||||||
|
}
|
||||||
|
// mustEmbedUnimplementedCodespaceHostServer holds details about calls to the mustEmbedUnimplementedCodespaceHostServer method.
|
||||||
|
mustEmbedUnimplementedCodespaceHostServer []struct {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lockNotifyCodespaceOfClientActivity sync.RWMutex
|
||||||
|
lockRebuildContainerAsync sync.RWMutex
|
||||||
|
lockmustEmbedUnimplementedCodespaceHostServer sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyCodespaceOfClientActivity calls NotifyCodespaceOfClientActivityFunc.
|
||||||
|
func (mock *CodespaceHostServerMock) NotifyCodespaceOfClientActivity(contextMoqParam context.Context, notifyCodespaceOfClientActivityRequest *NotifyCodespaceOfClientActivityRequest) (*NotifyCodespaceOfClientActivityResponse, error) {
|
||||||
|
if mock.NotifyCodespaceOfClientActivityFunc == nil {
|
||||||
|
panic("CodespaceHostServerMock.NotifyCodespaceOfClientActivityFunc: method is nil but CodespaceHostServer.NotifyCodespaceOfClientActivity was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
NotifyCodespaceOfClientActivityRequest *NotifyCodespaceOfClientActivityRequest
|
||||||
|
}{
|
||||||
|
ContextMoqParam: contextMoqParam,
|
||||||
|
NotifyCodespaceOfClientActivityRequest: notifyCodespaceOfClientActivityRequest,
|
||||||
|
}
|
||||||
|
mock.lockNotifyCodespaceOfClientActivity.Lock()
|
||||||
|
mock.calls.NotifyCodespaceOfClientActivity = append(mock.calls.NotifyCodespaceOfClientActivity, callInfo)
|
||||||
|
mock.lockNotifyCodespaceOfClientActivity.Unlock()
|
||||||
|
return mock.NotifyCodespaceOfClientActivityFunc(contextMoqParam, notifyCodespaceOfClientActivityRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotifyCodespaceOfClientActivityCalls gets all the calls that were made to NotifyCodespaceOfClientActivity.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedCodespaceHostServer.NotifyCodespaceOfClientActivityCalls())
|
||||||
|
func (mock *CodespaceHostServerMock) NotifyCodespaceOfClientActivityCalls() []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
NotifyCodespaceOfClientActivityRequest *NotifyCodespaceOfClientActivityRequest
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
NotifyCodespaceOfClientActivityRequest *NotifyCodespaceOfClientActivityRequest
|
||||||
|
}
|
||||||
|
mock.lockNotifyCodespaceOfClientActivity.RLock()
|
||||||
|
calls = mock.calls.NotifyCodespaceOfClientActivity
|
||||||
|
mock.lockNotifyCodespaceOfClientActivity.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildContainerAsync calls RebuildContainerAsyncFunc.
|
||||||
|
func (mock *CodespaceHostServerMock) RebuildContainerAsync(contextMoqParam context.Context, rebuildContainerRequest *RebuildContainerRequest) (*RebuildContainerResponse, error) {
|
||||||
|
if mock.RebuildContainerAsyncFunc == nil {
|
||||||
|
panic("CodespaceHostServerMock.RebuildContainerAsyncFunc: method is nil but CodespaceHostServer.RebuildContainerAsync was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
RebuildContainerRequest *RebuildContainerRequest
|
||||||
|
}{
|
||||||
|
ContextMoqParam: contextMoqParam,
|
||||||
|
RebuildContainerRequest: rebuildContainerRequest,
|
||||||
|
}
|
||||||
|
mock.lockRebuildContainerAsync.Lock()
|
||||||
|
mock.calls.RebuildContainerAsync = append(mock.calls.RebuildContainerAsync, callInfo)
|
||||||
|
mock.lockRebuildContainerAsync.Unlock()
|
||||||
|
return mock.RebuildContainerAsyncFunc(contextMoqParam, rebuildContainerRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebuildContainerAsyncCalls gets all the calls that were made to RebuildContainerAsync.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedCodespaceHostServer.RebuildContainerAsyncCalls())
|
||||||
|
func (mock *CodespaceHostServerMock) RebuildContainerAsyncCalls() []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
RebuildContainerRequest *RebuildContainerRequest
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
RebuildContainerRequest *RebuildContainerRequest
|
||||||
|
}
|
||||||
|
mock.lockRebuildContainerAsync.RLock()
|
||||||
|
calls = mock.calls.RebuildContainerAsync
|
||||||
|
mock.lockRebuildContainerAsync.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedCodespaceHostServer calls mustEmbedUnimplementedCodespaceHostServerFunc.
|
||||||
|
func (mock *CodespaceHostServerMock) mustEmbedUnimplementedCodespaceHostServer() {
|
||||||
|
if mock.mustEmbedUnimplementedCodespaceHostServerFunc == nil {
|
||||||
|
panic("CodespaceHostServerMock.mustEmbedUnimplementedCodespaceHostServerFunc: method is nil but CodespaceHostServer.mustEmbedUnimplementedCodespaceHostServer was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
}{}
|
||||||
|
mock.lockmustEmbedUnimplementedCodespaceHostServer.Lock()
|
||||||
|
mock.calls.mustEmbedUnimplementedCodespaceHostServer = append(mock.calls.mustEmbedUnimplementedCodespaceHostServer, callInfo)
|
||||||
|
mock.lockmustEmbedUnimplementedCodespaceHostServer.Unlock()
|
||||||
|
mock.mustEmbedUnimplementedCodespaceHostServerFunc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedCodespaceHostServerCalls gets all the calls that were made to mustEmbedUnimplementedCodespaceHostServer.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedCodespaceHostServer.mustEmbedUnimplementedCodespaceHostServerCalls())
|
||||||
|
func (mock *CodespaceHostServerMock) mustEmbedUnimplementedCodespaceHostServerCalls() []struct {
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
}
|
||||||
|
mock.lockmustEmbedUnimplementedCodespaceHostServer.RLock()
|
||||||
|
calls = mock.calls.mustEmbedUnimplementedCodespaceHostServer
|
||||||
|
mock.lockmustEmbedUnimplementedCodespaceHostServer.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
@ -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.
|
// 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 {
|
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)
|
RebuildContainerAsync(ctx context.Context, in *RebuildContainerRequest, opts ...grpc.CallOption) (*RebuildContainerResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -33,6 +34,15 @@ func NewCodespaceHostClient(cc grpc.ClientConnInterface) CodespaceHostClient {
|
||||||
return &codespaceHostClient{cc}
|
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) {
|
func (c *codespaceHostClient) RebuildContainerAsync(ctx context.Context, in *RebuildContainerRequest, opts ...grpc.CallOption) (*RebuildContainerResponse, error) {
|
||||||
out := new(RebuildContainerResponse)
|
out := new(RebuildContainerResponse)
|
||||||
err := c.cc.Invoke(ctx, "/Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost/RebuildContainerAsync", in, out, opts...)
|
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
|
// All implementations must embed UnimplementedCodespaceHostServer
|
||||||
// for forward compatibility
|
// for forward compatibility
|
||||||
type CodespaceHostServer interface {
|
type CodespaceHostServer interface {
|
||||||
|
NotifyCodespaceOfClientActivity(context.Context, *NotifyCodespaceOfClientActivityRequest) (*NotifyCodespaceOfClientActivityResponse, error)
|
||||||
RebuildContainerAsync(context.Context, *RebuildContainerRequest) (*RebuildContainerResponse, error)
|
RebuildContainerAsync(context.Context, *RebuildContainerRequest) (*RebuildContainerResponse, error)
|
||||||
mustEmbedUnimplementedCodespaceHostServer()
|
mustEmbedUnimplementedCodespaceHostServer()
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +65,9 @@ type CodespaceHostServer interface {
|
||||||
type UnimplementedCodespaceHostServer struct {
|
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) {
|
func (UnimplementedCodespaceHostServer) RebuildContainerAsync(context.Context, *RebuildContainerRequest) (*RebuildContainerResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method RebuildContainerAsync not implemented")
|
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)
|
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) {
|
func _CodespaceHost_RebuildContainerAsync_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(RebuildContainerRequest)
|
in := new(RebuildContainerRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
|
|
@ -95,6 +127,10 @@ var CodespaceHost_ServiceDesc = grpc.ServiceDesc{
|
||||||
ServiceName: "Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost",
|
ServiceName: "Codespaces.Grpc.CodespaceHostService.v1.CodespaceHost",
|
||||||
HandlerType: (*CodespaceHostServer)(nil),
|
HandlerType: (*CodespaceHostServer)(nil),
|
||||||
Methods: []grpc.MethodDesc{
|
Methods: []grpc.MethodDesc{
|
||||||
|
{
|
||||||
|
MethodName: "NotifyCodespaceOfClientActivity",
|
||||||
|
Handler: _CodespaceHost_NotifyCodespaceOfClientActivity_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "RebuildContainerAsync",
|
MethodName: "RebuildContainerAsync",
|
||||||
Handler: _CodespaceHost_RebuildContainerAsync_Handler,
|
Handler: _CodespaceHost_RebuildContainerAsync_Handler,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ Instructions for generating and adding gRPC protocol buffers.
|
||||||
|
|
||||||
1. [Download `protoc`](https://grpc.io/docs/protoc-installation/)
|
1. [Download `protoc`](https://grpc.io/docs/protoc-installation/)
|
||||||
2. [Download protocol compiler plugins for Go](https://grpc.io/docs/languages/go/quickstart/)
|
2. [Download protocol compiler plugins for Go](https://grpc.io/docs/languages/go/quickstart/)
|
||||||
3. Run `./generate.sh` from the `internal/codespaces/grpc` directory
|
3. Install moq: `go install github.com/matryer/moq@latest`
|
||||||
|
4. Run `./generate.sh` from the `internal/codespaces/rpc` directory
|
||||||
|
|
||||||
## Add New Protocol Buffers
|
## Add New Protocol Buffers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,21 @@ if ! protoc-gen-go-grpc --version; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
function generate {
|
function generate {
|
||||||
local contract="$1"
|
local dir="$1"
|
||||||
|
local proto="$2"
|
||||||
|
|
||||||
|
local contract="$dir/$proto"
|
||||||
|
|
||||||
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract"
|
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract"
|
||||||
echo "Generated protocol buffers for $contract"
|
echo "Generated protocol buffers for $contract"
|
||||||
|
|
||||||
|
services=$(cat "$contract" | grep -Eo "service .+ {" | awk '{print $2 "Server"}')
|
||||||
|
moq -out $contract.mock.go $dir $services
|
||||||
|
echo "Generated mock protocols for $contract"
|
||||||
}
|
}
|
||||||
|
|
||||||
generate jupyter/jupyter_server_host_service.v1.proto
|
generate jupyter jupyter_server_host_service.v1.proto
|
||||||
generate codespace/codespace_host_service.v1.proto
|
generate codespace codespace_host_service.v1.proto
|
||||||
generate ssh/ssh_server_host_service.v1.proto
|
generate ssh ssh_server_host_service.v1.proto
|
||||||
|
|
||||||
echo 'Done!'
|
echo 'Done!'
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,8 @@ const (
|
||||||
const (
|
const (
|
||||||
codespacesInternalPort = 16634
|
codespacesInternalPort = 16634
|
||||||
codespacesInternalSessionName = "CodespacesInternal"
|
codespacesInternalSessionName = "CodespacesInternal"
|
||||||
|
clientName = "gh"
|
||||||
|
connectedEventName = "connected"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StartSSHServerOptions struct {
|
type StartSSHServerOptions struct {
|
||||||
|
|
@ -68,11 +70,11 @@ func CreateInvoker(ctx context.Context, session liveshare.LiveshareSession) (Inv
|
||||||
|
|
||||||
// Finds a free port to listen on and creates a new RPC invoker that connects to that port
|
// Finds a free port to listen on and creates a new RPC invoker that connects to that port
|
||||||
func connect(ctx context.Context, session liveshare.LiveshareSession) (Invoker, error) {
|
func connect(ctx context.Context, session liveshare.LiveshareSession) (Invoker, error) {
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0))
|
listener, err := listenTCP()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port)
|
localAddress := listener.Addr().String()
|
||||||
|
|
||||||
invoker := &invoker{
|
invoker := &invoker{
|
||||||
session: session,
|
session: session,
|
||||||
|
|
@ -128,6 +130,12 @@ func connect(ctx context.Context, session liveshare.LiveshareSession) (Invoker,
|
||||||
invoker.codespaceClient = codespace.NewCodespaceHostClient(conn)
|
invoker.codespaceClient = codespace.NewCodespaceHostClient(conn)
|
||||||
invoker.sshClient = ssh.NewSshServerHostClient(conn)
|
invoker.sshClient = ssh.NewSshServerHostClient(conn)
|
||||||
|
|
||||||
|
// Send initial connection heartbeat (no need to throw if we fail to get a response from the server)
|
||||||
|
_ = invoker.notifyCodespaceOfClientActivity(ctx, connectedEventName)
|
||||||
|
|
||||||
|
// Start the activity heatbeats
|
||||||
|
go invoker.heartbeat(pfctx, 1*time.Minute)
|
||||||
|
|
||||||
return invoker, nil
|
return invoker, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -229,3 +237,45 @@ func (i *invoker) StartSSHServerWithOptions(ctx context.Context, options StartSS
|
||||||
|
|
||||||
return port, response.User, nil
|
return port, response.User, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func listenTCP() (*net.TCPListener, error) {
|
||||||
|
addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build tcp address: %w", err)
|
||||||
|
}
|
||||||
|
listener, err := net.ListenTCP("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,73 +3,159 @@ package rpc
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"net"
|
||||||
"os"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/v2/internal/codespaces/rpc/codespace"
|
||||||
|
"github.com/cli/cli/v2/internal/codespaces/rpc/jupyter"
|
||||||
|
"github.com/cli/cli/v2/internal/codespaces/rpc/ssh"
|
||||||
rpctest "github.com/cli/cli/v2/internal/codespaces/rpc/test"
|
rpctest "github.com/cli/cli/v2/internal/codespaces/rpc/test"
|
||||||
|
"google.golang.org/grpc"
|
||||||
)
|
)
|
||||||
|
|
||||||
func startServer(t *testing.T) {
|
type mockServer struct {
|
||||||
t.Helper()
|
jupyter.JupyterServerHostServerMock
|
||||||
if os.Getenv("GITHUB_ACTIONS") == "true" {
|
codespace.CodespaceHostServerMock
|
||||||
t.Skip("fails intermittently in CI: https://github.com/cli/cli/issues/5663")
|
ssh.SshServerHostServerMock
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMockServer() *mockServer {
|
||||||
|
server := &mockServer{}
|
||||||
|
|
||||||
|
server.CodespaceHostServerMock.NotifyCodespaceOfClientActivityFunc = func(context.Context, *codespace.NotifyCodespaceOfClientActivityRequest) (*codespace.NotifyCodespaceOfClientActivityResponse, error) {
|
||||||
|
return &codespace.NotifyCodespaceOfClientActivityResponse{
|
||||||
|
Message: "",
|
||||||
|
Result: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
// runTestGrpcServer serves grpc requests over the provided Listener using the mockServer for mocked callbacks.
|
||||||
|
// It does not return until the Context is cancelled and the server fully shuts down.
|
||||||
|
func runTestGrpcServer(ctx context.Context, listener net.Listener, server *mockServer) error {
|
||||||
|
s := grpc.NewServer()
|
||||||
|
jupyter.RegisterJupyterServerHostServer(s, server)
|
||||||
|
codespace.RegisterCodespaceHostServer(s, server)
|
||||||
|
ssh.RegisterSshServerHostServer(s, server)
|
||||||
|
|
||||||
|
ch := make(chan error, 1)
|
||||||
|
go func() { ch <- s.Serve(listener) }()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
s.Stop()
|
||||||
|
<-ch
|
||||||
|
return nil
|
||||||
|
case err := <-ch:
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTestInvoker is the main test setup function. It returns an Invoker using the provided mockServer, as well as a shutdown function.
|
||||||
|
// The Invoker does not need to be closed directly, that will be handled by the shutdown function.
|
||||||
|
func createTestInvoker(t *testing.T, server *mockServer) (Invoker, func(), error) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:16634")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to listen: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
ch := make(chan error)
|
||||||
|
go func() { ch <- runTestGrpcServer(ctx, listener, server) }()
|
||||||
|
|
||||||
// Start the gRPC server in the background
|
close := func() {
|
||||||
go func() {
|
|
||||||
err := rpctest.StartServer(ctx)
|
|
||||||
if err != nil && err != context.Canceled {
|
|
||||||
log.Println(fmt.Errorf("error starting test server: %v", err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Stop the gRPC server when the test is done
|
|
||||||
t.Cleanup(func() {
|
|
||||||
cancel()
|
cancel()
|
||||||
})
|
<-ch
|
||||||
}
|
listener.Close()
|
||||||
|
}
|
||||||
func createTestInvoker(t *testing.T) Invoker {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
invoker, err := CreateInvoker(context.Background(), &rpctest.Session{})
|
invoker, err := CreateInvoker(context.Background(), &rpctest.Session{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("error connecting to internal server: %v", err)
|
close()
|
||||||
|
return nil, nil, fmt.Errorf("error connecting to internal server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Cleanup(func() {
|
return invoker, func() {
|
||||||
invoker.Close()
|
invoker.Close()
|
||||||
})
|
close()
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
return invoker
|
// Test that the RPC invoker notifies the codespace of client activity on connection
|
||||||
|
func verifyNotifyCodespaceOfClientActivity(t *testing.T, server *mockServer) {
|
||||||
|
calls := server.CodespaceHostServerMock.NotifyCodespaceOfClientActivityCalls()
|
||||||
|
if len(calls) == 0 {
|
||||||
|
t.Fatalf("no client activity calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, call := range calls {
|
||||||
|
activities := call.NotifyCodespaceOfClientActivityRequest.GetClientActivities()
|
||||||
|
if activities[0] == connectedEventName {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("no activity named %s", connectedEventName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that the RPC invoker returns the correct port and URL when the JupyterLab server starts successfully
|
// Test that the RPC invoker returns the correct port and URL when the JupyterLab server starts successfully
|
||||||
func TestStartJupyterServerSuccess(t *testing.T) {
|
func TestStartJupyterServerSuccess(t *testing.T) {
|
||||||
startServer(t)
|
resp := jupyter.GetRunningServerResponse{
|
||||||
invoker := createTestInvoker(t)
|
Port: strconv.Itoa(1234),
|
||||||
|
ServerUrl: "http://localhost:1234?token=1234",
|
||||||
|
Message: "",
|
||||||
|
Result: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newMockServer()
|
||||||
|
server.JupyterServerHostServerMock.GetRunningServerFunc = func(context.Context, *jupyter.GetRunningServerRequest) (*jupyter.GetRunningServerResponse, error) {
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invoker, stop, err := createTestInvoker(t, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error connecting to internal server: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
|
||||||
port, url, err := invoker.StartJupyterServer(context.Background())
|
port, url, err := invoker.StartJupyterServer(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected %v, got %v", nil, err)
|
t.Fatalf("expected %v, got %v", nil, err)
|
||||||
}
|
}
|
||||||
if port != rpctest.JupyterPort {
|
if strconv.Itoa(port) != resp.Port {
|
||||||
t.Fatalf("expected %d, got %d", rpctest.JupyterPort, port)
|
t.Fatalf("expected %s, got %d", resp.Port, port)
|
||||||
}
|
}
|
||||||
if url != rpctest.JupyterServerUrl {
|
if url != resp.ServerUrl {
|
||||||
t.Fatalf("expected %s, got %s", rpctest.JupyterServerUrl, url)
|
t.Fatalf("expected %s, got %s", resp.ServerUrl, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyNotifyCodespaceOfClientActivity(t, server)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that the RPC invoker returns an error when the JupyterLab server fails to start
|
// Test that the RPC invoker returns an error when the JupyterLab server fails to start
|
||||||
func TestStartJupyterServerFailure(t *testing.T) {
|
func TestStartJupyterServerFailure(t *testing.T) {
|
||||||
startServer(t)
|
resp := jupyter.GetRunningServerResponse{
|
||||||
invoker := createTestInvoker(t)
|
Port: strconv.Itoa(1234),
|
||||||
rpctest.JupyterMessage = "error message"
|
ServerUrl: "http://localhost:1234?token=1234",
|
||||||
rpctest.JupyterResult = false
|
Message: "error message",
|
||||||
errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", rpctest.JupyterMessage)
|
Result: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newMockServer()
|
||||||
|
server.JupyterServerHostServerMock.GetRunningServerFunc = func(context.Context, *jupyter.GetRunningServerRequest) (*jupyter.GetRunningServerResponse, error) {
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invoker, stop, err := createTestInvoker(t, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error connecting to internal server: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", resp.Message)
|
||||||
port, url, err := invoker.StartJupyterServer(context.Background())
|
port, url, err := invoker.StartJupyterServer(context.Background())
|
||||||
if err.Error() != errorMessage {
|
if err.Error() != errorMessage {
|
||||||
t.Fatalf("expected %v, got %v", errorMessage, err)
|
t.Fatalf("expected %v, got %v", errorMessage, err)
|
||||||
|
|
@ -80,35 +166,79 @@ func TestStartJupyterServerFailure(t *testing.T) {
|
||||||
if url != "" {
|
if url != "" {
|
||||||
t.Fatalf("expected %s, got %s", "", url)
|
t.Fatalf("expected %s, got %s", "", url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyNotifyCodespaceOfClientActivity(t, server)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that the RPC invoker doesn't throw an error when requesting an incremental rebuild
|
// Test that the RPC invoker doesn't throw an error when requesting an incremental rebuild
|
||||||
func TestRebuildContainerIncremental(t *testing.T) {
|
func TestRebuildContainerIncremental(t *testing.T) {
|
||||||
startServer(t)
|
resp := codespace.RebuildContainerResponse{
|
||||||
invoker := createTestInvoker(t)
|
RebuildContainer: true,
|
||||||
err := invoker.RebuildContainer(context.Background(), false)
|
}
|
||||||
|
|
||||||
|
server := newMockServer()
|
||||||
|
server.RebuildContainerAsyncFunc = func(context.Context, *codespace.RebuildContainerRequest) (*codespace.RebuildContainerResponse, error) {
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invoker, stop, err := createTestInvoker(t, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error connecting to internal server: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
err = invoker.RebuildContainer(context.Background(), false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected %v, got %v", nil, err)
|
t.Fatalf("expected %v, got %v", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyNotifyCodespaceOfClientActivity(t, server)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that the RPC invoker doesn't throw an error when requesting a full rebuild
|
// Test that the RPC invoker doesn't throw an error when requesting a full rebuild
|
||||||
func TestRebuildContainerFull(t *testing.T) {
|
func TestRebuildContainerFull(t *testing.T) {
|
||||||
startServer(t)
|
resp := codespace.RebuildContainerResponse{
|
||||||
invoker := createTestInvoker(t)
|
RebuildContainer: true,
|
||||||
err := invoker.RebuildContainer(context.Background(), true)
|
}
|
||||||
|
|
||||||
|
server := newMockServer()
|
||||||
|
server.RebuildContainerAsyncFunc = func(context.Context, *codespace.RebuildContainerRequest) (*codespace.RebuildContainerResponse, error) {
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invoker, stop, err := createTestInvoker(t, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error connecting to internal server: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
err = invoker.RebuildContainer(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected %v, got %v", nil, err)
|
t.Fatalf("expected %v, got %v", nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyNotifyCodespaceOfClientActivity(t, server)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that the RPC invoker throws an error when the rebuild fails
|
// Test that the RPC invoker throws an error when the rebuild fails
|
||||||
func TestRebuildContainerFailure(t *testing.T) {
|
func TestRebuildContainerFailure(t *testing.T) {
|
||||||
startServer(t)
|
resp := codespace.RebuildContainerResponse{
|
||||||
invoker := createTestInvoker(t)
|
RebuildContainer: false,
|
||||||
rpctest.RebuildContainer = false
|
}
|
||||||
|
|
||||||
|
server := newMockServer()
|
||||||
|
server.RebuildContainerAsyncFunc = func(context.Context, *codespace.RebuildContainerRequest) (*codespace.RebuildContainerResponse, error) {
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invoker, stop, err := createTestInvoker(t, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error connecting to internal server: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
|
||||||
errorMessage := "couldn't rebuild codespace"
|
errorMessage := "couldn't rebuild codespace"
|
||||||
err := invoker.RebuildContainer(context.Background(), true)
|
err = invoker.RebuildContainer(context.Background(), true)
|
||||||
if err.Error() != errorMessage {
|
if err.Error() != errorMessage {
|
||||||
t.Fatalf("expected %v, got %v", errorMessage, err)
|
t.Fatalf("expected %v, got %v", errorMessage, err)
|
||||||
}
|
}
|
||||||
|
|
@ -116,27 +246,59 @@ func TestRebuildContainerFailure(t *testing.T) {
|
||||||
|
|
||||||
// Test that the RPC invoker returns the correct port and user when the SSH server starts successfully
|
// Test that the RPC invoker returns the correct port and user when the SSH server starts successfully
|
||||||
func TestStartSSHServerSuccess(t *testing.T) {
|
func TestStartSSHServerSuccess(t *testing.T) {
|
||||||
startServer(t)
|
resp := ssh.StartRemoteServerResponse{
|
||||||
invoker := createTestInvoker(t)
|
ServerPort: strconv.Itoa(1234),
|
||||||
|
User: "test",
|
||||||
|
Message: "",
|
||||||
|
Result: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newMockServer()
|
||||||
|
server.StartRemoteServerAsyncFunc = func(context.Context, *ssh.StartRemoteServerRequest) (*ssh.StartRemoteServerResponse, error) {
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invoker, stop, err := createTestInvoker(t, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error connecting to internal server: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
|
||||||
port, user, err := invoker.StartSSHServer(context.Background())
|
port, user, err := invoker.StartSSHServer(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("expected %v, got %v", nil, err)
|
t.Fatalf("expected %v, got %v", nil, err)
|
||||||
}
|
}
|
||||||
if port != rpctest.SshServerPort {
|
if strconv.Itoa(port) != resp.ServerPort {
|
||||||
t.Fatalf("expected %d, got %d", rpctest.SshServerPort, port)
|
t.Fatalf("expected %s, got %d", resp.ServerPort, port)
|
||||||
}
|
}
|
||||||
if user != rpctest.SshUser {
|
if user != resp.User {
|
||||||
t.Fatalf("expected %s, got %s", rpctest.SshUser, user)
|
t.Fatalf("expected %s, got %s", resp.User, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
verifyNotifyCodespaceOfClientActivity(t, server)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that the RPC invoker returns an error when the SSH server fails to start
|
// Test that the RPC invoker returns an error when the SSH server fails to start
|
||||||
func TestStartSSHServerFailure(t *testing.T) {
|
func TestStartSSHServerFailure(t *testing.T) {
|
||||||
startServer(t)
|
resp := ssh.StartRemoteServerResponse{
|
||||||
invoker := createTestInvoker(t)
|
ServerPort: strconv.Itoa(1234),
|
||||||
rpctest.SshMessage = "error message"
|
User: "test",
|
||||||
rpctest.SshResult = false
|
Message: "error message",
|
||||||
errorMessage := fmt.Sprintf("failed to start SSH server: %s", rpctest.SshMessage)
|
Result: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
server := newMockServer()
|
||||||
|
server.StartRemoteServerAsyncFunc = func(context.Context, *ssh.StartRemoteServerRequest) (*ssh.StartRemoteServerResponse, error) {
|
||||||
|
return &resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
invoker, stop, err := createTestInvoker(t, server)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("error connecting to internal server: %v", err)
|
||||||
|
}
|
||||||
|
defer stop()
|
||||||
|
|
||||||
|
errorMessage := fmt.Sprintf("failed to start SSH server: %s", resp.Message)
|
||||||
port, user, err := invoker.StartSSHServer(context.Background())
|
port, user, err := invoker.StartSSHServer(context.Background())
|
||||||
if err.Error() != errorMessage {
|
if err.Error() != errorMessage {
|
||||||
t.Fatalf("expected %v, got %v", errorMessage, err)
|
t.Fatalf("expected %v, got %v", errorMessage, err)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.28.0
|
// protoc-gen-go v1.28.1
|
||||||
// protoc v3.21.12
|
// protoc v3.21.12
|
||||||
// source: jupyter/jupyter_server_host_service.v1.proto
|
// source: jupyter/jupyter_server_host_service.v1.proto
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
// Code generated by moq; DO NOT EDIT.
|
||||||
|
// github.com/matryer/moq
|
||||||
|
|
||||||
|
package jupyter
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure, that JupyterServerHostServerMock does implement JupyterServerHostServer.
|
||||||
|
// If this is not the case, regenerate this file with moq.
|
||||||
|
var _ JupyterServerHostServer = &JupyterServerHostServerMock{}
|
||||||
|
|
||||||
|
// JupyterServerHostServerMock is a mock implementation of JupyterServerHostServer.
|
||||||
|
//
|
||||||
|
// func TestSomethingThatUsesJupyterServerHostServer(t *testing.T) {
|
||||||
|
//
|
||||||
|
// // make and configure a mocked JupyterServerHostServer
|
||||||
|
// mockedJupyterServerHostServer := &JupyterServerHostServerMock{
|
||||||
|
// GetRunningServerFunc: func(contextMoqParam context.Context, getRunningServerRequest *GetRunningServerRequest) (*GetRunningServerResponse, error) {
|
||||||
|
// panic("mock out the GetRunningServer method")
|
||||||
|
// },
|
||||||
|
// mustEmbedUnimplementedJupyterServerHostServerFunc: func() {
|
||||||
|
// panic("mock out the mustEmbedUnimplementedJupyterServerHostServer method")
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // use mockedJupyterServerHostServer in code that requires JupyterServerHostServer
|
||||||
|
// // and then make assertions.
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
type JupyterServerHostServerMock struct {
|
||||||
|
// GetRunningServerFunc mocks the GetRunningServer method.
|
||||||
|
GetRunningServerFunc func(contextMoqParam context.Context, getRunningServerRequest *GetRunningServerRequest) (*GetRunningServerResponse, error)
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedJupyterServerHostServerFunc mocks the mustEmbedUnimplementedJupyterServerHostServer method.
|
||||||
|
mustEmbedUnimplementedJupyterServerHostServerFunc func()
|
||||||
|
|
||||||
|
// calls tracks calls to the methods.
|
||||||
|
calls struct {
|
||||||
|
// GetRunningServer holds details about calls to the GetRunningServer method.
|
||||||
|
GetRunningServer []struct {
|
||||||
|
// ContextMoqParam is the contextMoqParam argument value.
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
// GetRunningServerRequest is the getRunningServerRequest argument value.
|
||||||
|
GetRunningServerRequest *GetRunningServerRequest
|
||||||
|
}
|
||||||
|
// mustEmbedUnimplementedJupyterServerHostServer holds details about calls to the mustEmbedUnimplementedJupyterServerHostServer method.
|
||||||
|
mustEmbedUnimplementedJupyterServerHostServer []struct {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lockGetRunningServer sync.RWMutex
|
||||||
|
lockmustEmbedUnimplementedJupyterServerHostServer sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRunningServer calls GetRunningServerFunc.
|
||||||
|
func (mock *JupyterServerHostServerMock) GetRunningServer(contextMoqParam context.Context, getRunningServerRequest *GetRunningServerRequest) (*GetRunningServerResponse, error) {
|
||||||
|
if mock.GetRunningServerFunc == nil {
|
||||||
|
panic("JupyterServerHostServerMock.GetRunningServerFunc: method is nil but JupyterServerHostServer.GetRunningServer was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
GetRunningServerRequest *GetRunningServerRequest
|
||||||
|
}{
|
||||||
|
ContextMoqParam: contextMoqParam,
|
||||||
|
GetRunningServerRequest: getRunningServerRequest,
|
||||||
|
}
|
||||||
|
mock.lockGetRunningServer.Lock()
|
||||||
|
mock.calls.GetRunningServer = append(mock.calls.GetRunningServer, callInfo)
|
||||||
|
mock.lockGetRunningServer.Unlock()
|
||||||
|
return mock.GetRunningServerFunc(contextMoqParam, getRunningServerRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRunningServerCalls gets all the calls that were made to GetRunningServer.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedJupyterServerHostServer.GetRunningServerCalls())
|
||||||
|
func (mock *JupyterServerHostServerMock) GetRunningServerCalls() []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
GetRunningServerRequest *GetRunningServerRequest
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
GetRunningServerRequest *GetRunningServerRequest
|
||||||
|
}
|
||||||
|
mock.lockGetRunningServer.RLock()
|
||||||
|
calls = mock.calls.GetRunningServer
|
||||||
|
mock.lockGetRunningServer.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedJupyterServerHostServer calls mustEmbedUnimplementedJupyterServerHostServerFunc.
|
||||||
|
func (mock *JupyterServerHostServerMock) mustEmbedUnimplementedJupyterServerHostServer() {
|
||||||
|
if mock.mustEmbedUnimplementedJupyterServerHostServerFunc == nil {
|
||||||
|
panic("JupyterServerHostServerMock.mustEmbedUnimplementedJupyterServerHostServerFunc: method is nil but JupyterServerHostServer.mustEmbedUnimplementedJupyterServerHostServer was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
}{}
|
||||||
|
mock.lockmustEmbedUnimplementedJupyterServerHostServer.Lock()
|
||||||
|
mock.calls.mustEmbedUnimplementedJupyterServerHostServer = append(mock.calls.mustEmbedUnimplementedJupyterServerHostServer, callInfo)
|
||||||
|
mock.lockmustEmbedUnimplementedJupyterServerHostServer.Unlock()
|
||||||
|
mock.mustEmbedUnimplementedJupyterServerHostServerFunc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedJupyterServerHostServerCalls gets all the calls that were made to mustEmbedUnimplementedJupyterServerHostServer.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedJupyterServerHostServer.mustEmbedUnimplementedJupyterServerHostServerCalls())
|
||||||
|
func (mock *JupyterServerHostServerMock) mustEmbedUnimplementedJupyterServerHostServerCalls() []struct {
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
}
|
||||||
|
mock.lockmustEmbedUnimplementedJupyterServerHostServer.RLock()
|
||||||
|
calls = mock.calls.mustEmbedUnimplementedJupyterServerHostServer
|
||||||
|
mock.lockmustEmbedUnimplementedJupyterServerHostServer.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.28.0
|
// protoc-gen-go v1.28.1
|
||||||
// protoc v3.21.12
|
// protoc v3.21.12
|
||||||
// source: ssh/ssh_server_host_service.v1.proto
|
// source: ssh/ssh_server_host_service.v1.proto
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
// Code generated by moq; DO NOT EDIT.
|
||||||
|
// github.com/matryer/moq
|
||||||
|
|
||||||
|
package ssh
|
||||||
|
|
||||||
|
import (
|
||||||
|
context "context"
|
||||||
|
sync "sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure, that SshServerHostServerMock does implement SshServerHostServer.
|
||||||
|
// If this is not the case, regenerate this file with moq.
|
||||||
|
var _ SshServerHostServer = &SshServerHostServerMock{}
|
||||||
|
|
||||||
|
// SshServerHostServerMock is a mock implementation of SshServerHostServer.
|
||||||
|
//
|
||||||
|
// func TestSomethingThatUsesSshServerHostServer(t *testing.T) {
|
||||||
|
//
|
||||||
|
// // make and configure a mocked SshServerHostServer
|
||||||
|
// mockedSshServerHostServer := &SshServerHostServerMock{
|
||||||
|
// StartRemoteServerAsyncFunc: func(contextMoqParam context.Context, startRemoteServerRequest *StartRemoteServerRequest) (*StartRemoteServerResponse, error) {
|
||||||
|
// panic("mock out the StartRemoteServerAsync method")
|
||||||
|
// },
|
||||||
|
// mustEmbedUnimplementedSshServerHostServerFunc: func() {
|
||||||
|
// panic("mock out the mustEmbedUnimplementedSshServerHostServer method")
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // use mockedSshServerHostServer in code that requires SshServerHostServer
|
||||||
|
// // and then make assertions.
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
type SshServerHostServerMock struct {
|
||||||
|
// StartRemoteServerAsyncFunc mocks the StartRemoteServerAsync method.
|
||||||
|
StartRemoteServerAsyncFunc func(contextMoqParam context.Context, startRemoteServerRequest *StartRemoteServerRequest) (*StartRemoteServerResponse, error)
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedSshServerHostServerFunc mocks the mustEmbedUnimplementedSshServerHostServer method.
|
||||||
|
mustEmbedUnimplementedSshServerHostServerFunc func()
|
||||||
|
|
||||||
|
// calls tracks calls to the methods.
|
||||||
|
calls struct {
|
||||||
|
// StartRemoteServerAsync holds details about calls to the StartRemoteServerAsync method.
|
||||||
|
StartRemoteServerAsync []struct {
|
||||||
|
// ContextMoqParam is the contextMoqParam argument value.
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
// StartRemoteServerRequest is the startRemoteServerRequest argument value.
|
||||||
|
StartRemoteServerRequest *StartRemoteServerRequest
|
||||||
|
}
|
||||||
|
// mustEmbedUnimplementedSshServerHostServer holds details about calls to the mustEmbedUnimplementedSshServerHostServer method.
|
||||||
|
mustEmbedUnimplementedSshServerHostServer []struct {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lockStartRemoteServerAsync sync.RWMutex
|
||||||
|
lockmustEmbedUnimplementedSshServerHostServer sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartRemoteServerAsync calls StartRemoteServerAsyncFunc.
|
||||||
|
func (mock *SshServerHostServerMock) StartRemoteServerAsync(contextMoqParam context.Context, startRemoteServerRequest *StartRemoteServerRequest) (*StartRemoteServerResponse, error) {
|
||||||
|
if mock.StartRemoteServerAsyncFunc == nil {
|
||||||
|
panic("SshServerHostServerMock.StartRemoteServerAsyncFunc: method is nil but SshServerHostServer.StartRemoteServerAsync was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
StartRemoteServerRequest *StartRemoteServerRequest
|
||||||
|
}{
|
||||||
|
ContextMoqParam: contextMoqParam,
|
||||||
|
StartRemoteServerRequest: startRemoteServerRequest,
|
||||||
|
}
|
||||||
|
mock.lockStartRemoteServerAsync.Lock()
|
||||||
|
mock.calls.StartRemoteServerAsync = append(mock.calls.StartRemoteServerAsync, callInfo)
|
||||||
|
mock.lockStartRemoteServerAsync.Unlock()
|
||||||
|
return mock.StartRemoteServerAsyncFunc(contextMoqParam, startRemoteServerRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartRemoteServerAsyncCalls gets all the calls that were made to StartRemoteServerAsync.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedSshServerHostServer.StartRemoteServerAsyncCalls())
|
||||||
|
func (mock *SshServerHostServerMock) StartRemoteServerAsyncCalls() []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
StartRemoteServerRequest *StartRemoteServerRequest
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
ContextMoqParam context.Context
|
||||||
|
StartRemoteServerRequest *StartRemoteServerRequest
|
||||||
|
}
|
||||||
|
mock.lockStartRemoteServerAsync.RLock()
|
||||||
|
calls = mock.calls.StartRemoteServerAsync
|
||||||
|
mock.lockStartRemoteServerAsync.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedSshServerHostServer calls mustEmbedUnimplementedSshServerHostServerFunc.
|
||||||
|
func (mock *SshServerHostServerMock) mustEmbedUnimplementedSshServerHostServer() {
|
||||||
|
if mock.mustEmbedUnimplementedSshServerHostServerFunc == nil {
|
||||||
|
panic("SshServerHostServerMock.mustEmbedUnimplementedSshServerHostServerFunc: method is nil but SshServerHostServer.mustEmbedUnimplementedSshServerHostServer was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
}{}
|
||||||
|
mock.lockmustEmbedUnimplementedSshServerHostServer.Lock()
|
||||||
|
mock.calls.mustEmbedUnimplementedSshServerHostServer = append(mock.calls.mustEmbedUnimplementedSshServerHostServer, callInfo)
|
||||||
|
mock.lockmustEmbedUnimplementedSshServerHostServer.Unlock()
|
||||||
|
mock.mustEmbedUnimplementedSshServerHostServerFunc()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mustEmbedUnimplementedSshServerHostServerCalls gets all the calls that were made to mustEmbedUnimplementedSshServerHostServer.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedSshServerHostServer.mustEmbedUnimplementedSshServerHostServerCalls())
|
||||||
|
func (mock *SshServerHostServerMock) mustEmbedUnimplementedSshServerHostServerCalls() []struct {
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
}
|
||||||
|
mock.lockmustEmbedUnimplementedSshServerHostServer.RLock()
|
||||||
|
calls = mock.calls.mustEmbedUnimplementedSshServerHostServer
|
||||||
|
mock.lockmustEmbedUnimplementedSshServerHostServer.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
package test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/cli/cli/v2/internal/codespaces/rpc/codespace"
|
|
||||||
"github.com/cli/cli/v2/internal/codespaces/rpc/jupyter"
|
|
||||||
"github.com/cli/cli/v2/internal/codespaces/rpc/ssh"
|
|
||||||
"google.golang.org/grpc"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ServerPort = 50051
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mock responses for the `GetRunningServer` RPC method
|
|
||||||
var (
|
|
||||||
JupyterPort = 1234
|
|
||||||
JupyterServerUrl = "http://localhost:1234?token=1234"
|
|
||||||
JupyterMessage = ""
|
|
||||||
JupyterResult = true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mock responses for the `RebuildContainerAsync` RPC method
|
|
||||||
var (
|
|
||||||
RebuildContainer = true
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mock responses for the `StartRemoteServerAsync` RPC method
|
|
||||||
var (
|
|
||||||
SshServerPort = 1234
|
|
||||||
SshUser = "test"
|
|
||||||
SshMessage = ""
|
|
||||||
SshResult = true
|
|
||||||
)
|
|
||||||
|
|
||||||
type server struct {
|
|
||||||
jupyter.UnimplementedJupyterServerHostServer
|
|
||||||
codespace.CodespaceHostServer
|
|
||||||
ssh.SshServerHostServer
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) GetRunningServer(ctx context.Context, in *jupyter.GetRunningServerRequest) (*jupyter.GetRunningServerResponse, error) {
|
|
||||||
return &jupyter.GetRunningServerResponse{
|
|
||||||
Port: strconv.Itoa(JupyterPort),
|
|
||||||
ServerUrl: JupyterServerUrl,
|
|
||||||
Message: JupyterMessage,
|
|
||||||
Result: JupyterResult,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) RebuildContainerAsync(ctx context.Context, in *codespace.RebuildContainerRequest) (*codespace.RebuildContainerResponse, error) {
|
|
||||||
return &codespace.RebuildContainerResponse{
|
|
||||||
RebuildContainer: RebuildContainer,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *server) StartRemoteServerAsync(ctx context.Context, in *ssh.StartRemoteServerRequest) (*ssh.StartRemoteServerResponse, error) {
|
|
||||||
return &ssh.StartRemoteServerResponse{
|
|
||||||
ServerPort: strconv.Itoa(SshServerPort),
|
|
||||||
User: SshUser,
|
|
||||||
Message: SshMessage,
|
|
||||||
Result: SshResult,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starts the mock gRPC server listening on port 50051
|
|
||||||
func StartServer(ctx context.Context) error {
|
|
||||||
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to listen: %v", err)
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
|
||||||
|
|
||||||
s := grpc.NewServer()
|
|
||||||
jupyter.RegisterJupyterServerHostServer(s, &server{})
|
|
||||||
codespace.RegisterCodespaceHostServer(s, &server{})
|
|
||||||
ssh.RegisterSshServerHostServer(s, &server{})
|
|
||||||
|
|
||||||
ch := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
if err := s.Serve(listener); err != nil {
|
|
||||||
ch <- fmt.Errorf("failed to serve: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
s.Stop()
|
|
||||||
return ctx.Err()
|
|
||||||
case err := <-ch:
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -24,8 +24,12 @@ func (*Session) GetSharedServers(context.Context) ([]*liveshare.Port, error) {
|
||||||
func (s *Session) KeepAlive(reason string) {
|
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) {
|
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))
|
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return liveshare.ChannelID{}, err
|
return liveshare.ChannelID{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||||
|
|
@ -53,11 +52,10 @@ func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiCl
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
||||||
listen, err := net.Listen("tcp", "127.0.0.1:0") // arbitrary port
|
listen, localPort, err := ListenTCP(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
localPort := listen.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
progress.StartProgressIndicatorWithLabel("Fetching SSH Details")
|
progress.StartProgressIndicatorWithLabel("Fetching SSH Details")
|
||||||
invoker, err := rpc.CreateInvoker(ctx, session)
|
invoker, err := rpc.CreateInvoker(ctx, session)
|
||||||
|
|
|
||||||
|
|
@ -106,18 +106,36 @@ type commandStub struct {
|
||||||
callbacks []CommandCallback
|
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
|
// Run satisfies Runnable
|
||||||
func (s *commandStub) Run() error {
|
func (s *commandStub) Run() error {
|
||||||
if s.exitStatus != 0 {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output satisfies Runnable
|
// Output satisfies Runnable
|
||||||
func (s *commandStub) Output() ([]byte, error) {
|
func (s *commandStub) Output() ([]byte, error) {
|
||||||
if s.exitStatus != 0 {
|
if err := s.Run(); err != nil {
|
||||||
return []byte(nil), fmt.Errorf("%s exited with status %d", s.pattern, s.exitStatus)
|
return []byte(nil), err
|
||||||
}
|
}
|
||||||
return []byte(s.stdout), nil
|
return []byte(s.stdout), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,11 +24,12 @@ func (t *TablePrinter) HeaderRow(columns ...string) {
|
||||||
t.EndRow()
|
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)
|
tf := t.Format(time.RFC3339)
|
||||||
if tp.isTTY {
|
if tp.isTTY {
|
||||||
// TODO: use a static time.Now
|
tf = text.FuzzyAgo(now, t)
|
||||||
tf = text.FuzzyAgo(time.Now(), t)
|
|
||||||
}
|
}
|
||||||
tp.AddField(tf, tableprinter.WithColor(c))
|
tp.AddField(tf, tableprinter.WithColor(c))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
@ -9,8 +13,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/api"
|
|
||||||
"github.com/cli/cli/v2/internal/ghinstance"
|
|
||||||
"github.com/hashicorp/go-version"
|
"github.com/hashicorp/go-version"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -30,13 +32,13 @@ type StateEntry struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckForUpdate checks whether this software has had a newer release on GitHub
|
// CheckForUpdate checks whether this software has had a newer release on GitHub
|
||||||
func CheckForUpdate(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) {
|
func CheckForUpdate(ctx context.Context, client *http.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) {
|
||||||
stateEntry, _ := getStateEntry(stateFilePath)
|
stateEntry, _ := getStateEntry(stateFilePath)
|
||||||
if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 {
|
if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseInfo, err := getLatestReleaseInfo(client, repo)
|
releaseInfo, err := getLatestReleaseInfo(ctx, client, repo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -53,13 +55,27 @@ func CheckForUpdate(client *api.Client, stateFilePath, repo, currentVersion stri
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLatestReleaseInfo(client *api.Client, repo string) (*ReleaseInfo, error) {
|
func getLatestReleaseInfo(ctx context.Context, client *http.Client, repo string) (*ReleaseInfo, error) {
|
||||||
var latestRelease ReleaseInfo
|
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo), nil)
|
||||||
err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
res, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_, _ = io.Copy(io.Discard, res.Body)
|
||||||
|
res.Body.Close()
|
||||||
|
}()
|
||||||
|
if res.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("unexpected HTTP %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(res.Body)
|
||||||
|
var latestRelease ReleaseInfo
|
||||||
|
if err := dec.Decode(&latestRelease); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
return &latestRelease, nil
|
return &latestRelease, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
package update
|
package update
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/api"
|
|
||||||
"github.com/cli/cli/v2/pkg/httpmock"
|
"github.com/cli/cli/v2/pkg/httpmock"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -75,7 +75,6 @@ func TestCheckForUpdate(t *testing.T) {
|
||||||
reg := &httpmock.Registry{}
|
reg := &httpmock.Registry{}
|
||||||
httpClient := &http.Client{}
|
httpClient := &http.Client{}
|
||||||
httpmock.ReplaceTripper(httpClient, reg)
|
httpmock.ReplaceTripper(httpClient, reg)
|
||||||
client := api.NewClientFromHTTP(httpClient)
|
|
||||||
|
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"),
|
httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"),
|
||||||
|
|
@ -85,7 +84,7 @@ func TestCheckForUpdate(t *testing.T) {
|
||||||
}`, s.LatestVersion, s.LatestURL)),
|
}`, s.LatestVersion, s.LatestURL)),
|
||||||
)
|
)
|
||||||
|
|
||||||
rel, err := CheckForUpdate(client, tempFilePath(), "OWNER/REPO", s.CurrentVersion)
|
rel, err := CheckForUpdate(context.TODO(), httpClient, tempFilePath(), "OWNER/REPO", s.CurrentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@ import (
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -183,6 +185,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
||||||
opts.RequestPath = args[0]
|
opts.RequestPath = args[0]
|
||||||
opts.RequestMethodPassed = c.Flags().Changed("method")
|
opts.RequestMethodPassed = c.Flags().Changed("method")
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" && filepath.IsAbs(opts.RequestPath) {
|
||||||
|
return fmt.Errorf(`invalid API endpoint: "%s". Your shell might be rewriting URL paths as filesystem paths. To avoid this, omit the leading slash from the endpoint argument`, opts.RequestPath)
|
||||||
|
}
|
||||||
|
|
||||||
if c.Flags().Changed("hostname") {
|
if c.Flags().Changed("hostname") {
|
||||||
if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
|
if err := ghinstance.HostnameValidator(opts.Hostname); err != nil {
|
||||||
return cmdutil.FlagErrorf("error parsing `--hostname`: %w", err)
|
return cmdutil.FlagErrorf("error parsing `--hostname`: %w", err)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -355,6 +356,20 @@ func Test_NewCmdApi(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Test_NewCmdApi_WindowsAbsPath(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.SkipNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := NewCmdApi(&cmdutil.Factory{}, func(opts *ApiOptions) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
cmd.SetArgs([]string{`C:\users\repos`})
|
||||||
|
_, err := cmd.ExecuteC()
|
||||||
|
assert.EqualError(t, err, `invalid API endpoint: "C:\users\repos". Your shell might be rewriting URL paths as filesystem paths. To avoid this, omit the leading slash from the endpoint argument`)
|
||||||
|
}
|
||||||
|
|
||||||
func Test_apiRun(t *testing.T) {
|
func Test_apiRun(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type httpClient interface {
|
||||||
Do(*http.Request) (*http.Response, error)
|
Do(*http.Request) (*http.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetScopes performs a GitHub API request and returns the value of the X-Oauth-Scopes header.
|
||||||
func GetScopes(httpClient httpClient, hostname, authToken string) (string, error) {
|
func GetScopes(httpClient httpClient, hostname, authToken string) (string, error) {
|
||||||
apiEndpoint := ghinstance.RESTPrefix(hostname)
|
apiEndpoint := ghinstance.RESTPrefix(hostname)
|
||||||
|
|
||||||
|
|
@ -60,12 +61,20 @@ func GetScopes(httpClient httpClient, hostname, authToken string) (string, error
|
||||||
return res.Header.Get("X-Oauth-Scopes"), nil
|
return res.Header.Get("X-Oauth-Scopes"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasMinimumScopes performs a GitHub API request and returns an error if the token used in the request
|
||||||
|
// lacks the minimum required scopes for performing API operations with gh.
|
||||||
func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error {
|
func HasMinimumScopes(httpClient httpClient, hostname, authToken string) error {
|
||||||
scopesHeader, err := GetScopes(httpClient, hostname, authToken)
|
scopesHeader, err := GetScopes(httpClient, hostname, authToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return HeaderHasMinimumScopes(scopesHeader)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeaderHasMinimumScopes parses the comma separated scopesHeader string and returns an error
|
||||||
|
// if it lacks the minimum required scopes for performing API operations with gh.
|
||||||
|
func HeaderHasMinimumScopes(scopesHeader string) error {
|
||||||
if scopesHeader == "" {
|
if scopesHeader == "" {
|
||||||
// if the token reports no scopes, assume that it's an integration token and give up on
|
// if the token reports no scopes, assume that it's an integration token and give up on
|
||||||
// detecting its capabilities
|
// detecting its capabilities
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,53 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func Test_HasMinimumScopes(t *testing.T) {
|
func Test_HasMinimumScopes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "write:org satisfies read:org",
|
||||||
|
header: "repo, write:org",
|
||||||
|
wantErr: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "insufficient scope",
|
||||||
|
header: "repo",
|
||||||
|
wantErr: "missing required scope 'read:org'",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
fakehttp := &httpmock.Registry{}
|
||||||
|
defer fakehttp.Verify(t)
|
||||||
|
|
||||||
|
var gotAuthorization string
|
||||||
|
fakehttp.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) {
|
||||||
|
gotAuthorization = req.Header.Get("authorization")
|
||||||
|
return &http.Response{
|
||||||
|
Request: req,
|
||||||
|
StatusCode: 200,
|
||||||
|
Body: io.NopCloser(&bytes.Buffer{}),
|
||||||
|
Header: map[string][]string{
|
||||||
|
"X-Oauth-Scopes": {tt.header},
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
client := http.Client{Transport: fakehttp}
|
||||||
|
err := HasMinimumScopes(&client, "github.com", "ATOKEN")
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
assert.EqualError(t, err, tt.wantErr)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
assert.Equal(t, gotAuthorization, "token ATOKEN")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_HeaderHasMinimumScopes(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
header string
|
header string
|
||||||
|
|
@ -49,31 +96,13 @@ func Test_HasMinimumScopes(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
fakehttp := &httpmock.Registry{}
|
|
||||||
defer fakehttp.Verify(t)
|
|
||||||
|
|
||||||
var gotAuthorization string
|
err := HeaderHasMinimumScopes(tt.header)
|
||||||
fakehttp.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) {
|
|
||||||
gotAuthorization = req.Header.Get("authorization")
|
|
||||||
return &http.Response{
|
|
||||||
Request: req,
|
|
||||||
StatusCode: 200,
|
|
||||||
Body: io.NopCloser(&bytes.Buffer{}),
|
|
||||||
Header: map[string][]string{
|
|
||||||
"X-Oauth-Scopes": {tt.header},
|
|
||||||
},
|
|
||||||
}, nil
|
|
||||||
})
|
|
||||||
|
|
||||||
client := http.Client{Transport: fakehttp}
|
|
||||||
err := HasMinimumScopes(&client, "github.com", "ATOKEN")
|
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
assert.EqualError(t, err, tt.wantErr)
|
assert.EqualError(t, err, tt.wantErr)
|
||||||
} else {
|
} else {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
assert.Equal(t, gotAuthorization, "token ATOKEN")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/MakeNowJust/heredoc"
|
"github.com/MakeNowJust/heredoc"
|
||||||
"github.com/cli/cli/v2/api"
|
"github.com/cli/cli/v2/api"
|
||||||
|
|
@ -90,6 +92,11 @@ func statusRun(opts *StatusOptions) error {
|
||||||
isHostnameFound = true
|
isHostnameFound = true
|
||||||
|
|
||||||
token, tokenSource := cfg.AuthToken(hostname)
|
token, tokenSource := cfg.AuthToken(hostname)
|
||||||
|
if tokenSource == "oauth_token" {
|
||||||
|
// The go-gh function TokenForHost returns this value as source for tokens read from the
|
||||||
|
// config file, but we want the file path instead. This attempts to reconstruct it.
|
||||||
|
tokenSource = filepath.Join(config.ConfigDir(), "hosts.yml")
|
||||||
|
}
|
||||||
_, tokenIsWriteable := shared.AuthTokenWriteable(cfg, hostname)
|
_, tokenIsWriteable := shared.AuthTokenWriteable(cfg, hostname)
|
||||||
|
|
||||||
statusInfo[hostname] = []string{}
|
statusInfo[hostname] = []string{}
|
||||||
|
|
@ -97,24 +104,29 @@ func statusRun(opts *StatusOptions) error {
|
||||||
statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...))
|
statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := shared.HasMinimumScopes(httpClient, hostname, token); err != nil {
|
scopesHeader, err := shared.GetScopes(httpClient, hostname, token)
|
||||||
|
if err != nil {
|
||||||
|
addMsg("%s %s: authentication failed", cs.Red("X"), hostname)
|
||||||
|
addMsg("- The %s token in %s is no longer valid.", cs.Bold(hostname), tokenSource)
|
||||||
|
if tokenIsWriteable {
|
||||||
|
addMsg("- To re-authenticate, run: %s %s",
|
||||||
|
cs.Bold("gh auth login -h"), cs.Bold(hostname))
|
||||||
|
addMsg("- To forget about this host, run: %s %s",
|
||||||
|
cs.Bold("gh auth logout -h"), cs.Bold(hostname))
|
||||||
|
}
|
||||||
|
failed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := shared.HeaderHasMinimumScopes(scopesHeader); err != nil {
|
||||||
var missingScopes *shared.MissingScopesError
|
var missingScopes *shared.MissingScopesError
|
||||||
if errors.As(err, &missingScopes) {
|
if errors.As(err, &missingScopes) {
|
||||||
addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err)
|
addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err)
|
||||||
if tokenIsWriteable {
|
if tokenIsWriteable {
|
||||||
addMsg("- To request missing scopes, run: %s %s\n",
|
addMsg("- To request missing scopes, run: %s %s",
|
||||||
cs.Bold("gh auth refresh -h"),
|
cs.Bold("gh auth refresh -h"),
|
||||||
cs.Bold(hostname))
|
cs.Bold(hostname))
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
addMsg("%s %s: authentication failed", cs.Red("X"), hostname)
|
|
||||||
addMsg("- The %s token in %s is no longer valid.", cs.Bold(hostname), tokenSource)
|
|
||||||
if tokenIsWriteable {
|
|
||||||
addMsg("- To re-authenticate, run: %s %s",
|
|
||||||
cs.Bold("gh auth login -h"), cs.Bold(hostname))
|
|
||||||
addMsg("- To forget about this host, run: %s %s",
|
|
||||||
cs.Bold("gh auth logout -h"), cs.Bold(hostname))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
failed = true
|
failed = true
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -122,23 +134,23 @@ func statusRun(opts *StatusOptions) error {
|
||||||
username, err := api.CurrentLoginName(apiClient, hostname)
|
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
addMsg("%s %s: api call failed: %s", cs.Red("X"), hostname, err)
|
addMsg("%s %s: api call failed: %s", cs.Red("X"), hostname, err)
|
||||||
|
failed = true
|
||||||
}
|
}
|
||||||
|
|
||||||
addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource)
|
addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.Bold(username), tokenSource)
|
||||||
proto, _ := cfg.GetOrDefault(hostname, "git_protocol")
|
proto, _ := cfg.GetOrDefault(hostname, "git_protocol")
|
||||||
if proto != "" {
|
if proto != "" {
|
||||||
addMsg("%s Git operations for %s configured to use %s protocol.",
|
addMsg("%s Git operations for %s configured to use %s protocol.",
|
||||||
cs.SuccessIcon(), hostname, cs.Bold(proto))
|
cs.SuccessIcon(), hostname, cs.Bold(proto))
|
||||||
}
|
}
|
||||||
tokenDisplay := "*******************"
|
addMsg("%s Token: %s", cs.SuccessIcon(), displayToken(token, opts.ShowToken))
|
||||||
if opts.ShowToken {
|
|
||||||
tokenDisplay = token
|
|
||||||
}
|
|
||||||
addMsg("%s Token: %s", cs.SuccessIcon(), tokenDisplay)
|
|
||||||
}
|
|
||||||
addMsg("")
|
|
||||||
|
|
||||||
// NB we could take this opportunity to add or fix the "user" key in the hosts config. I chose
|
if scopesHeader != "" {
|
||||||
// not to since I wanted this command to be read-only.
|
addMsg("%s Token scopes: %s", cs.SuccessIcon(), scopesHeader)
|
||||||
|
} else if expectScopes(token) {
|
||||||
|
addMsg("%s Token scopes: none", cs.Red("X"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isHostnameFound {
|
if !isHostnameFound {
|
||||||
|
|
@ -147,11 +159,16 @@ func statusRun(opts *StatusOptions) error {
|
||||||
return cmdutil.SilentError
|
return cmdutil.SilentError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prevEntry := false
|
||||||
for _, hostname := range hostnames {
|
for _, hostname := range hostnames {
|
||||||
lines, ok := statusInfo[hostname]
|
lines, ok := statusInfo[hostname]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if prevEntry {
|
||||||
|
fmt.Fprint(stderr, "\n")
|
||||||
|
}
|
||||||
|
prevEntry = true
|
||||||
fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname))
|
fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname))
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
fmt.Fprintf(stderr, " %s\n", line)
|
fmt.Fprintf(stderr, " %s\n", line)
|
||||||
|
|
@ -164,3 +181,20 @@ func statusRun(opts *StatusOptions) error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func displayToken(token string, printRaw bool) string {
|
||||||
|
if printRaw {
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx := strings.LastIndexByte(token, '_'); idx > -1 {
|
||||||
|
prefix := token[0 : idx+1]
|
||||||
|
return prefix + strings.Repeat("*", len(token)-len(prefix))
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Repeat("*", len(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func expectScopes(token string) bool {
|
||||||
|
return strings.HasPrefix(token, "ghp_") || strings.HasPrefix(token, "gho_")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,11 @@ package status
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
"github.com/cli/cli/v2/internal/config"
|
"github.com/cli/cli/v2/internal/config"
|
||||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
"github.com/cli/cli/v2/pkg/httpmock"
|
"github.com/cli/cli/v2/pkg/httpmock"
|
||||||
|
|
@ -74,12 +76,12 @@ func Test_statusRun(t *testing.T) {
|
||||||
readConfigs := config.StubWriteConfig(t)
|
readConfigs := config.StubWriteConfig(t)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
opts *StatusOptions
|
opts *StatusOptions
|
||||||
httpStubs func(*httpmock.Registry)
|
httpStubs func(*httpmock.Registry)
|
||||||
cfgStubs func(*config.ConfigMock)
|
cfgStubs func(*config.ConfigMock)
|
||||||
wantErr string
|
wantErr string
|
||||||
wantErrOut *regexp.Regexp
|
wantOut string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "hostname set",
|
name: "hostname set",
|
||||||
|
|
@ -91,12 +93,20 @@ func Test_statusRun(t *testing.T) {
|
||||||
c.Set("github.com", "oauth_token", "abc123")
|
c.Set("github.com", "oauth_token", "abc123")
|
||||||
},
|
},
|
||||||
httpStubs: func(reg *httpmock.Registry) {
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
// mocks for HeaderHasMinimumScopes api requests to a non-github.com host
|
||||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||||
|
// mock for CurrentLoginName
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
},
|
},
|
||||||
wantErrOut: regexp.MustCompile(`Logged in to joel.miller as.*tess`),
|
wantOut: heredoc.Doc(`
|
||||||
|
joel.miller
|
||||||
|
✓ Logged in to joel.miller as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for joel.miller configured to use https protocol.
|
||||||
|
✓ Token: ******
|
||||||
|
✓ Token scopes: repo,read:org
|
||||||
|
`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing scope",
|
name: "missing scope",
|
||||||
|
|
@ -106,14 +116,27 @@ func Test_statusRun(t *testing.T) {
|
||||||
c.Set("github.com", "oauth_token", "abc123")
|
c.Set("github.com", "oauth_token", "abc123")
|
||||||
},
|
},
|
||||||
httpStubs: func(reg *httpmock.Registry) {
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
// mocks for HeaderHasMinimumScopes api requests to a non-github.com host
|
||||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo"))
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo"))
|
||||||
|
// mocks for HeaderHasMinimumScopes api requests to github.com host
|
||||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||||
|
// mock for CurrentLoginName
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
},
|
},
|
||||||
wantErrOut: regexp.MustCompile(`joel.miller: missing required.*Logged in to github.com as.*tess`),
|
wantErr: "SilentError",
|
||||||
wantErr: "SilentError",
|
wantOut: heredoc.Doc(`
|
||||||
|
joel.miller
|
||||||
|
X joel.miller: the token in GH_CONFIG_DIR/hosts.yml is missing required scope 'read:org'
|
||||||
|
- To request missing scopes, run: gh auth refresh -h joel.miller
|
||||||
|
|
||||||
|
github.com
|
||||||
|
✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for github.com configured to use https protocol.
|
||||||
|
✓ Token: ******
|
||||||
|
✓ Token scopes: repo,read:org
|
||||||
|
`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "bad token",
|
name: "bad token",
|
||||||
|
|
@ -123,25 +146,47 @@ func Test_statusRun(t *testing.T) {
|
||||||
c.Set("github.com", "oauth_token", "abc123")
|
c.Set("github.com", "oauth_token", "abc123")
|
||||||
},
|
},
|
||||||
httpStubs: func(reg *httpmock.Registry) {
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
// mock for HeaderHasMinimumScopes api requests to a non-github.com host
|
||||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno"))
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno"))
|
||||||
|
// mock for HeaderHasMinimumScopes api requests to github.com
|
||||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||||
|
// mock for CurrentLoginName
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
},
|
},
|
||||||
wantErrOut: regexp.MustCompile(`joel.miller: authentication failed.*Logged in to github.com as.*tess`),
|
wantErr: "SilentError",
|
||||||
wantErr: "SilentError",
|
wantOut: heredoc.Doc(`
|
||||||
|
joel.miller
|
||||||
|
X joel.miller: authentication failed
|
||||||
|
- The joel.miller token in GH_CONFIG_DIR/hosts.yml is no longer valid.
|
||||||
|
- To re-authenticate, run: gh auth login -h joel.miller
|
||||||
|
- To forget about this host, run: gh auth logout -h joel.miller
|
||||||
|
|
||||||
|
github.com
|
||||||
|
✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for github.com configured to use https protocol.
|
||||||
|
✓ Token: ******
|
||||||
|
✓ Token scopes: repo,read:org
|
||||||
|
`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "all good",
|
name: "all good",
|
||||||
opts: &StatusOptions{},
|
opts: &StatusOptions{},
|
||||||
cfgStubs: func(c *config.ConfigMock) {
|
cfgStubs: func(c *config.ConfigMock) {
|
||||||
c.Set("github.com", "oauth_token", "abc123")
|
c.Set("github.com", "oauth_token", "gho_abc123")
|
||||||
c.Set("joel.miller", "oauth_token", "abc123")
|
c.Set("joel.miller", "oauth_token", "gho_abc123")
|
||||||
},
|
},
|
||||||
httpStubs: func(reg *httpmock.Registry) {
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
// mocks for HeaderHasMinimumScopes api requests to github.com
|
||||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
reg.Register(
|
||||||
|
httpmock.REST("GET", ""),
|
||||||
|
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", "repo, read:org"))
|
||||||
|
// mocks for HeaderHasMinimumScopes api requests to a non-github.com host
|
||||||
|
reg.Register(
|
||||||
|
httpmock.REST("GET", "api/v3/"),
|
||||||
|
httpmock.WithHeader(httpmock.ScopesResponder("repo,read:org"), "X-Oauth-Scopes", ""))
|
||||||
|
// mock for CurrentLoginName, one for each host
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
|
@ -149,26 +194,65 @@ func Test_statusRun(t *testing.T) {
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
},
|
},
|
||||||
wantErrOut: regexp.MustCompile(`(?s)Logged in to github.com as.*tess.*Logged in to joel.miller as.*tess`),
|
wantOut: heredoc.Doc(`
|
||||||
|
github.com
|
||||||
|
✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for github.com configured to use https protocol.
|
||||||
|
✓ Token: gho_******
|
||||||
|
✓ Token scopes: repo, read:org
|
||||||
|
|
||||||
|
joel.miller
|
||||||
|
✓ Logged in to joel.miller as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for joel.miller configured to use https protocol.
|
||||||
|
✓ Token: gho_******
|
||||||
|
X Token scopes: none
|
||||||
|
`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "hide token",
|
name: "server-to-server token",
|
||||||
opts: &StatusOptions{},
|
opts: &StatusOptions{},
|
||||||
cfgStubs: func(c *config.ConfigMock) {
|
cfgStubs: func(c *config.ConfigMock) {
|
||||||
c.Set("joel.miller", "oauth_token", "abc123")
|
c.Set("github.com", "oauth_token", "ghs_xxx")
|
||||||
c.Set("github.com", "oauth_token", "xyz456")
|
|
||||||
},
|
},
|
||||||
httpStubs: func(reg *httpmock.Registry) {
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
// mocks for HeaderHasMinimumScopes api requests to github.com
|
||||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.REST("GET", ""),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.ScopesResponder(""))
|
||||||
|
// mock for CurrentLoginName
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
},
|
},
|
||||||
wantErrOut: regexp.MustCompile(`(?s)Token: \*{19}.*Token: \*{19}`),
|
wantOut: heredoc.Doc(`
|
||||||
|
github.com
|
||||||
|
✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for github.com configured to use https protocol.
|
||||||
|
✓ Token: ghs_***
|
||||||
|
`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PAT V2 token",
|
||||||
|
opts: &StatusOptions{},
|
||||||
|
cfgStubs: func(c *config.ConfigMock) {
|
||||||
|
c.Set("github.com", "oauth_token", "github_pat_xxx")
|
||||||
|
},
|
||||||
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
// mocks for HeaderHasMinimumScopes api requests to github.com
|
||||||
|
reg.Register(
|
||||||
|
httpmock.REST("GET", ""),
|
||||||
|
httpmock.ScopesResponder(""))
|
||||||
|
// mock for CurrentLoginName
|
||||||
|
reg.Register(
|
||||||
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
},
|
||||||
|
wantOut: heredoc.Doc(`
|
||||||
|
github.com
|
||||||
|
✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for github.com configured to use https protocol.
|
||||||
|
✓ Token: github_pat_***
|
||||||
|
`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "show token",
|
name: "show token",
|
||||||
|
|
@ -180,8 +264,11 @@ func Test_statusRun(t *testing.T) {
|
||||||
c.Set("joel.miller", "oauth_token", "abc123")
|
c.Set("joel.miller", "oauth_token", "abc123")
|
||||||
},
|
},
|
||||||
httpStubs: func(reg *httpmock.Registry) {
|
httpStubs: func(reg *httpmock.Registry) {
|
||||||
|
// mocks for HeaderHasMinimumScopes on a non-github.com host
|
||||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||||
|
// mocks for HeaderHasMinimumScopes on github.com
|
||||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||||
|
// mock for CurrentLoginName, one for each host
|
||||||
reg.Register(
|
reg.Register(
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
|
|
@ -189,7 +276,19 @@ func Test_statusRun(t *testing.T) {
|
||||||
httpmock.GraphQL(`query UserCurrent\b`),
|
httpmock.GraphQL(`query UserCurrent\b`),
|
||||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||||
},
|
},
|
||||||
wantErrOut: regexp.MustCompile(`(?s)Token: xyz456.*Token: abc123`),
|
wantOut: heredoc.Doc(`
|
||||||
|
github.com
|
||||||
|
✓ Logged in to github.com as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for github.com configured to use https protocol.
|
||||||
|
✓ Token: xyz456
|
||||||
|
✓ Token scopes: repo,read:org
|
||||||
|
|
||||||
|
joel.miller
|
||||||
|
✓ Logged in to joel.miller as tess (GH_CONFIG_DIR/hosts.yml)
|
||||||
|
✓ Git operations for joel.miller configured to use https protocol.
|
||||||
|
✓ Token: abc123
|
||||||
|
✓ Token scopes: repo,read:org
|
||||||
|
`),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing hostname",
|
name: "missing hostname",
|
||||||
|
|
@ -199,9 +298,9 @@ func Test_statusRun(t *testing.T) {
|
||||||
cfgStubs: func(c *config.ConfigMock) {
|
cfgStubs: func(c *config.ConfigMock) {
|
||||||
c.Set("github.com", "oauth_token", "abc123")
|
c.Set("github.com", "oauth_token", "abc123")
|
||||||
},
|
},
|
||||||
httpStubs: func(reg *httpmock.Registry) {},
|
httpStubs: func(reg *httpmock.Registry) {},
|
||||||
wantErrOut: regexp.MustCompile(`(?s)Hostname "github.example.com" not found among authenticated GitHub hosts`),
|
wantErr: "SilentError",
|
||||||
wantErr: "SilentError",
|
wantOut: "Hostname \"github.example.com\" not found among authenticated GitHub hosts\n",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +326,7 @@ func Test_statusRun(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
reg := &httpmock.Registry{}
|
reg := &httpmock.Registry{}
|
||||||
|
defer reg.Verify(t)
|
||||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||||
return &http.Client{Transport: reg}, nil
|
return &http.Client{Transport: reg}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -237,16 +337,12 @@ func Test_statusRun(t *testing.T) {
|
||||||
err := statusRun(tt.opts)
|
err := statusRun(tt.opts)
|
||||||
if tt.wantErr != "" {
|
if tt.wantErr != "" {
|
||||||
assert.EqualError(t, err, tt.wantErr)
|
assert.EqualError(t, err, tt.wantErr)
|
||||||
return
|
|
||||||
} else {
|
} else {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.wantErrOut == nil {
|
output := strings.ReplaceAll(stderr.String(), config.ConfigDir()+string(filepath.Separator), "GH_CONFIG_DIR/")
|
||||||
assert.Equal(t, "", stderr.String())
|
assert.Equal(t, tt.wantOut, output)
|
||||||
} else {
|
|
||||||
assert.True(t, tt.wantErrOut.MatchString(stderr.String()))
|
|
||||||
}
|
|
||||||
|
|
||||||
mainBuf := bytes.Buffer{}
|
mainBuf := bytes.Buffer{}
|
||||||
hostsBuf := bytes.Buffer{}
|
hostsBuf := bytes.Buffer{}
|
||||||
|
|
@ -254,8 +350,6 @@ func Test_statusRun(t *testing.T) {
|
||||||
|
|
||||||
assert.Equal(t, "", mainBuf.String())
|
assert.Equal(t, "", mainBuf.String())
|
||||||
assert.Equal(t, "", hostsBuf.String())
|
assert.Equal(t, "", hostsBuf.String())
|
||||||
|
|
||||||
reg.Verify(t)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,8 @@ func tokenRun(opts *TokenOptions) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
key := "oauth_token"
|
val, _ := cfg.AuthToken(hostname)
|
||||||
val, err := cfg.GetOrDefault(hostname, key)
|
if val == "" {
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("no oauth token")
|
return fmt.Errorf("no oauth token")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ type BrowseOptions struct {
|
||||||
Branch string
|
Branch string
|
||||||
CommitFlag bool
|
CommitFlag bool
|
||||||
ProjectsFlag bool
|
ProjectsFlag bool
|
||||||
|
ReleasesFlag bool
|
||||||
SettingsFlag bool
|
SettingsFlag bool
|
||||||
WikiFlag bool
|
WikiFlag bool
|
||||||
NoBrowserFlag bool
|
NoBrowserFlag bool
|
||||||
|
|
@ -94,12 +95,13 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cmdutil.MutuallyExclusive(
|
if err := cmdutil.MutuallyExclusive(
|
||||||
"specify only one of `--branch`, `--commit`, `--projects`, `--wiki`, or `--settings`",
|
"specify only one of `--branch`, `--commit`, `--releases`, `--projects`, `--wiki`, or `--settings`",
|
||||||
opts.Branch != "",
|
opts.Branch != "",
|
||||||
opts.CommitFlag,
|
opts.CommitFlag,
|
||||||
opts.WikiFlag,
|
opts.WikiFlag,
|
||||||
opts.SettingsFlag,
|
opts.SettingsFlag,
|
||||||
opts.ProjectsFlag,
|
opts.ProjectsFlag,
|
||||||
|
opts.ReleasesFlag,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -116,6 +118,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
||||||
|
|
||||||
cmdutil.EnableRepoOverride(cmd, f)
|
cmdutil.EnableRepoOverride(cmd, f)
|
||||||
cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open repository projects")
|
cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open repository projects")
|
||||||
|
cmd.Flags().BoolVarP(&opts.ReleasesFlag, "releases", "r", false, "Open repository releases")
|
||||||
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki")
|
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki")
|
||||||
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
|
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
|
||||||
cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser")
|
cmd.Flags().BoolVarP(&opts.NoBrowserFlag, "no-browser", "n", false, "Print destination URL instead of opening the browser")
|
||||||
|
|
@ -160,6 +163,8 @@ func parseSection(baseRepo ghrepo.Interface, opts *BrowseOptions) (string, error
|
||||||
if opts.SelectorArg == "" {
|
if opts.SelectorArg == "" {
|
||||||
if opts.ProjectsFlag {
|
if opts.ProjectsFlag {
|
||||||
return "projects", nil
|
return "projects", nil
|
||||||
|
} else if opts.ReleasesFlag {
|
||||||
|
return "releases", nil
|
||||||
} else if opts.SettingsFlag {
|
} else if opts.SettingsFlag {
|
||||||
return "settings", nil
|
return "settings", nil
|
||||||
} else if opts.WikiFlag {
|
} else if opts.WikiFlag {
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,14 @@ func TestNewCmdBrowse(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantsErr: false,
|
wantsErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "releases flag",
|
||||||
|
cli: "--releases",
|
||||||
|
wants: BrowseOptions{
|
||||||
|
ReleasesFlag: true,
|
||||||
|
},
|
||||||
|
wantsErr: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "wiki flag",
|
name: "wiki flag",
|
||||||
cli: "--wiki",
|
cli: "--wiki",
|
||||||
|
|
@ -141,6 +149,7 @@ func TestNewCmdBrowse(t *testing.T) {
|
||||||
assert.Equal(t, tt.wants.Branch, opts.Branch)
|
assert.Equal(t, tt.wants.Branch, opts.Branch)
|
||||||
assert.Equal(t, tt.wants.SelectorArg, opts.SelectorArg)
|
assert.Equal(t, tt.wants.SelectorArg, opts.SelectorArg)
|
||||||
assert.Equal(t, tt.wants.ProjectsFlag, opts.ProjectsFlag)
|
assert.Equal(t, tt.wants.ProjectsFlag, opts.ProjectsFlag)
|
||||||
|
assert.Equal(t, tt.wants.ReleasesFlag, opts.ReleasesFlag)
|
||||||
assert.Equal(t, tt.wants.WikiFlag, opts.WikiFlag)
|
assert.Equal(t, tt.wants.WikiFlag, opts.WikiFlag)
|
||||||
assert.Equal(t, tt.wants.NoBrowserFlag, opts.NoBrowserFlag)
|
assert.Equal(t, tt.wants.NoBrowserFlag, opts.NoBrowserFlag)
|
||||||
assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag)
|
assert.Equal(t, tt.wants.SettingsFlag, opts.SettingsFlag)
|
||||||
|
|
@ -190,6 +199,14 @@ func Test_runBrowse(t *testing.T) {
|
||||||
baseRepo: ghrepo.New("ttran112", "7ate9"),
|
baseRepo: ghrepo.New("ttran112", "7ate9"),
|
||||||
expectedURL: "https://github.com/ttran112/7ate9/projects",
|
expectedURL: "https://github.com/ttran112/7ate9/projects",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "releases flag",
|
||||||
|
opts: BrowseOptions{
|
||||||
|
ReleasesFlag: true,
|
||||||
|
},
|
||||||
|
baseRepo: ghrepo.New("ttran112", "7ate9"),
|
||||||
|
expectedURL: "https://github.com/ttran112/7ate9/releases",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "wiki flag",
|
name: "wiki flag",
|
||||||
opts: BrowseOptions{
|
opts: BrowseOptions{
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ func TestPendingOperationDisallowsCode(t *testing.T) {
|
||||||
|
|
||||||
func testingCodeApp() *App {
|
func testingCodeApp() *App {
|
||||||
ios, _, _, _ := iostreams.Test()
|
ios, _, _, _ := iostreams.Test()
|
||||||
return NewApp(ios, nil, testCodeApiMock(), nil)
|
return NewApp(ios, nil, testCodeApiMock(), nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCodeApiMock() *apiClientMock {
|
func testCodeApiMock() *apiClientMock {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import (
|
||||||
|
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/AlecAivazis/survey/v2/terminal"
|
"github.com/AlecAivazis/survey/v2/terminal"
|
||||||
|
clicontext "github.com/cli/cli/v2/context"
|
||||||
"github.com/cli/cli/v2/internal/browser"
|
"github.com/cli/cli/v2/internal/browser"
|
||||||
"github.com/cli/cli/v2/internal/codespaces"
|
"github.com/cli/cli/v2/internal/codespaces"
|
||||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||||
|
|
@ -32,9 +33,10 @@ type App struct {
|
||||||
errLogger *log.Logger
|
errLogger *log.Logger
|
||||||
executable executable
|
executable executable
|
||||||
browser browser.Browser
|
browser browser.Browser
|
||||||
|
remotes func() (clicontext.Remotes, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient, browser browser.Browser) *App {
|
func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient, browser browser.Browser, remotes func() (clicontext.Remotes, error)) *App {
|
||||||
errLogger := log.New(io.ErrOut, "", 0)
|
errLogger := log.New(io.ErrOut, "", 0)
|
||||||
|
|
||||||
return &App{
|
return &App{
|
||||||
|
|
@ -43,6 +45,7 @@ func NewApp(io *iostreams.IOStreams, exe executable, apiClient apiClient, browse
|
||||||
errLogger: errLogger,
|
errLogger: errLogger,
|
||||||
executable: exe,
|
executable: exe,
|
||||||
browser: browser,
|
browser: browser,
|
||||||
|
remotes: remotes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -84,6 +87,7 @@ func startLiveShareSession(ctx context.Context, codespace *api.Codespace, a *App
|
||||||
|
|
||||||
//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient
|
//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient
|
||||||
type apiClient interface {
|
type apiClient interface {
|
||||||
|
GetUser(ctx context.Context) (*api.User, error)
|
||||||
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
|
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
|
||||||
GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error)
|
GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error)
|
||||||
ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error)
|
ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error)
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/cli/cli/v2/internal/codespaces"
|
"github.com/cli/cli/v2/internal/codespaces"
|
||||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||||
|
"github.com/cli/cli/v2/internal/ghrepo"
|
||||||
"github.com/cli/cli/v2/internal/text"
|
"github.com/cli/cli/v2/internal/text"
|
||||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
@ -119,12 +120,24 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
||||||
|
|
||||||
promptForRepoAndBranch := userInputs.Repository == ""
|
promptForRepoAndBranch := userInputs.Repository == ""
|
||||||
if promptForRepoAndBranch {
|
if promptForRepoAndBranch {
|
||||||
|
var defaultRepo string
|
||||||
|
if remotes, _ := a.remotes(); remotes != nil {
|
||||||
|
if defaultRemote, _ := remotes.ResolvedRemote(); defaultRemote != nil {
|
||||||
|
// this is a remote explicitly chosen via `repo set-default`
|
||||||
|
defaultRepo = ghrepo.FullName(defaultRemote)
|
||||||
|
} else if len(remotes) > 0 {
|
||||||
|
// as a fallback, just pick the first remote
|
||||||
|
defaultRepo = ghrepo.FullName(remotes[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
repoQuestions := []*survey.Question{
|
repoQuestions := []*survey.Question{
|
||||||
{
|
{
|
||||||
Name: "repository",
|
Name: "repository",
|
||||||
Prompt: &survey.Input{
|
Prompt: &survey.Input{
|
||||||
Message: "Repository:",
|
Message: "Repository:",
|
||||||
Help: "Search for repos by name. To search within an org or user, or to see private repos, enter at least ':user/'.",
|
Help: "Search for repos by name. To search within an org or user, or to see private repos, enter at least ':user/'.",
|
||||||
|
Default: defaultRepo,
|
||||||
Suggest: func(toComplete string) []string {
|
Suggest: func(toComplete string) []string {
|
||||||
return getRepoSuggestions(ctx, a.apiClient, toComplete)
|
return getRepoSuggestions(ctx, a.apiClient, toComplete)
|
||||||
},
|
},
|
||||||
|
|
@ -157,7 +170,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error checking codespace ownership: %w", err)
|
return fmt.Errorf("error checking codespace ownership: %w", err)
|
||||||
} else if billableOwner != nil && billableOwner.Type == "Organization" {
|
} else if billableOwner != nil && (billableOwner.Type == "Organization" || billableOwner.Type == "User") {
|
||||||
cs := a.io.ColorScheme()
|
cs := a.io.ColorScheme()
|
||||||
fmt.Fprintln(a.io.ErrOut, cs.Blue(" ✓ Codespaces usage for this repository is paid for by "+billableOwner.Login))
|
fmt.Fprintln(a.io.ErrOut, cs.Blue(" ✓ Codespaces usage for this repository is paid for by "+billableOwner.Login))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ func TestApp_Create(t *testing.T) {
|
||||||
retentionPeriod: NullableDuration{durationPtr(48 * time.Hour)},
|
retentionPeriod: NullableDuration{durationPtr(48 * time.Hour)},
|
||||||
},
|
},
|
||||||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||||
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create with explicit display name",
|
name: "create with explicit display name",
|
||||||
|
|
@ -78,6 +79,7 @@ func TestApp_Create(t *testing.T) {
|
||||||
displayName: "funky flute",
|
displayName: "funky flute",
|
||||||
},
|
},
|
||||||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||||
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create codespace with default branch shows idle timeout notice if present",
|
name: "create codespace with default branch shows idle timeout notice if present",
|
||||||
|
|
@ -111,6 +113,7 @@ func TestApp_Create(t *testing.T) {
|
||||||
devContainerPath: ".devcontainer/foobar/devcontainer.json",
|
devContainerPath: ".devcontainer/foobar/devcontainer.json",
|
||||||
},
|
},
|
||||||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||||
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create codespace with devcontainer path results in selecting the correct machine type",
|
name: "create codespace with devcontainer path results in selecting the correct machine type",
|
||||||
|
|
@ -172,6 +175,7 @@ func TestApp_Create(t *testing.T) {
|
||||||
devContainerPath: ".devcontainer/foobar/devcontainer.json",
|
devContainerPath: ".devcontainer/foobar/devcontainer.json",
|
||||||
},
|
},
|
||||||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||||
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create codespace with default branch with default devcontainer if no path provided and no devcontainer files exist in the repo",
|
name: "create codespace with default branch with default devcontainer if no path provided and no devcontainer files exist in the repo",
|
||||||
|
|
@ -205,7 +209,7 @@ func TestApp_Create(t *testing.T) {
|
||||||
idleTimeout: 30 * time.Minute,
|
idleTimeout: 30 * time.Minute,
|
||||||
},
|
},
|
||||||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||||
wantStderr: "Notice: Idle timeout for this codespace is set to 10 minutes in compliance with your organization's policy\n",
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\nNotice: Idle timeout for this codespace is set to 10 minutes in compliance with your organization's policy\n",
|
||||||
isTTY: true,
|
isTTY: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -224,7 +228,8 @@ func TestApp_Create(t *testing.T) {
|
||||||
showStatus: false,
|
showStatus: false,
|
||||||
idleTimeout: 30 * time.Minute,
|
idleTimeout: 30 * time.Minute,
|
||||||
},
|
},
|
||||||
wantErr: fmt.Errorf("error getting devcontainer.json paths: some error"),
|
wantErr: fmt.Errorf("error getting devcontainer.json paths: some error"),
|
||||||
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create codespace with default branch does not show idle timeout notice if not conntected to terminal",
|
name: "create codespace with default branch does not show idle timeout notice if not conntected to terminal",
|
||||||
|
|
@ -252,7 +257,7 @@ func TestApp_Create(t *testing.T) {
|
||||||
idleTimeout: 30 * time.Minute,
|
idleTimeout: 30 * time.Minute,
|
||||||
},
|
},
|
||||||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||||
wantStderr: "",
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||||
isTTY: false,
|
isTTY: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -280,7 +285,8 @@ func TestApp_Create(t *testing.T) {
|
||||||
idleTimeout: 30 * time.Minute,
|
idleTimeout: 30 * time.Minute,
|
||||||
},
|
},
|
||||||
wantErr: cmdutil.SilentError,
|
wantErr: cmdutil.SilentError,
|
||||||
wantStderr: `You must authorize or deny additional permissions requested by this codespace before continuing.
|
wantStderr: ` ✓ Codespaces usage for this repository is paid for by monalisa
|
||||||
|
You must authorize or deny additional permissions requested by this codespace before continuing.
|
||||||
Open this URL in your browser to review and authorize additional permissions: example.com/permissions
|
Open this URL in your browser to review and authorize additional permissions: example.com/permissions
|
||||||
Alternatively, you can run "create" with the "--default-permissions" option to continue without authorizing additional permissions.
|
Alternatively, you can run "create" with the "--default-permissions" option to continue without authorizing additional permissions.
|
||||||
`,
|
`,
|
||||||
|
|
@ -304,7 +310,31 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
||||||
wantErr: fmt.Errorf("error checking codespace ownership: some error"),
|
wantErr: fmt.Errorf("error checking codespace ownership: some error"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mentions billable owner when org covers codepaces for a repository",
|
name: "mentions User as billable owner when org does not cover codepaces for a repository",
|
||||||
|
fields: fields{
|
||||||
|
apiClient: apiCreateDefaults(&apiClientMock{
|
||||||
|
GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) {
|
||||||
|
return &api.User{
|
||||||
|
Type: "User",
|
||||||
|
Login: "monalisa",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||||
|
return &api.Codespace{
|
||||||
|
Name: "monalisa-dotfiles-abcd1234",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
opts: createOptions{
|
||||||
|
repo: "monalisa/dotfiles",
|
||||||
|
branch: "main",
|
||||||
|
},
|
||||||
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||||
|
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mentions Organization as billable owner when org covers codepaces for a repository",
|
||||||
fields: fields{
|
fields: fields{
|
||||||
apiClient: apiCreateDefaults(&apiClientMock{
|
apiClient: apiCreateDefaults(&apiClientMock{
|
||||||
GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) {
|
GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) {
|
||||||
|
|
@ -330,6 +360,28 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
||||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by megacorp\n",
|
wantStderr: " ✓ Codespaces usage for this repository is paid for by megacorp\n",
|
||||||
wantStdout: "megacorp-private-abcd1234\n",
|
wantStdout: "megacorp-private-abcd1234\n",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "does not mention billable owner when not an expected type",
|
||||||
|
fields: fields{
|
||||||
|
apiClient: apiCreateDefaults(&apiClientMock{
|
||||||
|
GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) {
|
||||||
|
return &api.User{
|
||||||
|
Type: "UnexpectedBillableOwnerType",
|
||||||
|
Login: "mega-owner",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||||
|
return &api.Codespace{
|
||||||
|
Name: "megacorp-private-abcd1234",
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
opts: createOptions{
|
||||||
|
repo: "megacorp/private",
|
||||||
|
},
|
||||||
|
wantStdout: "megacorp-private-abcd1234\n",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -83,11 +83,17 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
|
||||||
var codespaces []*api.Codespace
|
var codespaces []*api.Codespace
|
||||||
nameFilter := opts.codespaceName
|
nameFilter := opts.codespaceName
|
||||||
if nameFilter == "" {
|
if nameFilter == "" {
|
||||||
var codespaces []*api.Codespace
|
a.StartProgressIndicatorWithLabel("Fetching codespaces")
|
||||||
err = a.RunWithProgress("Fetching codespaces", func() (err error) {
|
userName := opts.userName
|
||||||
codespaces, err = a.apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{OrgName: opts.orgName, UserName: opts.userName})
|
if userName == "" && opts.orgName != "" {
|
||||||
return
|
currentUser, err := a.apiClient.GetUser(ctx)
|
||||||
})
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
userName = currentUser.Login
|
||||||
|
}
|
||||||
|
codespaces, err = a.apiClient.ListCodespaces(ctx, api.ListCodespacesOptions{OrgName: opts.orgName, UserName: userName})
|
||||||
|
a.StopProgressIndicator()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error getting codespaces: %w", err)
|
return fmt.Errorf("error getting codespaces: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -202,10 +202,28 @@ func TestDelete(t *testing.T) {
|
||||||
wantStdout: "",
|
wantStdout: "",
|
||||||
wantErr: true,
|
wantErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "deletion for org codespace succeeds without username",
|
||||||
|
opts: deleteOptions{
|
||||||
|
deleteAll: true,
|
||||||
|
orgName: "bookish",
|
||||||
|
},
|
||||||
|
codespaces: []*api.Codespace{
|
||||||
|
{
|
||||||
|
Name: "monalisa-spoonknife-123",
|
||||||
|
Owner: api.User{Login: "monalisa"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantDeleted: []string{"monalisa-spoonknife-123"},
|
||||||
|
wantStdout: "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
apiMock := &apiClientMock{
|
apiMock := &apiClientMock{
|
||||||
|
GetUserFunc: func(_ context.Context) (*api.User, error) {
|
||||||
|
return &api.User{Login: "monalisa"}, nil
|
||||||
|
},
|
||||||
DeleteCodespaceFunc: func(_ context.Context, name string, orgName string, userName string) error {
|
DeleteCodespaceFunc: func(_ context.Context, name string, orgName string, userName string) error {
|
||||||
if tt.deleteErr != nil {
|
if tt.deleteErr != nil {
|
||||||
return tt.deleteErr
|
return tt.deleteErr
|
||||||
|
|
@ -248,11 +266,16 @@ func TestDelete(t *testing.T) {
|
||||||
ios, _, stdout, stderr := iostreams.Test()
|
ios, _, stdout, stderr := iostreams.Test()
|
||||||
ios.SetStdinTTY(true)
|
ios.SetStdinTTY(true)
|
||||||
ios.SetStdoutTTY(true)
|
ios.SetStdoutTTY(true)
|
||||||
app := NewApp(ios, nil, apiMock, nil)
|
app := NewApp(ios, nil, apiMock, nil, nil)
|
||||||
err := app.Delete(context.Background(), opts)
|
err := app.Delete(context.Background(), opts)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
}
|
}
|
||||||
|
for _, listArgs := range apiMock.ListCodespacesCalls() {
|
||||||
|
if listArgs.Opts.OrgName != "" && listArgs.Opts.UserName == "" {
|
||||||
|
t.Errorf("ListCodespaces() expected username option to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
var gotDeleted []string
|
var gotDeleted []string
|
||||||
for _, delArgs := range apiMock.DeleteCodespaceCalls() {
|
for _, delArgs := range apiMock.DeleteCodespaceCalls() {
|
||||||
gotDeleted = append(gotDeleted, delArgs.Name)
|
gotDeleted = append(gotDeleted, delArgs.Name)
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,7 @@ func TestEdit(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
ios, _, stdout, stderr := iostreams.Test()
|
ios, _, stdout, stderr := iostreams.Test()
|
||||||
a := NewApp(ios, nil, apiMock, nil)
|
a := NewApp(ios, nil, apiMock, nil, nil)
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
if tt.cliArgs == nil {
|
if tt.cliArgs == nil {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/cli/cli/v2/internal/codespaces"
|
||||||
"github.com/cli/cli/v2/internal/codespaces/rpc"
|
"github.com/cli/cli/v2/internal/codespaces/rpc"
|
||||||
"github.com/cli/cli/v2/pkg/liveshare"
|
"github.com/cli/cli/v2/pkg/liveshare"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
@ -60,7 +61,7 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 0 to pick a random port
|
// Pass 0 to pick a random port
|
||||||
listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0))
|
listen, _, err := codespaces.ListenTCP(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package codespace
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
|
|
||||||
"github.com/cli/cli/v2/internal/codespaces"
|
"github.com/cli/cli/v2/internal/codespaces"
|
||||||
"github.com/cli/cli/v2/internal/codespaces/rpc"
|
"github.com/cli/cli/v2/internal/codespaces/rpc"
|
||||||
|
|
@ -49,12 +48,11 @@ func (a *App) Logs(ctx context.Context, codespaceName string, follow bool) (err
|
||||||
defer safeClose(session, &err)
|
defer safeClose(session, &err)
|
||||||
|
|
||||||
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
||||||
listen, err := net.Listen("tcp", "127.0.0.1:0") // arbitrary port
|
listen, localPort, err := codespaces.ListenTCP(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer listen.Close()
|
defer listen.Close()
|
||||||
localPort := listen.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
remoteSSHServerPort, sshUser := 0, ""
|
remoteSSHServerPort, sshUser := 0, ""
|
||||||
err = a.RunWithProgress("Fetching SSH Details", func() error {
|
err = a.RunWithProgress("Fetching SSH Details", func() error {
|
||||||
|
|
|
||||||
|
|
@ -36,5 +36,5 @@ func testingLogsApp() *App {
|
||||||
}
|
}
|
||||||
|
|
||||||
ios, _, _, _ := iostreams.Test()
|
ios, _, _, _ := iostreams.Test()
|
||||||
return NewApp(ios, nil, apiMock, nil)
|
return NewApp(ios, nil, apiMock, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ import (
|
||||||
// GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
// GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
||||||
// panic("mock out the GetRepository method")
|
// panic("mock out the GetRepository method")
|
||||||
// },
|
// },
|
||||||
|
// GetUserFunc: func(ctx context.Context) (*api.User, error) {
|
||||||
|
// panic("mock out the GetUser method")
|
||||||
|
// },
|
||||||
// ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
|
// ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
|
||||||
// panic("mock out the ListCodespaces method")
|
// panic("mock out the ListCodespaces method")
|
||||||
// },
|
// },
|
||||||
|
|
@ -95,6 +98,9 @@ type apiClientMock struct {
|
||||||
// GetRepositoryFunc mocks the GetRepository method.
|
// GetRepositoryFunc mocks the GetRepository method.
|
||||||
GetRepositoryFunc func(ctx context.Context, nwo string) (*api.Repository, error)
|
GetRepositoryFunc func(ctx context.Context, nwo string) (*api.Repository, error)
|
||||||
|
|
||||||
|
// GetUserFunc mocks the GetUser method.
|
||||||
|
GetUserFunc func(ctx context.Context) (*api.User, error)
|
||||||
|
|
||||||
// ListCodespacesFunc mocks the ListCodespaces method.
|
// ListCodespacesFunc mocks the ListCodespaces method.
|
||||||
ListCodespacesFunc func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error)
|
ListCodespacesFunc func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error)
|
||||||
|
|
||||||
|
|
@ -201,6 +207,11 @@ type apiClientMock struct {
|
||||||
// Nwo is the nwo argument value.
|
// Nwo is the nwo argument value.
|
||||||
Nwo string
|
Nwo string
|
||||||
}
|
}
|
||||||
|
// GetUser holds details about calls to the GetUser method.
|
||||||
|
GetUser []struct {
|
||||||
|
// Ctx is the ctx argument value.
|
||||||
|
Ctx context.Context
|
||||||
|
}
|
||||||
// ListCodespaces holds details about calls to the ListCodespaces method.
|
// ListCodespaces holds details about calls to the ListCodespaces method.
|
||||||
ListCodespaces []struct {
|
ListCodespaces []struct {
|
||||||
// Ctx is the ctx argument value.
|
// Ctx is the ctx argument value.
|
||||||
|
|
@ -248,6 +259,7 @@ type apiClientMock struct {
|
||||||
lockGetCodespacesMachines sync.RWMutex
|
lockGetCodespacesMachines sync.RWMutex
|
||||||
lockGetOrgMemberCodespace sync.RWMutex
|
lockGetOrgMemberCodespace sync.RWMutex
|
||||||
lockGetRepository sync.RWMutex
|
lockGetRepository sync.RWMutex
|
||||||
|
lockGetUser sync.RWMutex
|
||||||
lockListCodespaces sync.RWMutex
|
lockListCodespaces sync.RWMutex
|
||||||
lockListDevContainers sync.RWMutex
|
lockListDevContainers sync.RWMutex
|
||||||
lockStartCodespace sync.RWMutex
|
lockStartCodespace sync.RWMutex
|
||||||
|
|
@ -658,6 +670,38 @@ func (mock *apiClientMock) GetRepositoryCalls() []struct {
|
||||||
return calls
|
return calls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUser calls GetUserFunc.
|
||||||
|
func (mock *apiClientMock) GetUser(ctx context.Context) (*api.User, error) {
|
||||||
|
if mock.GetUserFunc == nil {
|
||||||
|
panic("apiClientMock.GetUserFunc: method is nil but apiClient.GetUser was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
Ctx context.Context
|
||||||
|
}{
|
||||||
|
Ctx: ctx,
|
||||||
|
}
|
||||||
|
mock.lockGetUser.Lock()
|
||||||
|
mock.calls.GetUser = append(mock.calls.GetUser, callInfo)
|
||||||
|
mock.lockGetUser.Unlock()
|
||||||
|
return mock.GetUserFunc(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserCalls gets all the calls that were made to GetUser.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedapiClient.GetUserCalls())
|
||||||
|
func (mock *apiClientMock) GetUserCalls() []struct {
|
||||||
|
Ctx context.Context
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
Ctx context.Context
|
||||||
|
}
|
||||||
|
mock.lockGetUser.RLock()
|
||||||
|
calls = mock.calls.GetUser
|
||||||
|
mock.lockGetUser.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
// ListCodespaces calls ListCodespacesFunc.
|
// ListCodespaces calls ListCodespacesFunc.
|
||||||
func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
|
func (mock *apiClientMock) ListCodespaces(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
|
||||||
if mock.ListCodespacesFunc == nil {
|
if mock.ListCodespacesFunc == nil {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -392,7 +391,7 @@ func (a *App) ForwardPorts(ctx context.Context, codespaceName string, ports []st
|
||||||
for _, pair := range portPairs {
|
for _, pair := range portPairs {
|
||||||
pair := pair
|
pair := pair
|
||||||
group.Go(func() error {
|
group.Go(func() error {
|
||||||
listen, err := net.Listen("tcp", fmt.Sprintf(":%d", pair.local))
|
listen, _, err := codespaces.ListenTCP(pair.local)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -263,5 +263,5 @@ func testingPortsApp() *App {
|
||||||
|
|
||||||
ios, _, _, _ := iostreams.Test()
|
ios, _, _, _ := iostreams.Test()
|
||||||
|
|
||||||
return NewApp(ios, nil, apiMock, nil)
|
return NewApp(ios, nil, apiMock, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,5 +32,5 @@ func testingRebuildApp(mockCodespace api.Codespace) *App {
|
||||||
}
|
}
|
||||||
|
|
||||||
ios, _, _, _ := iostreams.Test()
|
ios, _, _, _ := iostreams.Test()
|
||||||
return NewApp(ios, nil, apiMock, nil)
|
return NewApp(ios, nil, apiMock, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ func TestApp_Select(t *testing.T) {
|
||||||
ios, _, stdout, stderr := iostreams.Test()
|
ios, _, stdout, stderr := iostreams.Test()
|
||||||
ios.SetStdinTTY(true)
|
ios.SetStdinTTY(true)
|
||||||
ios.SetStdoutTTY(true)
|
ios.SetStdoutTTY(true)
|
||||||
a := NewApp(ios, nil, testSelectApiMock(), nil)
|
a := NewApp(ios, nil, testSelectApiMock(), nil, nil)
|
||||||
|
|
||||||
opts := selectOptions{}
|
opts := selectOptions{}
|
||||||
if tt.outputToFile {
|
if tt.outputToFile {
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
|
|
@ -190,7 +188,7 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
|
||||||
|
|
||||||
if opts.stdio {
|
if opts.stdio {
|
||||||
fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, true)
|
fwd := liveshare.NewPortForwarder(session, "sshd", remoteSSHServerPort, true)
|
||||||
stdio := newReadWriteCloser(os.Stdin, os.Stdout)
|
stdio := liveshare.NewReadWriteHalfCloser(os.Stdin, os.Stdout)
|
||||||
err := fwd.Forward(ctx, stdio) // always non-nil
|
err := fwd.Forward(ctx, stdio) // always non-nil
|
||||||
return fmt.Errorf("tunnel closed: %w", err)
|
return fmt.Errorf("tunnel closed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -201,12 +199,11 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
|
||||||
// Ensure local port is listening before client (Shell) connects.
|
// Ensure local port is listening before client (Shell) connects.
|
||||||
// Unless the user specifies a server port, localSSHServerPort is 0
|
// Unless the user specifies a server port, localSSHServerPort is 0
|
||||||
// and thus the client will pick a random port.
|
// and thus the client will pick a random port.
|
||||||
listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", localSSHServerPort))
|
listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer listen.Close()
|
defer listen.Close()
|
||||||
localSSHServerPort = listen.Addr().(*net.TCPAddr).Port
|
|
||||||
|
|
||||||
connectDestination := opts.profile
|
connectDestination := opts.profile
|
||||||
if connectDestination == "" {
|
if connectDestination == "" {
|
||||||
|
|
@ -748,21 +745,3 @@ func (fl *fileLogger) Name() string {
|
||||||
func (fl *fileLogger) Close() error {
|
func (fl *fileLogger) Close() error {
|
||||||
return fl.f.Close()
|
return fl.f.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
type combinedReadWriteCloser struct {
|
|
||||||
io.ReadCloser
|
|
||||||
io.WriteCloser
|
|
||||||
}
|
|
||||||
|
|
||||||
func newReadWriteCloser(reader io.ReadCloser, writer io.WriteCloser) io.ReadWriteCloser {
|
|
||||||
return &combinedReadWriteCloser{reader, writer}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (crwc *combinedReadWriteCloser) Close() error {
|
|
||||||
werr := crwc.WriteCloser.Close()
|
|
||||||
rerr := crwc.ReadCloser.Close()
|
|
||||||
if werr != nil {
|
|
||||||
return werr
|
|
||||||
}
|
|
||||||
return rerr
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -278,5 +278,5 @@ func testingSSHApp() *App {
|
||||||
}
|
}
|
||||||
|
|
||||||
ios, _, _, _ := iostreams.Test()
|
ios, _, _, _ := iostreams.Test()
|
||||||
return NewApp(ios, nil, apiMock, nil)
|
return NewApp(ios, nil, apiMock, nil, nil)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
"github.com/charmbracelet/glamour"
|
"github.com/charmbracelet/glamour"
|
||||||
"github.com/cli/cli/v2/git"
|
"github.com/cli/cli/v2/git"
|
||||||
"github.com/cli/cli/v2/internal/config"
|
"github.com/cli/cli/v2/internal/config"
|
||||||
|
|
@ -25,16 +26,17 @@ import (
|
||||||
const pagingOffset = 24
|
const pagingOffset = 24
|
||||||
|
|
||||||
type ExtBrowseOpts struct {
|
type ExtBrowseOpts struct {
|
||||||
Cmd *cobra.Command
|
Cmd *cobra.Command
|
||||||
Browser ibrowser
|
Browser ibrowser
|
||||||
IO *iostreams.IOStreams
|
IO *iostreams.IOStreams
|
||||||
Searcher search.Searcher
|
Searcher search.Searcher
|
||||||
Em extensions.ExtensionManager
|
Em extensions.ExtensionManager
|
||||||
Client *http.Client
|
Client *http.Client
|
||||||
Logger *log.Logger
|
Logger *log.Logger
|
||||||
Cfg config.Config
|
Cfg config.Config
|
||||||
Rg *readmeGetter
|
Rg *readmeGetter
|
||||||
Debug bool
|
Debug bool
|
||||||
|
SingleColumn bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type ibrowser interface {
|
type ibrowser interface {
|
||||||
|
|
@ -48,7 +50,8 @@ type uiRegistry struct {
|
||||||
App *tview.Application
|
App *tview.Application
|
||||||
Outerflex *tview.Flex
|
Outerflex *tview.Flex
|
||||||
List *tview.List
|
List *tview.List
|
||||||
Readme *tview.TextView
|
Pages *tview.Pages
|
||||||
|
CmdFlex *tview.Flex
|
||||||
}
|
}
|
||||||
|
|
||||||
type extEntry struct {
|
type extEntry struct {
|
||||||
|
|
@ -83,25 +86,44 @@ func (e extEntry) Description() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
type extList struct {
|
type extList struct {
|
||||||
ui uiRegistry
|
ui uiRegistry
|
||||||
extEntries []extEntry
|
extEntries []extEntry
|
||||||
app *tview.Application
|
app *tview.Application
|
||||||
filter string
|
filter string
|
||||||
opts ExtBrowseOpts
|
opts ExtBrowseOpts
|
||||||
|
QueueUpdateDraw func(func()) *tview.Application
|
||||||
|
WaitGroup wGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type wGroup interface {
|
||||||
|
Add(int)
|
||||||
|
Done()
|
||||||
|
Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGroup struct{}
|
||||||
|
|
||||||
|
func (w *fakeGroup) Add(int) {}
|
||||||
|
func (w *fakeGroup) Done() {}
|
||||||
|
func (w *fakeGroup) Wait() {}
|
||||||
|
|
||||||
func newExtList(opts ExtBrowseOpts, ui uiRegistry, extEntries []extEntry) *extList {
|
func newExtList(opts ExtBrowseOpts, ui uiRegistry, extEntries []extEntry) *extList {
|
||||||
ui.List.SetTitleColor(tcell.ColorWhite)
|
ui.List.SetTitleColor(tcell.ColorWhite)
|
||||||
ui.List.SetSelectedTextColor(tcell.ColorBlack)
|
ui.List.SetSelectedTextColor(tcell.ColorBlack)
|
||||||
ui.List.SetSelectedBackgroundColor(tcell.ColorWhite)
|
ui.List.SetSelectedBackgroundColor(tcell.ColorWhite)
|
||||||
ui.List.SetWrapAround(false)
|
ui.List.SetWrapAround(false)
|
||||||
ui.List.SetBorderPadding(1, 1, 1, 1)
|
ui.List.SetBorderPadding(1, 1, 1, 1)
|
||||||
|
ui.List.SetSelectedFunc(func(ix int, _, _ string, _ rune) {
|
||||||
|
ui.Pages.SwitchToPage("readme")
|
||||||
|
})
|
||||||
|
|
||||||
el := &extList{
|
el := &extList{
|
||||||
ui: ui,
|
ui: ui,
|
||||||
extEntries: extEntries,
|
extEntries: extEntries,
|
||||||
app: ui.App,
|
app: ui.App,
|
||||||
opts: opts,
|
opts: opts,
|
||||||
|
QueueUpdateDraw: ui.App.QueueUpdateDraw,
|
||||||
|
WaitGroup: &fakeGroup{},
|
||||||
}
|
}
|
||||||
|
|
||||||
el.Reset()
|
el.Reset()
|
||||||
|
|
@ -112,66 +134,97 @@ func (el *extList) createModal() *tview.Modal {
|
||||||
m := tview.NewModal()
|
m := tview.NewModal()
|
||||||
m.SetBackgroundColor(tcell.ColorPurple)
|
m.SetBackgroundColor(tcell.ColorPurple)
|
||||||
m.SetDoneFunc(func(_ int, _ string) {
|
m.SetDoneFunc(func(_ int, _ string) {
|
||||||
el.ui.App.SetRoot(el.ui.Outerflex, true)
|
el.ui.Pages.SwitchToPage("main")
|
||||||
el.Refresh()
|
el.Refresh()
|
||||||
})
|
})
|
||||||
|
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (el *extList) InstallSelected() {
|
func (el *extList) toggleSelected(verb string) {
|
||||||
ee, ix := el.FindSelected()
|
ee, ix := el.FindSelected()
|
||||||
if ix < 0 {
|
if ix < 0 {
|
||||||
el.opts.Logger.Println("failed to find selected entry")
|
el.opts.Logger.Println("failed to find selected entry")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
repo, err := ghrepo.FromFullName(ee.FullName)
|
modal := el.createModal()
|
||||||
if err != nil {
|
|
||||||
el.opts.Logger.Println(fmt.Errorf("failed to install '%s't: %w", ee.FullName, err))
|
if (ee.Installed && verb == "install") || (!ee.Installed && verb == "remove") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
modal := el.createModal()
|
var action func() error
|
||||||
|
|
||||||
modal.SetText(fmt.Sprintf("Installing %s...", ee.FullName))
|
if !ee.Installed {
|
||||||
el.ui.App.SetRoot(modal, true)
|
modal.SetText(fmt.Sprintf("Installing %s...", ee.FullName))
|
||||||
// I could eliminate this with a goroutine but it seems to be working fine
|
action = func() error {
|
||||||
el.app.ForceDraw()
|
repo, err := ghrepo.FromFullName(ee.FullName)
|
||||||
err = el.opts.Em.Install(repo, "")
|
if err != nil {
|
||||||
if err != nil {
|
el.opts.Logger.Println(fmt.Errorf("failed to install '%s': %w", ee.FullName, err))
|
||||||
modal.SetText(fmt.Sprintf("Failed to install %s: %s", ee.FullName, err.Error()))
|
return err
|
||||||
|
}
|
||||||
|
err = el.opts.Em.Install(repo, "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to install %s: %w", ee.FullName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
modal.SetText(fmt.Sprintf("Installed %s!", ee.FullName))
|
modal.SetText(fmt.Sprintf("Removing %s...", ee.FullName))
|
||||||
modal.AddButtons([]string{"ok"})
|
action = func() error {
|
||||||
el.ui.App.SetFocus(modal)
|
name := strings.TrimPrefix(ee.Name, "gh-")
|
||||||
|
err := el.opts.Em.Remove(name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove %s: %w", ee.FullName, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
el.toggleInstalled(ix)
|
el.ui.CmdFlex.Clear()
|
||||||
|
el.ui.CmdFlex.AddItem(modal, 0, 1, true)
|
||||||
|
var err error
|
||||||
|
wg := el.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
el.QueueUpdateDraw(func() {
|
||||||
|
el.ui.Pages.SwitchToPage("command")
|
||||||
|
wg.Add(1)
|
||||||
|
wg.Done()
|
||||||
|
go func() {
|
||||||
|
el.QueueUpdateDraw(func() {
|
||||||
|
err = action()
|
||||||
|
if err != nil {
|
||||||
|
modal.SetText(err.Error())
|
||||||
|
} else {
|
||||||
|
modalText := fmt.Sprintf("Installed %s!", ee.FullName)
|
||||||
|
if verb == "remove" {
|
||||||
|
modalText = fmt.Sprintf("Removed %s!", ee.FullName)
|
||||||
|
}
|
||||||
|
modal.SetText(modalText)
|
||||||
|
modal.AddButtons([]string{"ok"})
|
||||||
|
el.app.SetFocus(modal)
|
||||||
|
}
|
||||||
|
wg.Done()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
|
||||||
|
// TODO blocking the app's thread and deadlocking
|
||||||
|
wg.Wait()
|
||||||
|
if err == nil {
|
||||||
|
el.toggleInstalled(ix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (el *extList) InstallSelected() {
|
||||||
|
el.toggleSelected("install")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (el *extList) RemoveSelected() {
|
func (el *extList) RemoveSelected() {
|
||||||
ee, ix := el.FindSelected()
|
el.toggleSelected("remove")
|
||||||
if ix < 0 {
|
|
||||||
el.opts.Logger.Println("failed to find selected extension")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
modal := el.createModal()
|
|
||||||
|
|
||||||
modal.SetText(fmt.Sprintf("Removing %s...", ee.FullName))
|
|
||||||
el.ui.App.SetRoot(modal, true)
|
|
||||||
// I could eliminate this with a goroutine but it seems to be working fine
|
|
||||||
el.ui.App.ForceDraw()
|
|
||||||
|
|
||||||
err := el.opts.Em.Remove(strings.TrimPrefix(ee.Name, "gh-"))
|
|
||||||
if err != nil {
|
|
||||||
modal.SetText(fmt.Sprintf("Failed to remove %s: %s", ee.FullName, err.Error()))
|
|
||||||
} else {
|
|
||||||
modal.SetText(fmt.Sprintf("Removed %s.", ee.FullName))
|
|
||||||
modal.AddButtons([]string{"ok"})
|
|
||||||
el.ui.App.SetFocus(modal)
|
|
||||||
}
|
|
||||||
el.toggleInstalled(ix)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (el *extList) toggleInstalled(ix int) {
|
func (el *extList) toggleInstalled(ix int) {
|
||||||
|
|
@ -365,14 +418,19 @@ func ExtBrowse(opts ExtBrowseOpts) error {
|
||||||
readme.SetBorder(true).SetBorderColor(tcell.ColorPurple)
|
readme.SetBorder(true).SetBorderColor(tcell.ColorPurple)
|
||||||
|
|
||||||
help := tview.NewTextView()
|
help := tview.NewTextView()
|
||||||
help.SetText(
|
help.SetDynamicColors(true)
|
||||||
"/: filter i/r: install/remove w: open in browser pgup/pgdn: scroll readme q: quit")
|
help.SetText("[::b]?[-:-:-]: help [::b]j/k[-:-:-]: move [::b]i[-:-:-]: install [::b]r[-:-:-]: remove [::b]w[-:-:-]: web [::b]↵[-:-:-]: view readme [::b]q[-:-:-]: quit")
|
||||||
help.SetTextAlign(tview.AlignCenter)
|
|
||||||
|
cmdFlex := tview.NewFlex()
|
||||||
|
|
||||||
|
pages := tview.NewPages()
|
||||||
|
|
||||||
ui := uiRegistry{
|
ui := uiRegistry{
|
||||||
App: app,
|
App: app,
|
||||||
Outerflex: outerFlex,
|
Outerflex: outerFlex,
|
||||||
List: list,
|
List: list,
|
||||||
|
Pages: pages,
|
||||||
|
CmdFlex: cmdFlex,
|
||||||
}
|
}
|
||||||
|
|
||||||
extList := newExtList(opts, ui, extEntries)
|
extList := newExtList(opts, ui, extEntries)
|
||||||
|
|
@ -414,7 +472,9 @@ func ExtBrowse(opts ExtBrowseOpts) error {
|
||||||
|
|
||||||
innerFlex.SetDirection(tview.FlexColumn)
|
innerFlex.SetDirection(tview.FlexColumn)
|
||||||
innerFlex.AddItem(list, 0, 1, true)
|
innerFlex.AddItem(list, 0, 1, true)
|
||||||
innerFlex.AddItem(readme, 0, 1, false)
|
if !opts.SingleColumn {
|
||||||
|
innerFlex.AddItem(readme, 0, 1, false)
|
||||||
|
}
|
||||||
|
|
||||||
outerFlex.SetDirection(tview.FlexRow)
|
outerFlex.SetDirection(tview.FlexRow)
|
||||||
outerFlex.AddItem(header, 1, -1, false)
|
outerFlex.AddItem(header, 1, -1, false)
|
||||||
|
|
@ -422,7 +482,50 @@ func ExtBrowse(opts ExtBrowseOpts) error {
|
||||||
outerFlex.AddItem(innerFlex, 0, 1, true)
|
outerFlex.AddItem(innerFlex, 0, 1, true)
|
||||||
outerFlex.AddItem(help, 1, -1, false)
|
outerFlex.AddItem(help, 1, -1, false)
|
||||||
|
|
||||||
app.SetRoot(outerFlex, true)
|
helpBig := tview.NewTextView()
|
||||||
|
helpBig.SetDynamicColors(true)
|
||||||
|
helpBig.SetBorderPadding(0, 0, 2, 0)
|
||||||
|
helpBig.SetText(heredoc.Doc(`
|
||||||
|
[::b]Application[-:-:-]
|
||||||
|
|
||||||
|
?: toggle help
|
||||||
|
q: quit
|
||||||
|
|
||||||
|
[::b]Navigation[-:-:-]
|
||||||
|
|
||||||
|
↓, j: scroll list of extensions down by 1
|
||||||
|
↑, k: scroll list of extensions up by 1
|
||||||
|
|
||||||
|
shift+j, space: scroll list of extensions down by 25
|
||||||
|
shift+k, ctrl+space (mac), shift+space (windows): scroll list of extensions up by 25
|
||||||
|
|
||||||
|
[::b]Extension Management[-:-:-]
|
||||||
|
|
||||||
|
i: install highlighted extension
|
||||||
|
r: remove highlighted extension
|
||||||
|
w: open highlighted extension in web browser
|
||||||
|
|
||||||
|
[::b]Filtering[-:-:-]
|
||||||
|
|
||||||
|
/: focus filter
|
||||||
|
enter: finish filtering and go back to list
|
||||||
|
escape: clear filter and reset list
|
||||||
|
|
||||||
|
[::b]Readmes[-:-:-]
|
||||||
|
|
||||||
|
enter: open highlighted extension's readme full screen
|
||||||
|
page down: scroll readme pane down
|
||||||
|
page up: scroll readme pane up
|
||||||
|
|
||||||
|
(On a mac, page down and page up are fn+down arrow and fn+up arrow)
|
||||||
|
`))
|
||||||
|
|
||||||
|
pages.AddPage("main", outerFlex, true, true)
|
||||||
|
pages.AddPage("help", helpBig, true, false)
|
||||||
|
pages.AddPage("readme", readme, true, false)
|
||||||
|
pages.AddPage("command", cmdFlex, true, false)
|
||||||
|
|
||||||
|
app.SetRoot(pages, true)
|
||||||
|
|
||||||
// Force fetching of initial readme by loading it just prior to the first
|
// Force fetching of initial readme by loading it just prior to the first
|
||||||
// draw. The callback is removed immediately after draw.
|
// draw. The callback is removed immediately after draw.
|
||||||
|
|
@ -441,7 +544,41 @@ func ExtBrowse(opts ExtBrowseOpts) error {
|
||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
|
curPage, _ := pages.GetFrontPage()
|
||||||
|
|
||||||
|
if curPage != "main" {
|
||||||
|
if curPage == "command" {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
if event.Rune() == 'q' || event.Key() == tcell.KeyEscape {
|
||||||
|
pages.SwitchToPage("main")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch curPage {
|
||||||
|
case "readme":
|
||||||
|
switch event.Key() {
|
||||||
|
case tcell.KeyPgUp:
|
||||||
|
row, col := readme.GetScrollOffset()
|
||||||
|
if row > 0 {
|
||||||
|
readme.ScrollTo(row-2, col)
|
||||||
|
}
|
||||||
|
case tcell.KeyPgDn:
|
||||||
|
row, col := readme.GetScrollOffset()
|
||||||
|
readme.ScrollTo(row+2, col)
|
||||||
|
}
|
||||||
|
case "help":
|
||||||
|
switch event.Rune() {
|
||||||
|
case '?':
|
||||||
|
pages.SwitchToPage("main")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
switch event.Rune() {
|
switch event.Rune() {
|
||||||
|
case '?':
|
||||||
|
pages.SwitchToPage("help")
|
||||||
|
return nil
|
||||||
case 'q':
|
case 'q':
|
||||||
app.Stop()
|
app.Stop()
|
||||||
case 'k':
|
case 'k':
|
||||||
|
|
@ -491,7 +628,7 @@ func ExtBrowse(opts ExtBrowseOpts) error {
|
||||||
filter.SetText("")
|
filter.SetText("")
|
||||||
extList.Reset()
|
extList.Reset()
|
||||||
case tcell.KeyCtrlSpace:
|
case tcell.KeyCtrlSpace:
|
||||||
// The ctrl check works on windows/mac and not windows:
|
// The ctrl check works on linux/mac and not windows:
|
||||||
extList.PageUp()
|
extList.PageUp()
|
||||||
go loadSelectedReadme()
|
go loadSelectedReadme()
|
||||||
case tcell.KeyCtrlJ:
|
case tcell.KeyCtrlJ:
|
||||||
|
|
@ -500,25 +637,11 @@ func ExtBrowse(opts ExtBrowseOpts) error {
|
||||||
case tcell.KeyCtrlK:
|
case tcell.KeyCtrlK:
|
||||||
extList.PageUp()
|
extList.PageUp()
|
||||||
go loadSelectedReadme()
|
go loadSelectedReadme()
|
||||||
case tcell.KeyPgUp:
|
|
||||||
row, col := readme.GetScrollOffset()
|
|
||||||
if row > 0 {
|
|
||||||
readme.ScrollTo(row-2, col)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
case tcell.KeyPgDn:
|
|
||||||
row, col := readme.GetScrollOffset()
|
|
||||||
readme.ScrollTo(row+2, col)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return event
|
return event
|
||||||
})
|
})
|
||||||
|
|
||||||
// Without this redirection, the git client inside of the extension manager
|
|
||||||
// will dump git output to the terminal.
|
|
||||||
opts.IO.ErrOut = io.Discard
|
|
||||||
|
|
||||||
if err := app.Run(); err != nil {
|
if err := app.Run(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -274,11 +275,15 @@ func Test_extList(t *testing.T) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
cmdFlex := tview.NewFlex()
|
||||||
app := tview.NewApplication()
|
app := tview.NewApplication()
|
||||||
list := tview.NewList()
|
list := tview.NewList()
|
||||||
|
pages := tview.NewPages()
|
||||||
ui := uiRegistry{
|
ui := uiRegistry{
|
||||||
List: list,
|
List: list,
|
||||||
App: app,
|
App: app,
|
||||||
|
CmdFlex: cmdFlex,
|
||||||
|
Pages: pages,
|
||||||
}
|
}
|
||||||
extEntries := []extEntry{
|
extEntries := []extEntry{
|
||||||
{
|
{
|
||||||
|
|
@ -313,6 +318,13 @@ func Test_extList(t *testing.T) {
|
||||||
|
|
||||||
extList := newExtList(opts, ui, extEntries)
|
extList := newExtList(opts, ui, extEntries)
|
||||||
|
|
||||||
|
extList.QueueUpdateDraw = func(f func()) *tview.Application {
|
||||||
|
f()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
extList.WaitGroup = &sync.WaitGroup{}
|
||||||
|
|
||||||
extList.Filter("cool")
|
extList.Filter("cool")
|
||||||
assert.Equal(t, 1, extList.ui.List.GetItemCount())
|
assert.Equal(t, 1, extList.ui.List.GetItemCount())
|
||||||
|
|
||||||
|
|
@ -322,6 +334,8 @@ func Test_extList(t *testing.T) {
|
||||||
extList.InstallSelected()
|
extList.InstallSelected()
|
||||||
assert.True(t, extList.extEntries[0].Installed)
|
assert.True(t, extList.extEntries[0].Installed)
|
||||||
|
|
||||||
|
// so I think the goroutines are causing a later failure because the toggleInstalled isn't seen.
|
||||||
|
|
||||||
extList.Refresh()
|
extList.Refresh()
|
||||||
assert.Equal(t, 1, extList.ui.List.GetItemCount())
|
assert.Equal(t, 1, extList.ui.List.GetItemCount())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package extension
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
gio "io"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -24,6 +25,7 @@ import (
|
||||||
func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
||||||
m := f.ExtensionManager
|
m := f.ExtensionManager
|
||||||
io := f.IOStreams
|
io := f.IOStreams
|
||||||
|
gc := f.GitClient
|
||||||
prompter := f.Prompter
|
prompter := f.Prompter
|
||||||
config := f.Config
|
config := f.Config
|
||||||
browser := f.Browser
|
browser := f.Browser
|
||||||
|
|
@ -410,33 +412,25 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
||||||
},
|
},
|
||||||
func() *cobra.Command {
|
func() *cobra.Command {
|
||||||
var debug bool
|
var debug bool
|
||||||
|
var singleColumn bool
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "browse",
|
Use: "browse",
|
||||||
Short: "Enter a UI for browsing, adding, and removing extensions",
|
Short: "Enter a UI for browsing, adding, and removing extensions",
|
||||||
Long: heredoc.Doc(`
|
Long: heredoc.Doc(`
|
||||||
This command will take over your terminal and run a fully interactive
|
This command will take over your terminal and run a fully interactive
|
||||||
interface for browsing, adding, and removing gh extensions.
|
interface for browsing, adding, and removing gh extensions. A terminal
|
||||||
|
width greater than 100 columns is recommended.
|
||||||
|
|
||||||
The extension list is navigated with the arrow keys or with j/k.
|
To learn how to control this interface, press ? after running to see
|
||||||
Space and control+space (or control + j/k) page the list up and down.
|
the help text.
|
||||||
Extension readmes can be scrolled with page up/page down keys
|
|
||||||
(fn + arrow up/down on a mac keyboard).
|
|
||||||
|
|
||||||
For highlighted extensions, you can press:
|
|
||||||
|
|
||||||
- w to open the extension in your web browser
|
|
||||||
- i to install the extension
|
|
||||||
- r to remove the extension
|
|
||||||
|
|
||||||
Press / to focus the filter input. Press enter to scroll the results.
|
|
||||||
Press Escape to clear the filter and return to the full list.
|
|
||||||
|
|
||||||
Press q to quit.
|
Press q to quit.
|
||||||
|
|
||||||
The output of this command may be difficult to navigate for screen reader
|
Running this command with --single-column should make this command
|
||||||
users, users operating at high zoom and other users of assistive technology. It
|
more intelligible for users who rely on assistive technology like screen
|
||||||
is also not advised for automation scripts. We advise those users to use the
|
readers or high zoom.
|
||||||
alternative command:
|
|
||||||
|
For a more traditional way to discover extensions, see:
|
||||||
|
|
||||||
gh ext search
|
gh ext search
|
||||||
|
|
||||||
|
|
@ -459,21 +453,25 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
||||||
|
|
||||||
searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host)
|
searcher := search.NewSearcher(api.NewCachedHTTPClient(client, time.Hour*24), host)
|
||||||
|
|
||||||
|
gc.Stderr = gio.Discard
|
||||||
|
|
||||||
opts := browse.ExtBrowseOpts{
|
opts := browse.ExtBrowseOpts{
|
||||||
Cmd: cmd,
|
Cmd: cmd,
|
||||||
IO: io,
|
IO: io,
|
||||||
Browser: browser,
|
Browser: browser,
|
||||||
Searcher: searcher,
|
Searcher: searcher,
|
||||||
Em: m,
|
Em: m,
|
||||||
Client: client,
|
Client: client,
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
Debug: debug,
|
Debug: debug,
|
||||||
|
SingleColumn: singleColumn,
|
||||||
}
|
}
|
||||||
|
|
||||||
return browse.ExtBrowse(opts)
|
return browse.ExtBrowse(opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
cmd.Flags().BoolVar(&debug, "debug", false, "log to /tmp/extBrowse-*")
|
cmd.Flags().BoolVar(&debug, "debug", false, "log to /tmp/extBrowse-*")
|
||||||
|
cmd.Flags().BoolVarP(&singleColumn, "single-column", "s", false, "Render TUI with only one column of text")
|
||||||
return cmd
|
return cmd
|
||||||
}(),
|
}(),
|
||||||
&cobra.Command{
|
&cobra.Command{
|
||||||
|
|
@ -564,9 +562,18 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
||||||
} else {
|
} else {
|
||||||
fullName = "gh-" + extName
|
fullName = "gh-" + extName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cs := io.ColorScheme()
|
||||||
|
|
||||||
|
commitIcon := cs.SuccessIcon()
|
||||||
if err := m.Create(fullName, tmplType); err != nil {
|
if err := m.Create(fullName, tmplType); err != nil {
|
||||||
return err
|
if errors.Is(err, ErrInitialCommitFailed) {
|
||||||
|
commitIcon = cs.FailureIcon()
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !io.IsStdoutTTY() {
|
if !io.IsStdoutTTY() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -577,7 +584,6 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
||||||
"- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action",
|
"- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action",
|
||||||
fullName, extName)
|
fullName, extName)
|
||||||
|
|
||||||
cs := io.ColorScheme()
|
|
||||||
if tmplType == extensions.GoBinTemplateType {
|
if tmplType == extensions.GoBinTemplateType {
|
||||||
goBinChecks = heredoc.Docf(`
|
goBinChecks = heredoc.Docf(`
|
||||||
%[1]s Downloaded Go dependencies
|
%[1]s Downloaded Go dependencies
|
||||||
|
|
@ -585,7 +591,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
||||||
`, cs.SuccessIcon(), fullName)
|
`, cs.SuccessIcon(), fullName)
|
||||||
steps = heredoc.Docf(`
|
steps = heredoc.Docf(`
|
||||||
- run 'cd %[1]s; gh extension install .; gh %[2]s' to see your new extension in action
|
|||||||