Merge pull request #10915 from cli/wm/projectsv1-deprecation-pr-create-rest

Feature detect v1 projects on interactive `pr create`
This commit is contained in:
William Martin 2025-05-06 15:24:04 +02:00 committed by GitHub
commit a2fcb9b2df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 353 additions and 17 deletions

View file

@ -141,6 +141,18 @@ func IndexFor(options []string, answer string) (int, error) {
return -1, NoSuchAnswerErr(answer, options)
}
func IndexesFor(options []string, answers ...string) ([]int, error) {
indexes := make([]int, len(answers))
for i, answer := range answers {
index, err := IndexFor(options, answer)
if err != nil {
return nil, err
}
indexes[i] = index
}
return indexes, nil
}
func NoSuchPromptErr(prompt string) error {
return fmt.Errorf("no such prompt '%s'", prompt)
}

View file

@ -456,7 +456,6 @@ func createRun(opts *CreateOptions) error {
if err != nil {
return err
}
// TODO wm: revisit project support
return submitPR(*opts, *ctx, *state, projectsV1Support)
}
@ -534,7 +533,7 @@ func createRun(opts *CreateOptions) error {
}
}
openURL, err = generateCompareURL(*ctx, *state, gh.ProjectsV1Supported)
openURL, err = generateCompareURL(*ctx, *state, projectsV1Support)
if err != nil {
return err
}
@ -553,8 +552,7 @@ func createRun(opts *CreateOptions) error {
Repo: ctx.PRRefs.BaseRepo(),
State: state,
}
// TODO wm: revisit project support
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, gh.ProjectsV1Supported)
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support)
if err != nil {
return err
}
@ -583,12 +581,10 @@ func createRun(opts *CreateOptions) error {
if action == shared.SubmitDraftAction {
state.Draft = true
// TODO wm: revisit project support
return submitPR(*opts, *ctx, *state, projectsV1Support)
}
if action == shared.SubmitAction {
// TODO wm: revisit project support
return submitPR(*opts, *ctx, *state, projectsV1Support)
}
@ -1237,7 +1233,6 @@ func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState, proj
ctx.PRRefs.BaseRepo(),
"compare/%s...%s?expand=1",
url.PathEscape(ctx.PRRefs.BaseRef()), url.PathEscape(ctx.PRRefs.QualifiedHeadRef()))
// TODO wm: revisit project support
url, err := shared.WithPrAndIssueQueryParams(ctx.Client, ctx.PRRefs.BaseRepo(), u, state, projectsV1Support)
if err != nil {
return "", err

View file

@ -1852,11 +1852,13 @@ func mustParseQualifiedHeadRef(ref string) shared.QualifiedHeadRef {
func Test_generateCompareURL(t *testing.T) {
tests := []struct {
name string
ctx CreateContext
state shared.IssueMetadataState
want string
wantErr bool
name string
ctx CreateContext
state shared.IssueMetadataState
httpStubs func(*testing.T, *httpmock.Registry)
projectsV1Support gh.ProjectsV1Support
want string
wantErr bool
}{
{
name: "basic",
@ -1940,11 +1942,135 @@ func Test_generateCompareURL(t *testing.T) {
want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&template=story.md",
wantErr: false,
},
// TODO projectsV1Deprecation
// Clean up these tests, but probably keep one for general project ID resolution.
{
name: "with projects, no v1 support",
ctx: CreateContext{
PRRefs: &skipPushRefs{
qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"),
baseRefs: baseRefs{
baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
baseBranchName: "main",
},
},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
// Ensure no v1 projects are requestd
// ( is required to avoid matching projectsV2
reg.Exclude(t, httpmock.GraphQL(`projects\(`))
reg.Register(
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projectsV2": {
"nodes": [
{ "title": "ProjectTitle", "id": "PROJECTV2ID", "resourcePath": "/OWNER/REPO/projects/3" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query UserProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "viewer": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
},
state: shared.IssueMetadataState{
ProjectTitles: []string{"ProjectTitle"},
},
projectsV1Support: gh.ProjectsV1Unsupported,
want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F3",
wantErr: false,
},
{
name: "with projects, v1 support",
ctx: CreateContext{
PRRefs: &skipPushRefs{
qualifiedHeadRef: shared.NewQualifiedHeadRefWithoutOwner("feature"),
baseRefs: baseRefs{
baseRepo: api.InitRepoHostname(&api.Repository{Name: "REPO", Owner: api.RepositoryOwner{Login: "OWNER"}}, "github.com"),
baseBranchName: "main",
},
},
},
state: shared.IssueMetadataState{
ProjectTitles: []string{"ProjectV1Title"},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
// v1 project query responses
reg.Register(
httpmock.GraphQL(`query RepositoryProjectList\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "ProjectV1Title", "id": "PROJECTV1ID", "resourcePath": "/OWNER/REPO/projects/1" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationProjectList\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
// v2 project query responses
reg.Register(
httpmock.GraphQL(`query RepositoryProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query OrganizationProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "organization": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
reg.Register(
httpmock.GraphQL(`query UserProjectV2List\b`),
httpmock.StringResponse(`
{ "data": { "viewer": { "projectsV2": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
},
projectsV1Support: gh.ProjectsV1Supported,
want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1&projects=OWNER%2FREPO%2F1",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// TODO wm: projects v1 support?
got, err := generateCompareURL(tt.ctx, tt.state, gh.ProjectsV1Supported)
// If http stubs are provided, register them and inject the registry into a client
// that is provided to generateCompareURL in the ctx.
if tt.httpStubs != nil {
reg := &httpmock.Registry{}
defer reg.Verify(t)
tt.httpStubs(t, reg)
tt.ctx.Client = api.NewClientFromHTTP(&http.Client{Transport: reg})
}
got, err := generateCompareURL(tt.ctx, tt.state, tt.projectsV1Support)
if (err != nil) != tt.wantErr {
t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr)
return
@ -2011,8 +2137,6 @@ func mockRetrieveProjects(_ *testing.T, reg *httpmock.Registry) {
`))
}
// TODO interactive metadata tests once: 1) we have test utils for Prompter and 2) metadata questions use Prompter
// TODO projectsV1Deprecation
// Remove this test.
func TestProjectsV1Deprecation(t *testing.T) {
@ -2128,6 +2252,211 @@ func TestProjectsV1Deprecation(t *testing.T) {
})
})
t.Run("interactive submission", func(t *testing.T) {
t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "")
cs.Register(`git rev-parse --show-toplevel`, 0, "")
// When the command is run
reg := &httpmock.Registry{}
reg.StubRepoResponse("OWNER", "REPO")
reg.Register(
httpmock.GraphQL(`query PullRequestTemplates\b`),
httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`),
)
reg.Register(
// ( is required to avoid matching projectsV2
httpmock.GraphQL(`projects\(`),
// Simulate a GraphQL error to early exit the test.
httpmock.StatusStringResponse(500, ""),
)
// Register a handler to check for projects V2 just to avoid the registry panicking, even
// though we return a 500 error. This is because the project lookup is done in parallel
// so the previous error doesn't early exit.
reg.Register(
httpmock.GraphQL(`projectsV2`),
// Simulate a GraphQL error to early exit the test.
httpmock.StatusStringResponse(500, ""),
)
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(true)
ios.SetStdoutTTY(true)
ios.SetStderrTTY(true)
pm := &prompter.PrompterMock{}
pm.InputFunc = func(p, _ string) (string, error) {
if p == "Title (required)" {
return "Test Title", nil
} else {
return "", prompter.NoSuchPromptErr(p)
}
}
pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) {
if p == "Body" {
return "Test Body", nil
} else {
return "", prompter.NoSuchPromptErr(p)
}
}
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
switch p {
case "Choose a template":
return 0, nil
case "What's next?":
return prompter.IndexFor(opts, "Add metadata")
default:
return -1, prompter.NoSuchPromptErr(p)
}
}
pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) {
return prompter.IndexesFor(opts, "Projects")
}
opts := CreateOptions{
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
Browser: &browser.Stub{},
IO: ios,
Prompter: pm,
GitClient: &git.Client{
GhPath: "some/path/gh",
GitPath: "some/path/git",
},
Finder: shared.NewMockFinder("feature", nil, nil),
Detector: &fd.EnabledDetectorMock{},
Remotes: func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
},
Branch: func() (string, error) {
return "feature", nil
},
HeadBranch: "feature",
}
// Ignore the error because we have no way to really stub it without
// fully stubbing a GQL error structure in the request body.
_ = createRun(&opts)
// Verify that our request contained projects
reg.Verify(t)
})
t.Run("when projects v1 is not supported, does not query for it", func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp \^branch\\\..+\\\.\(remote\|merge\|pushremote\|gh-merge-base\)\$`, 0, "")
cs.Register("git -c log.ShowSignature=false log --pretty=format:%H%x00%s%x00%b%x00 --cherry origin/master...feature", 0, "")
cs.Register(`git rev-parse --show-toplevel`, 0, "")
// When the command is run
reg := &httpmock.Registry{}
reg.StubRepoResponse("OWNER", "REPO")
reg.Register(
httpmock.GraphQL(`query PullRequestTemplates\b`),
httpmock.StringResponse(`{ "data": { "repository": { "pullRequestTemplates": [] } } }`),
)
// ( is required to avoid matching projectsV2
reg.Exclude(t, httpmock.GraphQL(`projects\(`))
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(true)
ios.SetStdoutTTY(true)
ios.SetStderrTTY(true)
pm := &prompter.PrompterMock{}
pm.InputFunc = func(p, _ string) (string, error) {
if p == "Title (required)" {
return "Test Title", nil
} else {
return "", prompter.NoSuchPromptErr(p)
}
}
pm.MarkdownEditorFunc = func(p, _ string, ba bool) (string, error) {
if p == "Body" {
return "Test Body", nil
} else {
return "", prompter.NoSuchPromptErr(p)
}
}
pm.SelectFunc = func(p, _ string, opts []string) (int, error) {
switch p {
case "Choose a template":
return 0, nil
case "What's next?":
return prompter.IndexFor(opts, "Add metadata")
default:
return -1, prompter.NoSuchPromptErr(p)
}
}
pm.MultiSelectFunc = func(p string, _ []string, opts []string) ([]int, error) {
return prompter.IndexesFor(opts, "Projects")
}
opts := CreateOptions{
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (gh.Config, error) {
return config.NewBlankConfig(), nil
},
Browser: &browser.Stub{},
IO: ios,
Prompter: pm,
GitClient: &git.Client{
GhPath: "some/path/gh",
GitPath: "some/path/git",
},
Finder: shared.NewMockFinder("feature", nil, nil),
Detector: &fd.DisabledDetectorMock{},
Remotes: func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{
Name: "origin",
},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
},
Branch: func() (string, error) {
return "feature", nil
},
HeadBranch: "feature",
}
// Ignore the error because we have no way to really stub it without
// fully stubbing a GQL error structure in the request body.
_ = createRun(&opts)
// Verify that our request did not contain projectCards
reg.Verify(t)
})
})
t.Run("web mode", func(t *testing.T) {
t.Run("when projects v1 is supported, queries for it", func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
@ -2238,7 +2567,7 @@ func TestProjectsV1Deprecation(t *testing.T) {
Projects: []string{"Project"},
})
// Verify that our request contained projectCards
// Verify that our request did not contain projectCards
reg.Verify(t)
})
})