Merge branch 'trunk' into 8426-add-pr-update-cmd-no-local-update
This commit is contained in:
commit
c70479ac5d
30 changed files with 883 additions and 292 deletions
2
.github/workflows/deployment.yml
vendored
2
.github/workflows/deployment.yml
vendored
|
|
@ -299,7 +299,7 @@ jobs:
|
||||||
rpmsign --addsign dist/*.rpm
|
rpmsign --addsign dist/*.rpm
|
||||||
- name: Attest release artifacts
|
- name: Attest release artifacts
|
||||||
if: inputs.environment == 'production'
|
if: inputs.environment == 'production'
|
||||||
uses: actions/attest-build-provenance@bdd51370e0416ac948727f861e03c2f05d32d78e # v1.3.2
|
uses: actions/attest-build-provenance@5e9cb68e95676991667494a6a4e59b8a2f13e1d0 # v1.3.3
|
||||||
with:
|
with:
|
||||||
subject-path: "dist/gh_*"
|
subject-path: "dist/gh_*"
|
||||||
- name: Run createrepo
|
- name: Run createrepo
|
||||||
|
|
|
||||||
|
|
@ -249,7 +249,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string, includeEvent bool) str
|
||||||
}`), afterClause, prID, eventField)
|
}`), afterClause, prID, eventField)
|
||||||
}
|
}
|
||||||
|
|
||||||
var IssueFields = []string{
|
var sharedIssuePRFields = []string{
|
||||||
"assignees",
|
"assignees",
|
||||||
"author",
|
"author",
|
||||||
"body",
|
"body",
|
||||||
|
|
@ -268,10 +268,20 @@ var IssueFields = []string{
|
||||||
"title",
|
"title",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"url",
|
"url",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some fields are only valid in the context of issues.
|
||||||
|
// They need to be enumerated separately in order to be filtered
|
||||||
|
// from existing code that expects to be able to pass Issue fields
|
||||||
|
// to PR queries, e.g. the PullRequestGraphql function.
|
||||||
|
var issueOnlyFields = []string{
|
||||||
|
"isPinned",
|
||||||
"stateReason",
|
"stateReason",
|
||||||
}
|
}
|
||||||
|
|
||||||
var PullRequestFields = append(IssueFields,
|
var IssueFields = append(sharedIssuePRFields, issueOnlyFields...)
|
||||||
|
|
||||||
|
var PullRequestFields = append(sharedIssuePRFields,
|
||||||
"additions",
|
"additions",
|
||||||
"autoMergeRequest",
|
"autoMergeRequest",
|
||||||
"baseRefName",
|
"baseRefName",
|
||||||
|
|
@ -299,12 +309,6 @@ var PullRequestFields = append(IssueFields,
|
||||||
"statusCheckRollup",
|
"statusCheckRollup",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Some fields are only valid in the context of issues.
|
|
||||||
var issueOnlyFields = []string{
|
|
||||||
"isPinned",
|
|
||||||
"stateReason",
|
|
||||||
}
|
|
||||||
|
|
||||||
// IssueGraphQL constructs a GraphQL query fragment for a set of issue fields.
|
// IssueGraphQL constructs a GraphQL query fragment for a set of issue fields.
|
||||||
func IssueGraphQL(fields []string) string {
|
func IssueGraphQL(fields []string) string {
|
||||||
var q []string
|
var q []string
|
||||||
|
|
|
||||||
|
|
@ -15,18 +15,19 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
aliasesKey = "aliases"
|
aliasesKey = "aliases"
|
||||||
browserKey = "browser"
|
browserKey = "browser"
|
||||||
editorKey = "editor"
|
editorKey = "editor"
|
||||||
gitProtocolKey = "git_protocol"
|
gitProtocolKey = "git_protocol"
|
||||||
hostsKey = "hosts"
|
hostsKey = "hosts"
|
||||||
httpUnixSocketKey = "http_unix_socket"
|
httpUnixSocketKey = "http_unix_socket"
|
||||||
oauthTokenKey = "oauth_token"
|
oauthTokenKey = "oauth_token"
|
||||||
pagerKey = "pager"
|
pagerKey = "pager"
|
||||||
promptKey = "prompt"
|
promptKey = "prompt"
|
||||||
userKey = "user"
|
preferEditorPromptKey = "prefer_editor_prompt"
|
||||||
usersKey = "users"
|
userKey = "user"
|
||||||
versionKey = "version"
|
usersKey = "users"
|
||||||
|
versionKey = "version"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewConfig() (gh.Config, error) {
|
func NewConfig() (gh.Config, error) {
|
||||||
|
|
@ -137,6 +138,11 @@ func (c *cfg) Prompt(hostname string) gh.ConfigEntry {
|
||||||
return c.GetOrDefault(hostname, promptKey).Unwrap()
|
return c.GetOrDefault(hostname, promptKey).Unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *cfg) PreferEditorPrompt(hostname string) gh.ConfigEntry {
|
||||||
|
// Intentionally panic if there is no user provided value or default value (which would be a programmer error)
|
||||||
|
return c.GetOrDefault(hostname, preferEditorPromptKey).Unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
func (c *cfg) Version() o.Option[string] {
|
func (c *cfg) Version() o.Option[string] {
|
||||||
return c.get("", versionKey)
|
return c.get("", versionKey)
|
||||||
}
|
}
|
||||||
|
|
@ -509,6 +515,8 @@ git_protocol: https
|
||||||
editor:
|
editor:
|
||||||
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||||
prompt: enabled
|
prompt: enabled
|
||||||
|
# Preference for editor-based interactive prompting. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||||
|
prefer_editor_prompt: disabled
|
||||||
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
|
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
|
||||||
pager:
|
pager:
|
||||||
# Aliases allow you to create nicknames for gh commands
|
# Aliases allow you to create nicknames for gh commands
|
||||||
|
|
@ -555,6 +563,15 @@ var Options = []ConfigOption{
|
||||||
return c.Prompt(hostname).Value
|
return c.Prompt(hostname).Value
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Key: preferEditorPromptKey,
|
||||||
|
Description: "toggle preference for editor-based interactive prompting in the terminal",
|
||||||
|
DefaultValue: "disabled",
|
||||||
|
AllowedValues: []string{"enabled", "disabled"},
|
||||||
|
CurrentValue: func(c gh.Config, hostname string) string {
|
||||||
|
return c.PreferEditorPrompt(hostname).Value
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Key: pagerKey,
|
Key: pagerKey,
|
||||||
Description: "the terminal pager program to send standard output to",
|
Description: "the terminal pager program to send standard output to",
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,9 @@ func NewFromString(cfgStr string) *ghmock.ConfigMock {
|
||||||
mock.PromptFunc = func(hostname string) gh.ConfigEntry {
|
mock.PromptFunc = func(hostname string) gh.ConfigEntry {
|
||||||
return cfg.Prompt(hostname)
|
return cfg.Prompt(hostname)
|
||||||
}
|
}
|
||||||
|
mock.PreferEditorPromptFunc = func(hostname string) gh.ConfigEntry {
|
||||||
|
return cfg.PreferEditorPrompt(hostname)
|
||||||
|
}
|
||||||
mock.VersionFunc = func() o.Option[string] {
|
mock.VersionFunc = func() o.Option[string] {
|
||||||
return cfg.Version()
|
return cfg.Version()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ type Config interface {
|
||||||
Pager(hostname string) ConfigEntry
|
Pager(hostname string) ConfigEntry
|
||||||
// Prompt returns the configured prompt, optionally scoped by host.
|
// Prompt returns the configured prompt, optionally scoped by host.
|
||||||
Prompt(hostname string) ConfigEntry
|
Prompt(hostname string) ConfigEntry
|
||||||
|
// PreferEditorPrompt returns the configured editor-based prompt, optionally scoped by host.
|
||||||
|
PreferEditorPrompt(hostname string) ConfigEntry
|
||||||
|
|
||||||
// Aliases provides persistent storage and modification of command aliases.
|
// Aliases provides persistent storage and modification of command aliases.
|
||||||
Aliases() AliasConfig
|
Aliases() AliasConfig
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ var _ gh.Config = &ConfigMock{}
|
||||||
// PagerFunc: func(hostname string) gh.ConfigEntry {
|
// PagerFunc: func(hostname string) gh.ConfigEntry {
|
||||||
// panic("mock out the Pager method")
|
// panic("mock out the Pager method")
|
||||||
// },
|
// },
|
||||||
|
// PreferEditorPromptFunc: func(hostname string) gh.ConfigEntry {
|
||||||
|
// panic("mock out the PreferEditorPrompt method")
|
||||||
|
// },
|
||||||
// PromptFunc: func(hostname string) gh.ConfigEntry {
|
// PromptFunc: func(hostname string) gh.ConfigEntry {
|
||||||
// panic("mock out the Prompt method")
|
// panic("mock out the Prompt method")
|
||||||
// },
|
// },
|
||||||
|
|
@ -98,6 +101,9 @@ type ConfigMock struct {
|
||||||
// PagerFunc mocks the Pager method.
|
// PagerFunc mocks the Pager method.
|
||||||
PagerFunc func(hostname string) gh.ConfigEntry
|
PagerFunc func(hostname string) gh.ConfigEntry
|
||||||
|
|
||||||
|
// PreferEditorPromptFunc mocks the PreferEditorPrompt method.
|
||||||
|
PreferEditorPromptFunc func(hostname string) gh.ConfigEntry
|
||||||
|
|
||||||
// PromptFunc mocks the Prompt method.
|
// PromptFunc mocks the Prompt method.
|
||||||
PromptFunc func(hostname string) gh.ConfigEntry
|
PromptFunc func(hostname string) gh.ConfigEntry
|
||||||
|
|
||||||
|
|
@ -158,6 +164,11 @@ type ConfigMock struct {
|
||||||
// Hostname is the hostname argument value.
|
// Hostname is the hostname argument value.
|
||||||
Hostname string
|
Hostname string
|
||||||
}
|
}
|
||||||
|
// PreferEditorPrompt holds details about calls to the PreferEditorPrompt method.
|
||||||
|
PreferEditorPrompt []struct {
|
||||||
|
// Hostname is the hostname argument value.
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
// Prompt holds details about calls to the Prompt method.
|
// Prompt holds details about calls to the Prompt method.
|
||||||
Prompt []struct {
|
Prompt []struct {
|
||||||
// Hostname is the hostname argument value.
|
// Hostname is the hostname argument value.
|
||||||
|
|
@ -179,20 +190,21 @@ type ConfigMock struct {
|
||||||
Write []struct {
|
Write []struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lockAliases sync.RWMutex
|
lockAliases sync.RWMutex
|
||||||
lockAuthentication sync.RWMutex
|
lockAuthentication sync.RWMutex
|
||||||
lockBrowser sync.RWMutex
|
lockBrowser sync.RWMutex
|
||||||
lockCacheDir sync.RWMutex
|
lockCacheDir sync.RWMutex
|
||||||
lockEditor sync.RWMutex
|
lockEditor sync.RWMutex
|
||||||
lockGetOrDefault sync.RWMutex
|
lockGetOrDefault sync.RWMutex
|
||||||
lockGitProtocol sync.RWMutex
|
lockGitProtocol sync.RWMutex
|
||||||
lockHTTPUnixSocket sync.RWMutex
|
lockHTTPUnixSocket sync.RWMutex
|
||||||
lockMigrate sync.RWMutex
|
lockMigrate sync.RWMutex
|
||||||
lockPager sync.RWMutex
|
lockPager sync.RWMutex
|
||||||
lockPrompt sync.RWMutex
|
lockPreferEditorPrompt sync.RWMutex
|
||||||
lockSet sync.RWMutex
|
lockPrompt sync.RWMutex
|
||||||
lockVersion sync.RWMutex
|
lockSet sync.RWMutex
|
||||||
lockWrite sync.RWMutex
|
lockVersion sync.RWMutex
|
||||||
|
lockWrite sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aliases calls AliasesFunc.
|
// Aliases calls AliasesFunc.
|
||||||
|
|
@ -504,6 +516,38 @@ func (mock *ConfigMock) PagerCalls() []struct {
|
||||||
return calls
|
return calls
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PreferEditorPrompt calls PreferEditorPromptFunc.
|
||||||
|
func (mock *ConfigMock) PreferEditorPrompt(hostname string) gh.ConfigEntry {
|
||||||
|
if mock.PreferEditorPromptFunc == nil {
|
||||||
|
panic("ConfigMock.PreferEditorPromptFunc: method is nil but Config.PreferEditorPrompt was just called")
|
||||||
|
}
|
||||||
|
callInfo := struct {
|
||||||
|
Hostname string
|
||||||
|
}{
|
||||||
|
Hostname: hostname,
|
||||||
|
}
|
||||||
|
mock.lockPreferEditorPrompt.Lock()
|
||||||
|
mock.calls.PreferEditorPrompt = append(mock.calls.PreferEditorPrompt, callInfo)
|
||||||
|
mock.lockPreferEditorPrompt.Unlock()
|
||||||
|
return mock.PreferEditorPromptFunc(hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreferEditorPromptCalls gets all the calls that were made to PreferEditorPrompt.
|
||||||
|
// Check the length with:
|
||||||
|
//
|
||||||
|
// len(mockedConfig.PreferEditorPromptCalls())
|
||||||
|
func (mock *ConfigMock) PreferEditorPromptCalls() []struct {
|
||||||
|
Hostname string
|
||||||
|
} {
|
||||||
|
var calls []struct {
|
||||||
|
Hostname string
|
||||||
|
}
|
||||||
|
mock.lockPreferEditorPrompt.RLock()
|
||||||
|
calls = mock.calls.PreferEditorPrompt
|
||||||
|
mock.lockPreferEditorPrompt.RUnlock()
|
||||||
|
return calls
|
||||||
|
}
|
||||||
|
|
||||||
// Prompt calls PromptFunc.
|
// Prompt calls PromptFunc.
|
||||||
func (mock *ConfigMock) Prompt(hostname string) gh.ConfigEntry {
|
func (mock *ConfigMock) Prompt(hostname string) gh.ConfigEntry {
|
||||||
if mock.PromptFunc == nil {
|
if mock.PromptFunc == nil {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"github.com/MakeNowJust/heredoc"
|
"github.com/MakeNowJust/heredoc"
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/download"
|
"github.com/cli/cli/v2/pkg/cmd/attestation/download"
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/inspect"
|
"github.com/cli/cli/v2/pkg/cmd/attestation/inspect"
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/tufrootverify"
|
"github.com/cli/cli/v2/pkg/cmd/attestation/trustedroot"
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/verify"
|
"github.com/cli/cli/v2/pkg/cmd/attestation/verify"
|
||||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
|
|
||||||
|
|
@ -24,7 +24,7 @@ func NewCmdAttestation(f *cmdutil.Factory) *cobra.Command {
|
||||||
root.AddCommand(download.NewDownloadCmd(f, nil))
|
root.AddCommand(download.NewDownloadCmd(f, nil))
|
||||||
root.AddCommand(inspect.NewInspectCmd(f, nil))
|
root.AddCommand(inspect.NewInspectCmd(f, nil))
|
||||||
root.AddCommand(verify.NewVerifyCmd(f, nil))
|
root.AddCommand(verify.NewVerifyCmd(f, nil))
|
||||||
root.AddCommand(tufrootverify.NewTUFRootVerifyCmd(f, nil))
|
root.AddCommand(trustedroot.NewTrustedRootCmd(f, nil))
|
||||||
|
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +1 @@
|
||||||
{
|
{"mediaType":"application/vnd.dev.sigstore.trustedroot+json;version=0.1","tlogs":[{"baseUrl":"https://rekor.sigstore.dev","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-01-12T11:53:27.000Z"}},"logId":{"keyId":"wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="}}],"certificateAuthorities":[{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="}]},"validFor":{"start":"2021-03-07T03:20:29.000Z","end":"2022-12-31T23:59:59.999Z"}},{"subject":{"organization":"sigstore.dev","commonName":"sigstore"},"uri":"https://fulcio.sigstore.dev","certChain":{"certificates":[{"rawBytes":"MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="},{"rawBytes":"MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"}]},"validFor":{"start":"2022-04-13T20:06:15.000Z"}}],"ctlogs":[{"baseUrl":"https://ctfe.sigstore.dev/test","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2021-03-14T00:00:00.000Z","end":"2022-10-31T23:59:59.999Z"}},"logId":{"keyId":"CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I="}},{"baseUrl":"https://ctfe.sigstore.dev/2022","hashAlgorithm":"SHA2_256","publicKey":{"rawBytes":"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==","keyDetails":"PKIX_ECDSA_P256_SHA_256","validFor":{"start":"2022-10-20T00:00:00.000Z"}},"logId":{"keyId":"3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4="}}]}
|
||||||
"mediaType": "application/vnd.dev.sigstore.trustedroot+json;version=0.1",
|
|
||||||
"tlogs": [
|
|
||||||
{
|
|
||||||
"baseUrl": "https://rekor.sigstore.dev",
|
|
||||||
"hashAlgorithm": "SHA2_256",
|
|
||||||
"publicKey": {
|
|
||||||
"rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE2G2Y+2tabdTV5BcGiBIx0a9fAFwrkBbmLSGtks4L3qX6yYY0zufBnhC8Ur/iy55GhWP/9A/bY2LhC30M9+RYtw==",
|
|
||||||
"keyDetails": "PKIX_ECDSA_P256_SHA_256",
|
|
||||||
"validFor": {
|
|
||||||
"start": "2021-01-12T11:53:27.000Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"logId": {
|
|
||||||
"keyId": "wNI9atQGlz+VWfO6LRygH4QUfY/8W4RFwiT5i5WRgB0="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"certificateAuthorities": [
|
|
||||||
{
|
|
||||||
"subject": {
|
|
||||||
"organization": "sigstore.dev",
|
|
||||||
"commonName": "sigstore"
|
|
||||||
},
|
|
||||||
"uri": "https://fulcio.sigstore.dev",
|
|
||||||
"certChain": {
|
|
||||||
"certificates": [
|
|
||||||
{
|
|
||||||
"rawBytes": "MIIB+DCCAX6gAwIBAgITNVkDZoCiofPDsy7dfm6geLbuhzAKBggqhkjOPQQDAzAqMRUwEwYDVQQKEwxzaWdzdG9yZS5kZXYxETAPBgNVBAMTCHNpZ3N0b3JlMB4XDTIxMDMwNzAzMjAyOVoXDTMxMDIyMzAzMjAyOVowKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTB2MBAGByqGSM49AgEGBSuBBAAiA2IABLSyA7Ii5k+pNO8ZEWY0ylemWDowOkNa3kL+GZE5Z5GWehL9/A9bRNA3RbrsZ5i0JcastaRL7Sp5fp/jD5dxqc/UdTVnlvS16an+2Yfswe/QuLolRUCrcOE2+2iA5+tzd6NmMGQwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQEwHQYDVR0OBBYEFMjFHQBBmiQpMlEk6w2uSu1KBtPsMB8GA1UdIwQYMBaAFMjFHQBBmiQpMlEk6w2uSu1KBtPsMAoGCCqGSM49BAMDA2gAMGUCMH8liWJfMui6vXXBhjDgY4MwslmN/TJxVe/83WrFomwmNf056y1X48F9c4m3a3ozXAIxAKjRay5/aj/jsKKGIkmQatjI8uupHr/+CxFvaJWmpYqNkLDGRU+9orzh5hI2RrcuaQ=="
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"validFor": {
|
|
||||||
"start": "2021-03-07T03:20:29.000Z",
|
|
||||||
"end": "2022-12-31T23:59:59.999Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"subject": {
|
|
||||||
"organization": "sigstore.dev",
|
|
||||||
"commonName": "sigstore"
|
|
||||||
},
|
|
||||||
"uri": "https://fulcio.sigstore.dev",
|
|
||||||
"certChain": {
|
|
||||||
"certificates": [
|
|
||||||
{
|
|
||||||
"rawBytes": "MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV77LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYBBQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjpKFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZIzj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJRnZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsPmygUY7Ii2zbdCdliiow="
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"rawBytes": "MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMwKjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0yMTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3JlLmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxexX69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92jYzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRYwB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQKsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCMWP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"validFor": {
|
|
||||||
"start": "2022-04-13T20:06:15.000Z"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"ctlogs": [
|
|
||||||
{
|
|
||||||
"baseUrl": "https://ctfe.sigstore.dev/test",
|
|
||||||
"hashAlgorithm": "SHA2_256",
|
|
||||||
"publicKey": {
|
|
||||||
"rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbfwR+RJudXscgRBRpKX1XFDy3PyudDxz/SfnRi1fT8ekpfBd2O1uoz7jr3Z8nKzxA69EUQ+eFCFI3zeubPWU7w==",
|
|
||||||
"keyDetails": "PKIX_ECDSA_P256_SHA_256",
|
|
||||||
"validFor": {
|
|
||||||
"start": "2021-03-14T00:00:00.000Z",
|
|
||||||
"end": "2022-10-31T23:59:59.999Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"logId": {
|
|
||||||
"keyId": "CGCS8ChS/2hF0dFrJ4ScRWcYrBY9wzjSbea8IgY2b3I="
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"baseUrl": "https://ctfe.sigstore.dev/2022",
|
|
||||||
"hashAlgorithm": "SHA2_256",
|
|
||||||
"publicKey": {
|
|
||||||
"rawBytes": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEiPSlFi0CmFTfEjCUqF9HuCEcYXNKAaYalIJmBZ8yyezPjTqhxrKBpMnaocVtLJBI1eM3uXnQzQGAJdJ4gs9Fyw==",
|
|
||||||
"keyDetails": "PKIX_ECDSA_P256_SHA_256",
|
|
||||||
"validFor": {
|
|
||||||
"start": "2022-10-20T00:00:00.000Z"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"logId": {
|
|
||||||
"keyId": "3T0wasbHETJjGR4cmWc3AqJKXrjePK3/h4pygC8p7o4="
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
|
||||||
131
pkg/cmd/attestation/trustedroot/trustedroot.go
Normal file
131
pkg/cmd/attestation/trustedroot/trustedroot.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package trustedroot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/cli/cli/v2/pkg/cmd/attestation/auth"
|
||||||
|
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
|
||||||
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
|
|
||||||
|
"github.com/MakeNowJust/heredoc"
|
||||||
|
"github.com/sigstore/sigstore-go/pkg/tuf"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Options struct {
|
||||||
|
TufUrl string
|
||||||
|
TufRootPath string
|
||||||
|
VerifyOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type tufClientInstantiator func(o *tuf.Options) (*tuf.Client, error)
|
||||||
|
|
||||||
|
func NewTrustedRootCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command {
|
||||||
|
opts := &Options{}
|
||||||
|
trustedRootCmd := cobra.Command{
|
||||||
|
Use: "trusted-root [--tuf-url <url> --tuf-root <file-path>] [--verify-only]",
|
||||||
|
Args: cobra.ExactArgs(0),
|
||||||
|
Short: "Output trusted_root.jsonl contents, likely for offline verification",
|
||||||
|
Long: heredoc.Docf(`
|
||||||
|
### NOTE: This feature is currently in beta, and subject to change.
|
||||||
|
|
||||||
|
Output contents for a trusted_root.jsonl file, likely for offline verification.
|
||||||
|
|
||||||
|
When using %[1]sgh attestation verify%[1]s, if your machine is on the internet,
|
||||||
|
this will happen automatically. But to do offline verification, you need to
|
||||||
|
supply a trusted root file with %[1]s--custom-trusted-root%[1]s; this command
|
||||||
|
will help you fetch a %[1]strusted_root.jsonl%[1]s file for that purpose.
|
||||||
|
|
||||||
|
You can call this command without any flags to get a trusted root file covering
|
||||||
|
the Sigstore Public Good Instance as well as GitHub's Sigstore instance.
|
||||||
|
|
||||||
|
Otherwise you can use %[1]s--tuf-url%[1]s to specify the URL of a custom TUF
|
||||||
|
repository mirror, and %[1]s--tuf-root%[1]s should be the path to the
|
||||||
|
%[1]sroot.json%[1]s file that you securely obtained out-of-band.
|
||||||
|
|
||||||
|
If you just want to verify the integrity of your local TUF repository, and don't
|
||||||
|
want the contents of a trusted_root.jsonl file, use %[1]s--verify-only%[1]s.
|
||||||
|
`, "`"),
|
||||||
|
Example: heredoc.Doc(`
|
||||||
|
# Get a trusted_root.jsonl for both Sigstore Public Good and GitHub's instance
|
||||||
|
gh attestation trusted-root
|
||||||
|
`),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := auth.IsHostSupported(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if runF != nil {
|
||||||
|
return runF(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := getTrustedRoot(tuf.New, opts); err != nil {
|
||||||
|
return fmt.Errorf("Failed to verify the TUF repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedRootCmd.Flags().StringVarP(&opts.TufUrl, "tuf-url", "", "", "URL to the TUF repository mirror")
|
||||||
|
trustedRootCmd.Flags().StringVarP(&opts.TufRootPath, "tuf-root", "", "", "Path to the TUF root.json file on disk")
|
||||||
|
trustedRootCmd.MarkFlagsRequiredTogether("tuf-url", "tuf-root")
|
||||||
|
trustedRootCmd.Flags().BoolVarP(&opts.VerifyOnly, "verify-only", "", false, "Don't output trusted_root.jsonl contents")
|
||||||
|
|
||||||
|
return &trustedRootCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func getTrustedRoot(makeTUF tufClientInstantiator, opts *Options) error {
|
||||||
|
var tufOptions []*tuf.Options
|
||||||
|
|
||||||
|
tufOpt := verification.DefaultOptionsWithCacheSetting()
|
||||||
|
// Disable local caching, so we get up-to-date response from TUF repository
|
||||||
|
tufOpt.CacheValidity = 0
|
||||||
|
|
||||||
|
if opts.TufUrl != "" && opts.TufRootPath != "" {
|
||||||
|
tufRoot, err := os.ReadFile(opts.TufRootPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read root file %s: %v", opts.TufRootPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tufOpt.Root = tufRoot
|
||||||
|
tufOpt.RepositoryBaseURL = opts.TufUrl
|
||||||
|
tufOptions = append(tufOptions, tufOpt)
|
||||||
|
} else {
|
||||||
|
// Get from both Sigstore public good and GitHub private instance
|
||||||
|
tufOptions = append(tufOptions, tufOpt)
|
||||||
|
|
||||||
|
tufOpt = verification.GitHubTUFOptions()
|
||||||
|
tufOpt.CacheValidity = 0
|
||||||
|
tufOptions = append(tufOptions, tufOpt)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tufOpt = range tufOptions {
|
||||||
|
tufClient, err := makeTUF(tufOpt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create TUF client: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t, err := tufClient.GetTarget("trusted_root.json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
output := new(bytes.Buffer)
|
||||||
|
err = json.Compact(output, t)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.VerifyOnly {
|
||||||
|
fmt.Println(output)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Local TUF repository for %s updated and verified\n", tufOpt.RepositoryBaseURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package tufrootverify
|
package trustedroot
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
|
@ -6,16 +6,16 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sigstore/sigstore-go/pkg/tuf"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
|
"github.com/cli/cli/v2/pkg/cmd/attestation/test"
|
||||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
"github.com/cli/cli/v2/pkg/iostreams"
|
"github.com/cli/cli/v2/pkg/iostreams"
|
||||||
"github.com/sigstore/sigstore-go/pkg/tuf"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestNewTUFRootVerifyCmd(t *testing.T) {
|
func TestNewTrustedRootCmd(t *testing.T) {
|
||||||
testIO, _, _, _ := iostreams.Test()
|
testIO, _, _, _ := iostreams.Test()
|
||||||
f := &cmdutil.Factory{
|
f := &cmdutil.Factory{
|
||||||
IOStreams: testIO,
|
IOStreams: testIO,
|
||||||
|
|
@ -27,29 +27,37 @@ func TestNewTUFRootVerifyCmd(t *testing.T) {
|
||||||
wantsErr bool
|
wantsErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "Missing mirror flag",
|
name: "Happy path",
|
||||||
cli: "--root ../verification/embed/tuf-repo.github.com/root.json",
|
cli: "",
|
||||||
wantsErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Missing root flag",
|
|
||||||
cli: "--mirror https://tuf-repo.github.com",
|
|
||||||
wantsErr: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Has all required flags",
|
|
||||||
cli: "--mirror https://tuf-repo.github.com --root ../verification/embed/tuf-repo.github.com/root.json",
|
|
||||||
wantsErr: false,
|
wantsErr: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Happy path",
|
||||||
|
cli: "--verify-only",
|
||||||
|
wantsErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Custom TUF happy path",
|
||||||
|
cli: "--tuf-url https://tuf-repo.github.com --tuf-root ../verification/embed/tuf-repo.github.com/root.json",
|
||||||
|
wantsErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Missing tuf-root flag",
|
||||||
|
cli: "--tuf-url https://tuf-repo.github.com",
|
||||||
|
wantsErr: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range testcases {
|
for _, tc := range testcases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
cmd := NewTUFRootVerifyCmd(f, func() error {
|
cmd := NewTrustedRootCmd(f, func(_ *Options) error {
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
argv := strings.Split(tc.cli, " ")
|
argv := []string{}
|
||||||
|
if tc.cli != "" {
|
||||||
|
argv = strings.Split(tc.cli, " ")
|
||||||
|
}
|
||||||
cmd.SetArgs(argv)
|
cmd.SetArgs(argv)
|
||||||
cmd.SetIn(&bytes.Buffer{})
|
cmd.SetIn(&bytes.Buffer{})
|
||||||
cmd.SetOut(&bytes.Buffer{})
|
cmd.SetOut(&bytes.Buffer{})
|
||||||
|
|
@ -64,33 +72,35 @@ func TestNewTUFRootVerifyCmd(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var newTUFMockClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, error) {
|
|
||||||
return &tuf.Client{}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var newTUFErrClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, error) {
|
var newTUFErrClient tufClientInstantiator = func(o *tuf.Options) (*tuf.Client, error) {
|
||||||
return nil, fmt.Errorf("failed to create TUF client")
|
return nil, fmt.Errorf("failed to create TUF client")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTUFRootVerify(t *testing.T) {
|
func TestGetTrustedRoot(t *testing.T) {
|
||||||
mirror := "https://tuf-repo.github.com"
|
mirror := "https://tuf-repo.github.com"
|
||||||
root := test.NormalizeRelativePath("../verification/embed/tuf-repo.github.com/root.json")
|
root := test.NormalizeRelativePath("../verification/embed/tuf-repo.github.com/root.json")
|
||||||
|
|
||||||
|
opts := &Options{
|
||||||
|
TufUrl: mirror,
|
||||||
|
TufRootPath: root,
|
||||||
|
}
|
||||||
|
|
||||||
t.Run("successfully verifies TUF root", func(t *testing.T) {
|
t.Run("successfully verifies TUF root", func(t *testing.T) {
|
||||||
err := tufRootVerify(newTUFMockClient, mirror, root)
|
err := getTrustedRoot(tuf.New, opts)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("failed to create TUF root", func(t *testing.T) {
|
||||||
|
err := getTrustedRoot(newTUFErrClient, opts)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorContains(t, err, "failed to create TUF client")
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("fails because the root cannot be found", func(t *testing.T) {
|
t.Run("fails because the root cannot be found", func(t *testing.T) {
|
||||||
notFoundRoot := test.NormalizeRelativePath("./does/not/exist/root.json")
|
opts.TufRootPath = test.NormalizeRelativePath("./does/not/exist/root.json")
|
||||||
err := tufRootVerify(newTUFMockClient, mirror, notFoundRoot)
|
err := getTrustedRoot(tuf.New, opts)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
require.ErrorContains(t, err, "failed to read root file")
|
require.ErrorContains(t, err, "failed to read root file")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("failed to create TUF root", func(t *testing.T) {
|
|
||||||
err := tufRootVerify(newTUFErrClient, mirror, root)
|
|
||||||
require.Error(t, err)
|
|
||||||
require.ErrorContains(t, err, "failed to create TUF client")
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
package tufrootverify
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/auth"
|
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/verification"
|
|
||||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
||||||
|
|
||||||
"github.com/MakeNowJust/heredoc"
|
|
||||||
"github.com/sigstore/sigstore-go/pkg/tuf"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
)
|
|
||||||
|
|
||||||
type tufClientInstantiator func(o *tuf.Options) (*tuf.Client, error)
|
|
||||||
|
|
||||||
func NewTUFRootVerifyCmd(f *cmdutil.Factory, runF func() error) *cobra.Command {
|
|
||||||
var mirror string
|
|
||||||
var root string
|
|
||||||
var cmd = cobra.Command{
|
|
||||||
Use: "tuf-root-verify --mirror <mirror-url> --root <root.json>",
|
|
||||||
Args: cobra.ExactArgs(0),
|
|
||||||
Short: "Verify the TUF repository from a provided TUF root",
|
|
||||||
Hidden: true,
|
|
||||||
Long: heredoc.Docf(`
|
|
||||||
### NOTE: This feature is currently in beta, and subject to change.
|
|
||||||
|
|
||||||
Verify a TUF repository with a local TUF root.
|
|
||||||
|
|
||||||
The command requires you provide the %[1]s--mirror%[1]s flag, which should be the URL
|
|
||||||
of the TUF repository mirror.
|
|
||||||
|
|
||||||
The command also requires you provide the %[1]s--root%[1]s flag, which should be the
|
|
||||||
path to the TUF root file.
|
|
||||||
|
|
||||||
GitHub relies on TUF to securely deliver the trust root for our signing authority.
|
|
||||||
For more information on TUF, see the official documentation: <https://theupdateframework.github.io/>.
|
|
||||||
`, "`"),
|
|
||||||
Example: heredoc.Doc(`
|
|
||||||
# Verify the TUF repository from a provided TUF root
|
|
||||||
gh attestation tuf-root-verify --mirror https://tuf-repo.github.com --root /path/to/1.root.json
|
|
||||||
`),
|
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
|
||||||
if err := auth.IsHostSupported(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if runF != nil {
|
|
||||||
return runF()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tufRootVerify(tuf.New, mirror, root); err != nil {
|
|
||||||
return fmt.Errorf("Failed to verify the TUF repository: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
io := f.IOStreams
|
|
||||||
fmt.Sprintln(io.Out, io.ColorScheme().Green("Successfully verified the TUF repository"))
|
|
||||||
return nil
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.Flags().StringVarP(&mirror, "mirror", "m", "", "URL to the TUF repository mirror")
|
|
||||||
cmd.MarkFlagRequired("mirror") //nolint:errcheck
|
|
||||||
cmd.Flags().StringVarP(&root, "root", "r", "", "Path to the TUF root file on disk")
|
|
||||||
cmd.MarkFlagRequired("root") //nolint:errcheck
|
|
||||||
|
|
||||||
return &cmd
|
|
||||||
}
|
|
||||||
|
|
||||||
func tufRootVerify(makeTUF tufClientInstantiator, mirror, root string) error {
|
|
||||||
rb, err := os.ReadFile(root)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to read root file %s: %v", root, err)
|
|
||||||
}
|
|
||||||
opts := verification.GitHubTUFOptions()
|
|
||||||
opts.Root = rb
|
|
||||||
opts.RepositoryBaseURL = mirror
|
|
||||||
// The purpose is the verify the TUF root and repository, make
|
|
||||||
// sure there is no caching enabled
|
|
||||||
opts.CacheValidity = 0
|
|
||||||
if _, err = makeTUF(opts); err != nil {
|
|
||||||
return fmt.Errorf("failed to create TUF client: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
package verification
|
package verification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"crypto/x509"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
|
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
|
||||||
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
|
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
|
||||||
|
|
@ -29,9 +33,9 @@ type SigstoreResults struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type SigstoreConfig struct {
|
type SigstoreConfig struct {
|
||||||
CustomTrustedRoot string
|
TrustedRoot string
|
||||||
Logger *io.Handler
|
Logger *io.Handler
|
||||||
NoPublicGood bool
|
NoPublicGood bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type SigstoreVerifier interface {
|
type SigstoreVerifier interface {
|
||||||
|
|
@ -65,13 +69,68 @@ func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.ProtobufBundle) (*verify
|
||||||
}
|
}
|
||||||
issuer := leafCert.Issuer.Organization[0]
|
issuer := leafCert.Issuer.Organization[0]
|
||||||
|
|
||||||
// if user provided a custom trusted root file path, use the custom verifier
|
if v.config.TrustedRoot != "" {
|
||||||
if v.config.CustomTrustedRoot != "" {
|
customTrustRoots, err := os.ReadFile(v.config.TrustedRoot)
|
||||||
customVerifier, err := newCustomVerifier(v.config.CustomTrustedRoot)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", fmt.Errorf("failed to create custom verifier: %v", err)
|
return nil, "", fmt.Errorf("unable to read file %s: %v", v.config.TrustedRoot, err)
|
||||||
}
|
}
|
||||||
return customVerifier, issuer, nil
|
|
||||||
|
reader := bufio.NewReader(bytes.NewReader(customTrustRoots))
|
||||||
|
var line []byte
|
||||||
|
var readError error
|
||||||
|
line, readError = reader.ReadBytes('\n')
|
||||||
|
for readError == nil {
|
||||||
|
// Load each trusted root
|
||||||
|
trustedRoot, err := root.NewTrustedRootFromJSON(line)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to create custom verifier: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare bundle leafCert issuer with trusted root cert authority
|
||||||
|
certAuthorities := trustedRoot.FulcioCertificateAuthorities()
|
||||||
|
for _, certAuthority := range certAuthorities {
|
||||||
|
lowestCert, err := getLowestCertInChain(&certAuthority)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(lowestCert.Issuer.Organization) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if lowestCert.Issuer.Organization[0] == issuer {
|
||||||
|
// Determine what policy to use with this trusted root.
|
||||||
|
//
|
||||||
|
// Note that we are *only* inferring the policy with the
|
||||||
|
// issuer. We *must* use the trusted root provided.
|
||||||
|
if issuer == PublicGoodIssuerOrg {
|
||||||
|
if v.config.NoPublicGood {
|
||||||
|
return nil, "", fmt.Errorf("Detected public good instance but requested verification without public good instance")
|
||||||
|
}
|
||||||
|
verifier, err := newPublicGoodVerifierWithTrustedRoot(trustedRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return verifier, issuer, nil
|
||||||
|
} else if issuer == GitHubIssuerOrg {
|
||||||
|
verifier, err := newGitHubVerifierWithTrustedRoot(trustedRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
return verifier, issuer, nil
|
||||||
|
} else {
|
||||||
|
// Make best guess at reasonable policy
|
||||||
|
customVerifier, err := newCustomVerifier(trustedRoot)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("failed to create custom verifier: %v", err)
|
||||||
|
}
|
||||||
|
return customVerifier, issuer, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line, readError = reader.ReadBytes('\n')
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("unable to use provided trusted roots")
|
||||||
}
|
}
|
||||||
|
|
||||||
if leafCert.Issuer.Organization[0] == PublicGoodIssuerOrg && !v.config.NoPublicGood {
|
if leafCert.Issuer.Organization[0] == PublicGoodIssuerOrg && !v.config.NoPublicGood {
|
||||||
|
|
@ -93,6 +152,18 @@ func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.ProtobufBundle) (*verify
|
||||||
return nil, "", fmt.Errorf("leaf certificate issuer is not recognized")
|
return nil, "", fmt.Errorf("leaf certificate issuer is not recognized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getLowestCertInChain(ca *root.CertificateAuthority) (*x509.Certificate, error) {
|
||||||
|
if ca.Leaf != nil {
|
||||||
|
return ca.Leaf, nil
|
||||||
|
} else if len(ca.Intermediates) > 0 {
|
||||||
|
return ca.Intermediates[0], nil
|
||||||
|
} else if ca.Root != nil {
|
||||||
|
return ca.Root, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("certificate authority had no certificates")
|
||||||
|
}
|
||||||
|
|
||||||
func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) *SigstoreResults {
|
func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy verify.PolicyBuilder) *SigstoreResults {
|
||||||
// initialize the processing results before attempting to verify
|
// initialize the processing results before attempting to verify
|
||||||
// with multiple verifiers
|
// with multiple verifiers
|
||||||
|
|
@ -143,21 +214,17 @@ func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy ve
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func newCustomVerifier(trustedRootFilePath string) (*verify.SignedEntityVerifier, error) {
|
func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
|
||||||
trustedRoot, err := root.NewTrustedRootFromPath(trustedRootFilePath)
|
// All we know about this trust root is its configuration so make some
|
||||||
if err != nil {
|
// educated guesses as to what the policy should be.
|
||||||
return nil, fmt.Errorf("failed to create trusted root from file %s: %v", trustedRootFilePath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
verifierConfig := []verify.VerifierOption{}
|
verifierConfig := []verify.VerifierOption{}
|
||||||
verifierConfig = append(verifierConfig, verify.WithSignedCertificateTimestamps(1))
|
// This requires some independent corroboration of the signing certificate
|
||||||
|
// (e.g. from Sigstore Fulcio) time, one of:
|
||||||
|
// - a signed timestamp from a timestamp authority in the trusted root
|
||||||
|
// - a transparency log entry (e.g. from Sigstore Rekor)
|
||||||
verifierConfig = append(verifierConfig, verify.WithObserverTimestamps(1))
|
verifierConfig = append(verifierConfig, verify.WithObserverTimestamps(1))
|
||||||
|
|
||||||
// Infer verification options from contents of trusted root
|
// Infer verification options from contents of trusted root
|
||||||
if len(trustedRoot.TimestampingAuthorities()) > 0 {
|
|
||||||
verifierConfig = append(verifierConfig, verify.WithSignedTimestamps(1))
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(trustedRoot.RekorLogs()) > 0 {
|
if len(trustedRoot.RekorLogs()) > 0 {
|
||||||
verifierConfig = append(verifierConfig, verify.WithTransparencyLog(1))
|
verifierConfig = append(verifierConfig, verify.WithTransparencyLog(1))
|
||||||
}
|
}
|
||||||
|
|
@ -180,6 +247,10 @@ func newGitHubVerifier() (*verify.SignedEntityVerifier, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return newGitHubVerifierWithTrustedRoot(trustedRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
|
||||||
gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1))
|
gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create GitHub verifier: %v", err)
|
return nil, fmt.Errorf("failed to create GitHub verifier: %v", err)
|
||||||
|
|
@ -199,6 +270,10 @@ func newPublicGoodVerifier() (*verify.SignedEntityVerifier, error) {
|
||||||
return nil, fmt.Errorf("failed to get trusted root: %v", err)
|
return nil, fmt.Errorf("failed to get trusted root: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return newPublicGoodVerifierWithTrustedRoot(trustedRoot)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
|
||||||
sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1))
|
sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create Public Good verifier: %v", err)
|
return nil, fmt.Errorf("failed to create Public Good verifier: %v", err)
|
||||||
|
|
|
||||||
|
|
@ -92,8 +92,8 @@ func TestLiveSigstoreVerifier(t *testing.T) {
|
||||||
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
|
attestations := getAttestationsFor(t, "../test/data/sigstore-js-2.1.0_with_2_bundles.jsonl")
|
||||||
|
|
||||||
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
|
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
|
||||||
Logger: io.NewTestHandler(),
|
Logger: io.NewTestHandler(),
|
||||||
CustomTrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
|
TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
|
||||||
})
|
})
|
||||||
|
|
||||||
res := verifier.Verify(attestations, publicGoodPolicy(t))
|
res := verifier.Verify(attestations, publicGoodPolicy(t))
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ type Options struct {
|
||||||
ArtifactPath string
|
ArtifactPath string
|
||||||
BundlePath string
|
BundlePath string
|
||||||
Config func() (gh.Config, error)
|
Config func() (gh.Config, error)
|
||||||
CustomTrustedRoot string
|
TrustedRoot string
|
||||||
DenySelfHostedRunner bool
|
DenySelfHostedRunner bool
|
||||||
DigestAlgorithm string
|
DigestAlgorithm string
|
||||||
Limit int
|
Limit int
|
||||||
|
|
|
||||||
|
|
@ -132,9 +132,9 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
|
||||||
}
|
}
|
||||||
|
|
||||||
config := verification.SigstoreConfig{
|
config := verification.SigstoreConfig{
|
||||||
CustomTrustedRoot: opts.CustomTrustedRoot,
|
TrustedRoot: opts.TrustedRoot,
|
||||||
Logger: opts.Logger,
|
Logger: opts.Logger,
|
||||||
NoPublicGood: opts.NoPublicGood,
|
NoPublicGood: opts.NoPublicGood,
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
|
opts.SigstoreVerifier = verification.NewLiveSigstoreVerifier(config)
|
||||||
|
|
@ -156,8 +156,8 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
|
||||||
verifyCmd.MarkFlagsMutuallyExclusive("owner", "repo")
|
verifyCmd.MarkFlagsMutuallyExclusive("owner", "repo")
|
||||||
verifyCmd.MarkFlagsOneRequired("owner", "repo")
|
verifyCmd.MarkFlagsOneRequired("owner", "repo")
|
||||||
verifyCmd.Flags().StringVarP(&opts.PredicateType, "predicate-type", "", "", "Filter attestations by provided predicate type")
|
verifyCmd.Flags().StringVarP(&opts.PredicateType, "predicate-type", "", "", "Filter attestations by provided predicate type")
|
||||||
verifyCmd.Flags().BoolVarP(&opts.NoPublicGood, "no-public-good", "", false, "Only verify attestations signed with GitHub's Sigstore instance")
|
verifyCmd.Flags().BoolVarP(&opts.NoPublicGood, "no-public-good", "", false, "Do not verify attestations signed with Sigstore public good instance")
|
||||||
verifyCmd.Flags().StringVarP(&opts.CustomTrustedRoot, "custom-trusted-root", "", "", "Path to a custom trustedroot.json file to use for verification")
|
verifyCmd.Flags().StringVarP(&opts.TrustedRoot, "custom-trusted-root", "", "", "Path to a trusted_root.jsonl file; likely for offline verification")
|
||||||
verifyCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch")
|
verifyCmd.Flags().IntVarP(&opts.Limit, "limit", "L", api.DefaultLimit, "Maximum number of attestations to fetch")
|
||||||
cmdutil.AddFormatFlags(verifyCmd, &opts.exporter)
|
cmdutil.AddFormatFlags(verifyCmd, &opts.exporter)
|
||||||
// policy enforcement flags
|
// policy enforcement flags
|
||||||
|
|
|
||||||
|
|
@ -220,7 +220,7 @@ func TestNewVerifyCmd(t *testing.T) {
|
||||||
|
|
||||||
assert.Equal(t, tc.wants.ArtifactPath, opts.ArtifactPath)
|
assert.Equal(t, tc.wants.ArtifactPath, opts.ArtifactPath)
|
||||||
assert.Equal(t, tc.wants.BundlePath, opts.BundlePath)
|
assert.Equal(t, tc.wants.BundlePath, opts.BundlePath)
|
||||||
assert.Equal(t, tc.wants.CustomTrustedRoot, opts.CustomTrustedRoot)
|
assert.Equal(t, tc.wants.TrustedRoot, opts.TrustedRoot)
|
||||||
assert.Equal(t, tc.wants.DenySelfHostedRunner, opts.DenySelfHostedRunner)
|
assert.Equal(t, tc.wants.DenySelfHostedRunner, opts.DenySelfHostedRunner)
|
||||||
assert.Equal(t, tc.wants.DigestAlgorithm, opts.DigestAlgorithm)
|
assert.Equal(t, tc.wants.DigestAlgorithm, opts.DigestAlgorithm)
|
||||||
assert.Equal(t, tc.wants.Limit, opts.Limit)
|
assert.Equal(t, tc.wants.Limit, opts.Limit)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ func Test_listRun(t *testing.T) {
|
||||||
cfg.Set("HOST", "git_protocol", "ssh")
|
cfg.Set("HOST", "git_protocol", "ssh")
|
||||||
cfg.Set("HOST", "editor", "/usr/bin/vim")
|
cfg.Set("HOST", "editor", "/usr/bin/vim")
|
||||||
cfg.Set("HOST", "prompt", "disabled")
|
cfg.Set("HOST", "prompt", "disabled")
|
||||||
|
cfg.Set("HOST", "prefer_editor_prompt", "enabled")
|
||||||
cfg.Set("HOST", "pager", "less")
|
cfg.Set("HOST", "pager", "less")
|
||||||
cfg.Set("HOST", "http_unix_socket", "")
|
cfg.Set("HOST", "http_unix_socket", "")
|
||||||
cfg.Set("HOST", "browser", "brave")
|
cfg.Set("HOST", "browser", "brave")
|
||||||
|
|
@ -93,6 +94,7 @@ func Test_listRun(t *testing.T) {
|
||||||
stdout: `git_protocol=ssh
|
stdout: `git_protocol=ssh
|
||||||
editor=/usr/bin/vim
|
editor=/usr/bin/vim
|
||||||
prompt=disabled
|
prompt=disabled
|
||||||
|
prefer_editor_prompt=enabled
|
||||||
pager=less
|
pager=less
|
||||||
http_unix_socket=
|
http_unix_socket=
|
||||||
browser=brave
|
browser=brave
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreateOptions struct {
|
type CreateOptions struct {
|
||||||
HttpClient func() (*http.Client, error)
|
HttpClient func() (*http.Client, error)
|
||||||
Config func() (gh.Config, error)
|
Config func() (gh.Config, error)
|
||||||
IO *iostreams.IOStreams
|
IO *iostreams.IOStreams
|
||||||
BaseRepo func() (ghrepo.Interface, error)
|
BaseRepo func() (ghrepo.Interface, error)
|
||||||
Browser browser.Browser
|
Browser browser.Browser
|
||||||
Prompter prShared.Prompt
|
Prompter prShared.Prompt
|
||||||
|
TitledEditSurvey func(string, string) (string, string, error)
|
||||||
|
|
||||||
RootDirOverride string
|
RootDirOverride string
|
||||||
|
|
||||||
HasRepoOverride bool
|
HasRepoOverride bool
|
||||||
|
EditorMode bool
|
||||||
WebMode bool
|
WebMode bool
|
||||||
RecoverFile string
|
RecoverFile string
|
||||||
|
|
||||||
|
|
@ -44,11 +46,12 @@ type CreateOptions struct {
|
||||||
|
|
||||||
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
||||||
opts := &CreateOptions{
|
opts := &CreateOptions{
|
||||||
IO: f.IOStreams,
|
IO: f.IOStreams,
|
||||||
HttpClient: f.HttpClient,
|
HttpClient: f.HttpClient,
|
||||||
Config: f.Config,
|
Config: f.Config,
|
||||||
Browser: f.Browser,
|
Browser: f.Browser,
|
||||||
Prompter: f.Prompter,
|
Prompter: f.Prompter,
|
||||||
|
TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}),
|
||||||
}
|
}
|
||||||
|
|
||||||
var bodyFile string
|
var bodyFile string
|
||||||
|
|
@ -77,6 +80,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
||||||
opts.BaseRepo = f.BaseRepo
|
opts.BaseRepo = f.BaseRepo
|
||||||
opts.HasRepoOverride = cmd.Flags().Changed("repo")
|
opts.HasRepoOverride = cmd.Flags().Changed("repo")
|
||||||
|
|
||||||
|
if err := cmdutil.MutuallyExclusive(
|
||||||
|
"specify only one of `--editor` or `--web`",
|
||||||
|
opts.EditorMode,
|
||||||
|
opts.WebMode,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := f.Config()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
opts.EditorMode = !opts.WebMode && (opts.EditorMode || config.PreferEditorPrompt("").Value == "enabled")
|
||||||
|
|
||||||
titleProvided := cmd.Flags().Changed("title")
|
titleProvided := cmd.Flags().Changed("title")
|
||||||
bodyProvided := cmd.Flags().Changed("body")
|
bodyProvided := cmd.Flags().Changed("body")
|
||||||
if bodyFile != "" {
|
if bodyFile != "" {
|
||||||
|
|
@ -96,11 +113,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
||||||
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
|
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
|
||||||
}
|
}
|
||||||
|
|
||||||
opts.Interactive = !(titleProvided && bodyProvided)
|
opts.Interactive = !opts.EditorMode && !(titleProvided && bodyProvided)
|
||||||
|
|
||||||
if opts.Interactive && !opts.IO.CanPrompt() {
|
if opts.Interactive && !opts.IO.CanPrompt() {
|
||||||
return cmdutil.FlagErrorf("must provide `--title` and `--body` when not running interactively")
|
return cmdutil.FlagErrorf("must provide `--title` and `--body` when not running interactively")
|
||||||
}
|
}
|
||||||
|
if opts.EditorMode && !opts.IO.CanPrompt() {
|
||||||
|
return errors.New("--editor or enabled prefer_editor_prompt configuration are not supported in non-tty mode")
|
||||||
|
}
|
||||||
|
|
||||||
if runF != nil {
|
if runF != nil {
|
||||||
return runF(opts)
|
return runF(opts)
|
||||||
|
|
@ -112,6 +132,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
||||||
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.")
|
cmd.Flags().StringVarP(&opts.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.")
|
||||||
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
|
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
|
||||||
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
|
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
|
||||||
|
cmd.Flags().BoolVarP(&opts.EditorMode, "editor", "e", false, "Skip prompts and open the text editor to write the title and body in. The first line is the title and the rest text is the body.")
|
||||||
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue")
|
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue")
|
||||||
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
|
cmd.Flags().StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.")
|
||||||
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
|
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
|
||||||
|
|
@ -285,6 +306,25 @@ func createRun(opts *CreateOptions) (err error) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if opts.EditorMode {
|
||||||
|
if opts.Template != "" {
|
||||||
|
var template prShared.Template
|
||||||
|
template, err = tpl.Select(opts.Template)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tb.Title == "" {
|
||||||
|
tb.Title = template.Title()
|
||||||
|
}
|
||||||
|
templateNameForSubmit = template.NameForSubmit()
|
||||||
|
tb.Body = string(template.Body())
|
||||||
|
}
|
||||||
|
|
||||||
|
tb.Title, tb.Body, err = opts.TitledEditSurvey(tb.Title, tb.Body)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if tb.Title == "" {
|
if tb.Title == "" {
|
||||||
err = fmt.Errorf("title can't be blank")
|
err = fmt.Errorf("title can't be blank")
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ func TestNewCmdCreate(t *testing.T) {
|
||||||
tty bool
|
tty bool
|
||||||
stdin string
|
stdin string
|
||||||
cli string
|
cli string
|
||||||
|
config string
|
||||||
wantsErr bool
|
wantsErr bool
|
||||||
wantsOpts CreateOptions
|
wantsOpts CreateOptions
|
||||||
}{
|
}{
|
||||||
|
|
@ -125,6 +126,77 @@ func TestNewCmdCreate(t *testing.T) {
|
||||||
cli: `-t mytitle --template "bug report" --body-file "body_file.md"`,
|
cli: `-t mytitle --template "bug report" --body-file "body_file.md"`,
|
||||||
wantsErr: true,
|
wantsErr: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "editor by cli",
|
||||||
|
tty: true,
|
||||||
|
cli: "--editor",
|
||||||
|
wantsErr: false,
|
||||||
|
wantsOpts: CreateOptions{
|
||||||
|
Title: "",
|
||||||
|
Body: "",
|
||||||
|
RecoverFile: "",
|
||||||
|
WebMode: false,
|
||||||
|
EditorMode: true,
|
||||||
|
Interactive: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "editor by config",
|
||||||
|
tty: true,
|
||||||
|
cli: "",
|
||||||
|
config: "prefer_editor_prompt: enabled",
|
||||||
|
wantsErr: false,
|
||||||
|
wantsOpts: CreateOptions{
|
||||||
|
Title: "",
|
||||||
|
Body: "",
|
||||||
|
RecoverFile: "",
|
||||||
|
WebMode: false,
|
||||||
|
EditorMode: true,
|
||||||
|
Interactive: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "editor and template",
|
||||||
|
tty: true,
|
||||||
|
cli: `--editor --template "bug report"`,
|
||||||
|
wantsErr: false,
|
||||||
|
wantsOpts: CreateOptions{
|
||||||
|
Title: "",
|
||||||
|
Body: "",
|
||||||
|
RecoverFile: "",
|
||||||
|
WebMode: false,
|
||||||
|
EditorMode: true,
|
||||||
|
Template: "bug report",
|
||||||
|
Interactive: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "editor and web",
|
||||||
|
tty: true,
|
||||||
|
cli: "--editor --web",
|
||||||
|
wantsErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "can use web even though editor is enabled by config",
|
||||||
|
tty: true,
|
||||||
|
cli: `--web --title mytitle --body "issue body"`,
|
||||||
|
config: "prefer_editor_prompt: enabled",
|
||||||
|
wantsErr: false,
|
||||||
|
wantsOpts: CreateOptions{
|
||||||
|
Title: "mytitle",
|
||||||
|
Body: "issue body",
|
||||||
|
RecoverFile: "",
|
||||||
|
WebMode: true,
|
||||||
|
EditorMode: false,
|
||||||
|
Interactive: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "editor with non-tty",
|
||||||
|
tty: false,
|
||||||
|
cli: "--editor",
|
||||||
|
wantsErr: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
|
@ -138,6 +210,12 @@ func TestNewCmdCreate(t *testing.T) {
|
||||||
|
|
||||||
f := &cmdutil.Factory{
|
f := &cmdutil.Factory{
|
||||||
IOStreams: ios,
|
IOStreams: ios,
|
||||||
|
Config: func() (gh.Config, error) {
|
||||||
|
if tt.config != "" {
|
||||||
|
return config.NewFromString(tt.config), nil
|
||||||
|
}
|
||||||
|
return config.NewBlankConfig(), nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var opts *CreateOptions
|
var opts *CreateOptions
|
||||||
|
|
@ -310,6 +388,72 @@ func Test_createRun(t *testing.T) {
|
||||||
},
|
},
|
||||||
wantsErr: "cannot open in browser: maximum URL length exceeded",
|
wantsErr: "cannot open in browser: maximum URL length exceeded",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "editor",
|
||||||
|
httpStubs: func(r *httpmock.Registry) {
|
||||||
|
r.Register(
|
||||||
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"id": "REPOID",
|
||||||
|
"hasIssuesEnabled": true
|
||||||
|
} } }`))
|
||||||
|
r.Register(
|
||||||
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||||
|
httpmock.GraphQLMutation(`
|
||||||
|
{ "data": { "createIssue": { "issue": {
|
||||||
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||||
|
} } } }
|
||||||
|
`, func(inputs map[string]interface{}) {
|
||||||
|
assert.Equal(t, "title", inputs["title"])
|
||||||
|
assert.Equal(t, "body", inputs["body"])
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
opts: CreateOptions{
|
||||||
|
EditorMode: true,
|
||||||
|
TitledEditSurvey: func(string, string) (string, string, error) { return "title", "body", nil },
|
||||||
|
},
|
||||||
|
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
||||||
|
wantsStderr: "\nCreating issue in OWNER/REPO\n\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "editor and template",
|
||||||
|
httpStubs: func(r *httpmock.Registry) {
|
||||||
|
r.Register(
|
||||||
|
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": {
|
||||||
|
"id": "REPOID",
|
||||||
|
"hasIssuesEnabled": true
|
||||||
|
} } }`))
|
||||||
|
r.Register(
|
||||||
|
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||||
|
httpmock.StringResponse(`
|
||||||
|
{ "data": { "repository": { "issueTemplates": [
|
||||||
|
{ "name": "Bug report",
|
||||||
|
"title": "bug: ",
|
||||||
|
"body": "Does not work :((" }
|
||||||
|
] } } }`),
|
||||||
|
)
|
||||||
|
r.Register(
|
||||||
|
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||||
|
httpmock.GraphQLMutation(`
|
||||||
|
{ "data": { "createIssue": { "issue": {
|
||||||
|
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||||
|
} } } }
|
||||||
|
`, func(inputs map[string]interface{}) {
|
||||||
|
assert.Equal(t, "bug: ", inputs["title"])
|
||||||
|
assert.Equal(t, "Does not work :((", inputs["body"])
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
opts: CreateOptions{
|
||||||
|
EditorMode: true,
|
||||||
|
Template: "Bug report",
|
||||||
|
TitledEditSurvey: func(title string, body string) (string, string, error) { return title, body, nil },
|
||||||
|
},
|
||||||
|
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
||||||
|
wantsStderr: "\nCreating issue in OWNER/REPO\n\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) {
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,37 @@ import (
|
||||||
"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"
|
||||||
"github.com/cli/cli/v2/pkg/iostreams"
|
"github.com/cli/cli/v2/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/v2/pkg/jsonfieldstest"
|
||||||
"github.com/cli/cli/v2/test"
|
"github.com/cli/cli/v2/test"
|
||||||
"github.com/google/shlex"
|
"github.com/google/shlex"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestJSONFields(t *testing.T) {
|
||||||
|
jsonfieldstest.ExpectCommandToSupportJSONFields(t, NewCmdView, []string{
|
||||||
|
"assignees",
|
||||||
|
"author",
|
||||||
|
"body",
|
||||||
|
"closed",
|
||||||
|
"comments",
|
||||||
|
"createdAt",
|
||||||
|
"closedAt",
|
||||||
|
"id",
|
||||||
|
"labels",
|
||||||
|
"milestone",
|
||||||
|
"number",
|
||||||
|
"projectCards",
|
||||||
|
"projectItems",
|
||||||
|
"reactionGroups",
|
||||||
|
"state",
|
||||||
|
"title",
|
||||||
|
"updatedAt",
|
||||||
|
"url",
|
||||||
|
"isPinned",
|
||||||
|
"stateReason",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||||
ios, _, stdout, stderr := iostreams.Test()
|
ios, _, stdout, stderr := iostreams.Test()
|
||||||
ios.SetStdoutTTY(isTTY)
|
ios.SetStdoutTTY(isTTY)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/cli/cli/v2/api"
|
"github.com/cli/cli/v2/api"
|
||||||
|
"github.com/cli/cli/v2/internal/gh"
|
||||||
"github.com/cli/cli/v2/internal/ghrepo"
|
"github.com/cli/cli/v2/internal/ghrepo"
|
||||||
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
"github.com/cli/cli/v2/pkg/iostreams"
|
"github.com/cli/cli/v2/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/v2/pkg/surveyext"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Action int
|
type Action int
|
||||||
|
|
@ -317,3 +320,40 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Editor interface {
|
||||||
|
Edit(filename, initialValue string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserEditor struct {
|
||||||
|
IO *iostreams.IOStreams
|
||||||
|
Config func() (gh.Config, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *UserEditor) Edit(filename, initialValue string) (string, error) {
|
||||||
|
editorCommand, err := cmdutil.DetermineEditor(e.Config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return surveyext.Edit(editorCommand, filename, initialValue, e.IO.In, e.IO.Out, e.IO.ErrOut)
|
||||||
|
}
|
||||||
|
|
||||||
|
const editorHintMarker = "------------------------ >8 ------------------------"
|
||||||
|
const editorHint = `
|
||||||
|
Please Enter the title on the first line and the body on subsequent lines.
|
||||||
|
Lines below dotted lines will be ignored, and an empty title aborts the creation process.`
|
||||||
|
|
||||||
|
func TitledEditSurvey(editor Editor) func(string, string) (string, string, error) {
|
||||||
|
return func(initialTitle, initialBody string) (string, string, error) {
|
||||||
|
initialValue := strings.Join([]string{initialTitle, initialBody, editorHintMarker, editorHint}, "\n")
|
||||||
|
titleAndBody, err := editor.Edit("*.md", initialValue)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
titleAndBody = strings.ReplaceAll(titleAndBody, "\r\n", "\n")
|
||||||
|
titleAndBody, _, _ = strings.Cut(titleAndBody, editorHintMarker)
|
||||||
|
title, body, _ := strings.Cut(titleAndBody, "\n")
|
||||||
|
return title, strings.TrimSuffix(body, "\n"), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,3 +123,38 @@ func TestMetadataSurvey_keepExisting(t *testing.T) {
|
||||||
assert.Equal(t, []string{"good first issue"}, state.Labels)
|
assert.Equal(t, []string{"good first issue"}, state.Labels)
|
||||||
assert.Equal(t, []string{"The road to 1.0"}, state.Projects)
|
assert.Equal(t, []string{"The road to 1.0"}, state.Projects)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTitledEditSurvey_cleanupHint(t *testing.T) {
|
||||||
|
var editorInitialText string
|
||||||
|
editor := &testEditor{
|
||||||
|
edit: func(s string) (string, error) {
|
||||||
|
editorInitialText = s
|
||||||
|
return `editedTitle
|
||||||
|
editedBody
|
||||||
|
------------------------ >8 ------------------------
|
||||||
|
|
||||||
|
Please Enter the title on the first line and the body on subsequent lines.
|
||||||
|
Lines below dotted lines will be ignored, and an empty title aborts the creation process.`, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
title, body, err := TitledEditSurvey(editor)("initialTitle", "initialBody")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, `initialTitle
|
||||||
|
initialBody
|
||||||
|
------------------------ >8 ------------------------
|
||||||
|
|
||||||
|
Please Enter the title on the first line and the body on subsequent lines.
|
||||||
|
Lines below dotted lines will be ignored, and an empty title aborts the creation process.`, editorInitialText)
|
||||||
|
assert.Equal(t, "editedTitle", title)
|
||||||
|
assert.Equal(t, "editedBody", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
type testEditor struct {
|
||||||
|
edit func(string) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e testEditor) Edit(filename, text string) (string, error) {
|
||||||
|
return e.edit(text)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type issueTemplate struct {
|
type issueTemplate struct {
|
||||||
Gname string `graphql:"name"`
|
Gname string `graphql:"name"`
|
||||||
Gbody string `graphql:"body"`
|
Gbody string `graphql:"body"`
|
||||||
|
Gtitle string `graphql:"title"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type pullRequestTemplate struct {
|
type pullRequestTemplate struct {
|
||||||
|
|
@ -37,6 +38,10 @@ func (t *issueTemplate) Body() []byte {
|
||||||
return []byte(t.Gbody)
|
return []byte(t.Gbody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *issueTemplate) Title() string {
|
||||||
|
return t.Gtitle
|
||||||
|
}
|
||||||
|
|
||||||
func (t *pullRequestTemplate) Name() string {
|
func (t *pullRequestTemplate) Name() string {
|
||||||
return t.Gname
|
return t.Gname
|
||||||
}
|
}
|
||||||
|
|
@ -49,6 +54,10 @@ func (t *pullRequestTemplate) Body() []byte {
|
||||||
return []byte(t.Gbody)
|
return []byte(t.Gbody)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *pullRequestTemplate) Title() string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
func listIssueTemplates(httpClient *http.Client, repo ghrepo.Interface) ([]Template, error) {
|
func listIssueTemplates(httpClient *http.Client, repo ghrepo.Interface) ([]Template, error) {
|
||||||
var query struct {
|
var query struct {
|
||||||
Repository struct {
|
Repository struct {
|
||||||
|
|
@ -109,6 +118,7 @@ type Template interface {
|
||||||
Name() string
|
Name() string
|
||||||
NameForSubmit() string
|
NameForSubmit() string
|
||||||
Body() []byte
|
Body() []byte
|
||||||
|
Title() string
|
||||||
}
|
}
|
||||||
|
|
||||||
type iprompter interface {
|
type iprompter interface {
|
||||||
|
|
@ -294,3 +304,7 @@ func (t *filesystemTemplate) NameForSubmit() string {
|
||||||
func (t *filesystemTemplate) Body() []byte {
|
func (t *filesystemTemplate) Body() []byte {
|
||||||
return githubtemplate.ExtractContents(t.path)
|
return githubtemplate.ExtractContents(t.path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *filesystemTemplate) Title() string {
|
||||||
|
return githubtemplate.ExtractTitle(t.path)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ func TestTemplateManager_hasAPI(t *testing.T) {
|
||||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||||
httpmock.StringResponse(`{"data":{"repository":{
|
httpmock.StringResponse(`{"data":{"repository":{
|
||||||
"issueTemplates": [
|
"issueTemplates": [
|
||||||
{"name": "Bug report", "body": "I found a problem"},
|
{"name": "Bug report", "body": "I found a problem", "title": "bug: "},
|
||||||
{"name": "Feature request", "body": "I need a feature"}
|
{"name": "Feature request", "body": "I need a feature", "title": "request: "}
|
||||||
]
|
]
|
||||||
}}}`))
|
}}}`))
|
||||||
|
|
||||||
|
|
@ -62,6 +62,7 @@ func TestTemplateManager_hasAPI(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "Feature request", tpl.NameForSubmit())
|
assert.Equal(t, "Feature request", tpl.NameForSubmit())
|
||||||
assert.Equal(t, "I need a feature", string(tpl.Body()))
|
assert.Equal(t, "I need a feature", string(tpl.Body()))
|
||||||
|
assert.Equal(t, "request: ", tpl.Title())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
||||||
|
|
@ -112,6 +113,7 @@ func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.Equal(t, "", tpl.NameForSubmit())
|
assert.Equal(t, "", tpl.NameForSubmit())
|
||||||
assert.Equal(t, "I fixed a problem", string(tpl.Body()))
|
assert.Equal(t, "I fixed a problem", string(tpl.Body()))
|
||||||
|
assert.Equal(t, "", tpl.Title())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTemplateManagerSelect(t *testing.T) {
|
func TestTemplateManagerSelect(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,61 @@ import (
|
||||||
"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"
|
||||||
"github.com/cli/cli/v2/pkg/iostreams"
|
"github.com/cli/cli/v2/pkg/iostreams"
|
||||||
|
"github.com/cli/cli/v2/pkg/jsonfieldstest"
|
||||||
"github.com/cli/cli/v2/test"
|
"github.com/cli/cli/v2/test"
|
||||||
"github.com/google/shlex"
|
"github.com/google/shlex"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestJSONFields(t *testing.T) {
|
||||||
|
jsonfieldstest.ExpectCommandToSupportJSONFields(t, NewCmdView, []string{
|
||||||
|
"additions",
|
||||||
|
"assignees",
|
||||||
|
"author",
|
||||||
|
"autoMergeRequest",
|
||||||
|
"baseRefName",
|
||||||
|
"body",
|
||||||
|
"changedFiles",
|
||||||
|
"closed",
|
||||||
|
"closedAt",
|
||||||
|
"comments",
|
||||||
|
"commits",
|
||||||
|
"createdAt",
|
||||||
|
"deletions",
|
||||||
|
"files",
|
||||||
|
"headRefName",
|
||||||
|
"headRefOid",
|
||||||
|
"headRepository",
|
||||||
|
"headRepositoryOwner",
|
||||||
|
"id",
|
||||||
|
"isCrossRepository",
|
||||||
|
"isDraft",
|
||||||
|
"labels",
|
||||||
|
"latestReviews",
|
||||||
|
"maintainerCanModify",
|
||||||
|
"mergeCommit",
|
||||||
|
"mergeStateStatus",
|
||||||
|
"mergeable",
|
||||||
|
"mergedAt",
|
||||||
|
"mergedBy",
|
||||||
|
"milestone",
|
||||||
|
"number",
|
||||||
|
"potentialMergeCommit",
|
||||||
|
"projectCards",
|
||||||
|
"projectItems",
|
||||||
|
"reactionGroups",
|
||||||
|
"reviewDecision",
|
||||||
|
"reviewRequests",
|
||||||
|
"reviews",
|
||||||
|
"state",
|
||||||
|
"statusCheckRollup",
|
||||||
|
"title",
|
||||||
|
"updatedAt",
|
||||||
|
"url",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func Test_NewCmdView(t *testing.T) {
|
func Test_NewCmdView(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
||||||
Use release notes from a file
|
Use release notes from a file
|
||||||
$ gh release create v1.2.3 -F changelog.md
|
$ gh release create v1.2.3 -F changelog.md
|
||||||
|
|
||||||
Don't mark the release as latest
|
Don't mark the release as latest
|
||||||
$ gh release create v1.2.3 --latest=false
|
$ gh release create v1.2.3 --latest=false
|
||||||
|
|
||||||
Upload all tarballs in a directory as release assets
|
Upload all tarballs in a directory as release assets
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
||||||
To create a remote repository non-interactively, supply the repository name and one of %[1]s--public%[1]s, %[1]s--private%[1]s, or %[1]s--internal%[1]s.
|
To create a remote repository non-interactively, supply the repository name and one of %[1]s--public%[1]s, %[1]s--private%[1]s, or %[1]s--internal%[1]s.
|
||||||
Pass %[1]s--clone%[1]s to clone the new repository locally.
|
Pass %[1]s--clone%[1]s to clone the new repository locally.
|
||||||
|
|
||||||
|
If the %[1]sOWNER/%[1]s portion of the %[1]sOWNER/REPO%[1]s name argument is omitted, it
|
||||||
|
defaults to the name of the authenticating user.
|
||||||
|
|
||||||
To create a remote repository from an existing local repository, specify the source directory with %[1]s--source%[1]s.
|
To create a remote repository from an existing local repository, specify the source directory with %[1]s--source%[1]s.
|
||||||
By default, the remote repository name will be the name of the source directory.
|
By default, the remote repository name will be the name of the source directory.
|
||||||
Pass %[1]s--push%[1]s to push any local commits to the new repository.
|
Pass %[1]s--push%[1]s to push any local commits to the new repository.
|
||||||
|
|
@ -99,6 +102,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
||||||
# create a new remote repository and clone it locally
|
# create a new remote repository and clone it locally
|
||||||
gh repo create my-project --public --clone
|
gh repo create my-project --public --clone
|
||||||
|
|
||||||
|
# create a new remote repository in a different organization
|
||||||
|
gh repo create my-org/my-project --public
|
||||||
|
|
||||||
# create a remote repository from the current directory
|
# create a remote repository from the current directory
|
||||||
gh repo create my-project --private --source=. --remote=upstream
|
gh repo create my-project --private --source=. --remote=upstream
|
||||||
`),
|
`),
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,21 @@ func ExtractName(filePath string) string {
|
||||||
return path.Base(filePath)
|
return path.Base(filePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtractTitle returns the title of the template from YAML front-matter
|
||||||
|
func ExtractTitle(filePath string) string {
|
||||||
|
contents, err := os.ReadFile(filePath)
|
||||||
|
frontmatterBoundaries := detectFrontmatter(contents)
|
||||||
|
if err == nil && frontmatterBoundaries[0] == 0 {
|
||||||
|
templateData := struct {
|
||||||
|
Title string
|
||||||
|
}{}
|
||||||
|
if err := yaml.Unmarshal(contents[0:frontmatterBoundaries[1]], &templateData); err == nil && templateData.Title != "" {
|
||||||
|
return templateData.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractContents returns the template contents without the YAML front-matter
|
// ExtractContents returns the template contents without the YAML front-matter
|
||||||
func ExtractContents(filePath string) []byte {
|
func ExtractContents(filePath string) []byte {
|
||||||
contents, err := os.ReadFile(filePath)
|
contents, err := os.ReadFile(filePath)
|
||||||
|
|
|
||||||
|
|
@ -319,6 +319,67 @@ about: This is how you report bugs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractTitle(t *testing.T) {
|
||||||
|
tmpfile, err := os.CreateTemp(t.TempDir(), "gh-cli")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer tmpfile.Close()
|
||||||
|
|
||||||
|
type args struct {
|
||||||
|
filePath string
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prepare string
|
||||||
|
args args
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Complete front-matter",
|
||||||
|
prepare: `---
|
||||||
|
name: Bug Report
|
||||||
|
title: 'bug: '
|
||||||
|
about: This is how you report bugs
|
||||||
|
---
|
||||||
|
|
||||||
|
**Template contents**
|
||||||
|
`,
|
||||||
|
args: args{
|
||||||
|
filePath: tmpfile.Name(),
|
||||||
|
},
|
||||||
|
want: "bug: ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Incomplete front-matter",
|
||||||
|
prepare: `---
|
||||||
|
about: This is how you report bugs
|
||||||
|
---
|
||||||
|
`,
|
||||||
|
args: args{
|
||||||
|
filePath: tmpfile.Name(),
|
||||||
|
},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No front-matter",
|
||||||
|
prepare: `name: This is not yaml!`,
|
||||||
|
args: args{
|
||||||
|
filePath: tmpfile.Name(),
|
||||||
|
},
|
||||||
|
want: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_ = os.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600)
|
||||||
|
if got := ExtractTitle(tt.args.filePath); got != tt.want {
|
||||||
|
t.Errorf("ExtractTitle() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractContents(t *testing.T) {
|
func TestExtractContents(t *testing.T) {
|
||||||
tmpfile, err := os.CreateTemp(t.TempDir(), "gh-cli")
|
tmpfile, err := os.CreateTemp(t.TempDir(), "gh-cli")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
47
pkg/jsonfieldstest/jsonfieldstest.go
Normal file
47
pkg/jsonfieldstest/jsonfieldstest.go
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package jsonfieldstest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func jsonFieldsFor(cmd *cobra.Command) []string {
|
||||||
|
// This annotation is added by the `cmdutil.AddJSONFlags` function.
|
||||||
|
//
|
||||||
|
// This is an extremely janky way to get access to this information but due to the fact we pass
|
||||||
|
// around concrete cobra.Command structs, there's no viable way to have typed access to the fields.
|
||||||
|
//
|
||||||
|
// It's also kind of fragile because it's several hops away from the code that actually validates the usage
|
||||||
|
// of these flags, so it's possible for things to get out of sync.
|
||||||
|
stringFields, ok := cmd.Annotations["help:json-fields"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Split(stringFields, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCmdFunc represents the typical function signature we use for creating commands e.g. `NewCmdView`.
|
||||||
|
//
|
||||||
|
// It is generic over `T` as each command construction has their own Options type e.g. `ViewOptions`
|
||||||
|
type NewCmdFunc[T any] func(f *cmdutil.Factory, runF func(*T) error) *cobra.Command
|
||||||
|
|
||||||
|
// ExpectCommandToSupportJSONFields asserts that the provided command supports exactly the provided fields.
|
||||||
|
// Ordering of the expected fields is not important.
|
||||||
|
//
|
||||||
|
// Make sure you are not pointing to the same slice of fields in the test and the implementation.
|
||||||
|
// It can be a little tedious to rewrite the fields inline in the test but it's significantly more useful because:
|
||||||
|
// - It forces the test author to think about and convey exactly the expected fields for a command
|
||||||
|
// - It avoids accidentally adding fields to a command, and the test passing unintentionally
|
||||||
|
func ExpectCommandToSupportJSONFields[T any](t *testing.T, fn NewCmdFunc[T], expectedFields []string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
actualFields := jsonFieldsFor(fn(&cmdutil.Factory{}, nil))
|
||||||
|
assert.Equal(t, len(actualFields), len(expectedFields), "expected number of fields to match")
|
||||||
|
require.ElementsMatch(t, expectedFields, actualFields)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue