Merge branch 'trunk' into 8426-add-pr-update-cmd-no-local-update

This commit is contained in:
Babak K. Shandiz 2024-07-16 14:57:33 +01:00 committed by GitHub
commit c70479ac5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 883 additions and 292 deletions

View file

@ -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

View file

@ -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

View file

@ -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",

View file

@ -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()
} }

View file

@ -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

View file

@ -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 {

View file

@ -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
} }

View file

@ -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="
}
}
]
}

View 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
}

View file

@ -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")
})
} }

View file

@ -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
}

View file

@ -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)

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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)

View file

@ -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
}
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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) {

View file

@ -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

View file

@ -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

View file

@ -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
`), `),

View file

@ -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)

View file

@ -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 {

View 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)
}