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
|
||||
- name: Attest release artifacts
|
||||
if: inputs.environment == 'production'
|
||||
uses: actions/attest-build-provenance@bdd51370e0416ac948727f861e03c2f05d32d78e # v1.3.2
|
||||
uses: actions/attest-build-provenance@5e9cb68e95676991667494a6a4e59b8a2f13e1d0 # v1.3.3
|
||||
with:
|
||||
subject-path: "dist/gh_*"
|
||||
- name: Run createrepo
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string, includeEvent bool) str
|
|||
}`), afterClause, prID, eventField)
|
||||
}
|
||||
|
||||
var IssueFields = []string{
|
||||
var sharedIssuePRFields = []string{
|
||||
"assignees",
|
||||
"author",
|
||||
"body",
|
||||
|
|
@ -268,10 +268,20 @@ var IssueFields = []string{
|
|||
"title",
|
||||
"updatedAt",
|
||||
"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",
|
||||
}
|
||||
|
||||
var PullRequestFields = append(IssueFields,
|
||||
var IssueFields = append(sharedIssuePRFields, issueOnlyFields...)
|
||||
|
||||
var PullRequestFields = append(sharedIssuePRFields,
|
||||
"additions",
|
||||
"autoMergeRequest",
|
||||
"baseRefName",
|
||||
|
|
@ -299,12 +309,6 @@ var PullRequestFields = append(IssueFields,
|
|||
"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.
|
||||
func IssueGraphQL(fields []string) string {
|
||||
var q []string
|
||||
|
|
|
|||
|
|
@ -15,18 +15,19 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
aliasesKey = "aliases"
|
||||
browserKey = "browser"
|
||||
editorKey = "editor"
|
||||
gitProtocolKey = "git_protocol"
|
||||
hostsKey = "hosts"
|
||||
httpUnixSocketKey = "http_unix_socket"
|
||||
oauthTokenKey = "oauth_token"
|
||||
pagerKey = "pager"
|
||||
promptKey = "prompt"
|
||||
userKey = "user"
|
||||
usersKey = "users"
|
||||
versionKey = "version"
|
||||
aliasesKey = "aliases"
|
||||
browserKey = "browser"
|
||||
editorKey = "editor"
|
||||
gitProtocolKey = "git_protocol"
|
||||
hostsKey = "hosts"
|
||||
httpUnixSocketKey = "http_unix_socket"
|
||||
oauthTokenKey = "oauth_token"
|
||||
pagerKey = "pager"
|
||||
promptKey = "prompt"
|
||||
preferEditorPromptKey = "prefer_editor_prompt"
|
||||
userKey = "user"
|
||||
usersKey = "users"
|
||||
versionKey = "version"
|
||||
)
|
||||
|
||||
func NewConfig() (gh.Config, error) {
|
||||
|
|
@ -137,6 +138,11 @@ func (c *cfg) Prompt(hostname string) gh.ConfigEntry {
|
|||
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] {
|
||||
return c.get("", versionKey)
|
||||
}
|
||||
|
|
@ -509,6 +515,8 @@ git_protocol: https
|
|||
editor:
|
||||
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
|
||||
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.
|
||||
pager:
|
||||
# Aliases allow you to create nicknames for gh commands
|
||||
|
|
@ -555,6 +563,15 @@ var Options = []ConfigOption{
|
|||
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,
|
||||
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 {
|
||||
return cfg.Prompt(hostname)
|
||||
}
|
||||
mock.PreferEditorPromptFunc = func(hostname string) gh.ConfigEntry {
|
||||
return cfg.PreferEditorPrompt(hostname)
|
||||
}
|
||||
mock.VersionFunc = func() o.Option[string] {
|
||||
return cfg.Version()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ type Config interface {
|
|||
Pager(hostname string) ConfigEntry
|
||||
// Prompt returns the configured prompt, optionally scoped by host.
|
||||
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() AliasConfig
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ var _ gh.Config = &ConfigMock{}
|
|||
// PagerFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Pager method")
|
||||
// },
|
||||
// PreferEditorPromptFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the PreferEditorPrompt method")
|
||||
// },
|
||||
// PromptFunc: func(hostname string) gh.ConfigEntry {
|
||||
// panic("mock out the Prompt method")
|
||||
// },
|
||||
|
|
@ -98,6 +101,9 @@ type ConfigMock struct {
|
|||
// PagerFunc mocks the Pager method.
|
||||
PagerFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// PreferEditorPromptFunc mocks the PreferEditorPrompt method.
|
||||
PreferEditorPromptFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
// PromptFunc mocks the Prompt method.
|
||||
PromptFunc func(hostname string) gh.ConfigEntry
|
||||
|
||||
|
|
@ -158,6 +164,11 @@ type ConfigMock struct {
|
|||
// Hostname is the hostname argument value.
|
||||
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 []struct {
|
||||
// Hostname is the hostname argument value.
|
||||
|
|
@ -179,20 +190,21 @@ type ConfigMock struct {
|
|||
Write []struct {
|
||||
}
|
||||
}
|
||||
lockAliases sync.RWMutex
|
||||
lockAuthentication sync.RWMutex
|
||||
lockBrowser sync.RWMutex
|
||||
lockCacheDir sync.RWMutex
|
||||
lockEditor sync.RWMutex
|
||||
lockGetOrDefault sync.RWMutex
|
||||
lockGitProtocol sync.RWMutex
|
||||
lockHTTPUnixSocket sync.RWMutex
|
||||
lockMigrate sync.RWMutex
|
||||
lockPager sync.RWMutex
|
||||
lockPrompt sync.RWMutex
|
||||
lockSet sync.RWMutex
|
||||
lockVersion sync.RWMutex
|
||||
lockWrite sync.RWMutex
|
||||
lockAliases sync.RWMutex
|
||||
lockAuthentication sync.RWMutex
|
||||
lockBrowser sync.RWMutex
|
||||
lockCacheDir sync.RWMutex
|
||||
lockEditor sync.RWMutex
|
||||
lockGetOrDefault sync.RWMutex
|
||||
lockGitProtocol sync.RWMutex
|
||||
lockHTTPUnixSocket sync.RWMutex
|
||||
lockMigrate sync.RWMutex
|
||||
lockPager sync.RWMutex
|
||||
lockPreferEditorPrompt sync.RWMutex
|
||||
lockPrompt sync.RWMutex
|
||||
lockSet sync.RWMutex
|
||||
lockVersion sync.RWMutex
|
||||
lockWrite sync.RWMutex
|
||||
}
|
||||
|
||||
// Aliases calls AliasesFunc.
|
||||
|
|
@ -504,6 +516,38 @@ func (mock *ConfigMock) PagerCalls() []struct {
|
|||
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.
|
||||
func (mock *ConfigMock) Prompt(hostname string) gh.ConfigEntry {
|
||||
if mock.PromptFunc == nil {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"github.com/MakeNowJust/heredoc"
|
||||
"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/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/cmdutil"
|
||||
|
||||
|
|
@ -24,7 +24,7 @@ func NewCmdAttestation(f *cmdutil.Factory) *cobra.Command {
|
|||
root.AddCommand(download.NewDownloadCmd(f, nil))
|
||||
root.AddCommand(inspect.NewInspectCmd(f, nil))
|
||||
root.AddCommand(verify.NewVerifyCmd(f, nil))
|
||||
root.AddCommand(tufrootverify.NewTUFRootVerifyCmd(f, nil))
|
||||
root.AddCommand(trustedroot.NewTrustedRootCmd(f, nil))
|
||||
|
||||
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 (
|
||||
"bytes"
|
||||
|
|
@ -6,16 +6,16 @@ import (
|
|||
"strings"
|
||||
"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/cmdutil"
|
||||
"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()
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: testIO,
|
||||
|
|
@ -27,29 +27,37 @@ func TestNewTUFRootVerifyCmd(t *testing.T) {
|
|||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "Missing mirror flag",
|
||||
cli: "--root ../verification/embed/tuf-repo.github.com/root.json",
|
||||
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",
|
||||
name: "Happy path",
|
||||
cli: "",
|
||||
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 {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd := NewTUFRootVerifyCmd(f, func() error {
|
||||
cmd := NewTrustedRootCmd(f, func(_ *Options) error {
|
||||
return nil
|
||||
})
|
||||
|
||||
argv := strings.Split(tc.cli, " ")
|
||||
argv := []string{}
|
||||
if tc.cli != "" {
|
||||
argv = strings.Split(tc.cli, " ")
|
||||
}
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&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) {
|
||||
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"
|
||||
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) {
|
||||
err := tufRootVerify(newTUFMockClient, mirror, root)
|
||||
err := getTrustedRoot(tuf.New, opts)
|
||||
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) {
|
||||
notFoundRoot := test.NormalizeRelativePath("./does/not/exist/root.json")
|
||||
err := tufRootVerify(newTUFMockClient, mirror, notFoundRoot)
|
||||
opts.TufRootPath = test.NormalizeRelativePath("./does/not/exist/root.json")
|
||||
err := getTrustedRoot(tuf.New, opts)
|
||||
require.Error(t, err)
|
||||
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
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/api"
|
||||
"github.com/cli/cli/v2/pkg/cmd/attestation/io"
|
||||
|
|
@ -29,9 +33,9 @@ type SigstoreResults struct {
|
|||
}
|
||||
|
||||
type SigstoreConfig struct {
|
||||
CustomTrustedRoot string
|
||||
Logger *io.Handler
|
||||
NoPublicGood bool
|
||||
TrustedRoot string
|
||||
Logger *io.Handler
|
||||
NoPublicGood bool
|
||||
}
|
||||
|
||||
type SigstoreVerifier interface {
|
||||
|
|
@ -65,13 +69,68 @@ func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.ProtobufBundle) (*verify
|
|||
}
|
||||
issuer := leafCert.Issuer.Organization[0]
|
||||
|
||||
// if user provided a custom trusted root file path, use the custom verifier
|
||||
if v.config.CustomTrustedRoot != "" {
|
||||
customVerifier, err := newCustomVerifier(v.config.CustomTrustedRoot)
|
||||
if v.config.TrustedRoot != "" {
|
||||
customTrustRoots, err := os.ReadFile(v.config.TrustedRoot)
|
||||
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 {
|
||||
|
|
@ -93,6 +152,18 @@ func (v *LiveSigstoreVerifier) chooseVerifier(b *bundle.ProtobufBundle) (*verify
|
|||
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 {
|
||||
// initialize the processing results before attempting to verify
|
||||
// with multiple verifiers
|
||||
|
|
@ -143,21 +214,17 @@ func (v *LiveSigstoreVerifier) Verify(attestations []*api.Attestation, policy ve
|
|||
}
|
||||
}
|
||||
|
||||
func newCustomVerifier(trustedRootFilePath string) (*verify.SignedEntityVerifier, error) {
|
||||
trustedRoot, err := root.NewTrustedRootFromPath(trustedRootFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create trusted root from file %s: %v", trustedRootFilePath, err)
|
||||
}
|
||||
|
||||
func newCustomVerifier(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
|
||||
// All we know about this trust root is its configuration so make some
|
||||
// educated guesses as to what the policy should be.
|
||||
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))
|
||||
|
||||
// Infer verification options from contents of trusted root
|
||||
if len(trustedRoot.TimestampingAuthorities()) > 0 {
|
||||
verifierConfig = append(verifierConfig, verify.WithSignedTimestamps(1))
|
||||
}
|
||||
|
||||
if len(trustedRoot.RekorLogs()) > 0 {
|
||||
verifierConfig = append(verifierConfig, verify.WithTransparencyLog(1))
|
||||
}
|
||||
|
|
@ -180,6 +247,10 @@ func newGitHubVerifier() (*verify.SignedEntityVerifier, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newGitHubVerifierWithTrustedRoot(trustedRoot)
|
||||
}
|
||||
|
||||
func newGitHubVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
|
||||
gv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedTimestamps(1))
|
||||
if err != nil {
|
||||
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 newPublicGoodVerifierWithTrustedRoot(trustedRoot)
|
||||
}
|
||||
|
||||
func newPublicGoodVerifierWithTrustedRoot(trustedRoot *root.TrustedRoot) (*verify.SignedEntityVerifier, error) {
|
||||
sv, err := verify.NewSignedEntityVerifier(trustedRoot, verify.WithSignedCertificateTimestamps(1), verify.WithTransparencyLog(1), verify.WithObserverTimestamps(1))
|
||||
if err != nil {
|
||||
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")
|
||||
|
||||
verifier := NewLiveSigstoreVerifier(SigstoreConfig{
|
||||
Logger: io.NewTestHandler(),
|
||||
CustomTrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
|
||||
Logger: io.NewTestHandler(),
|
||||
TrustedRoot: test.NormalizeRelativePath("../test/data/trusted_root.json"),
|
||||
})
|
||||
|
||||
res := verifier.Verify(attestations, publicGoodPolicy(t))
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ type Options struct {
|
|||
ArtifactPath string
|
||||
BundlePath string
|
||||
Config func() (gh.Config, error)
|
||||
CustomTrustedRoot string
|
||||
TrustedRoot string
|
||||
DenySelfHostedRunner bool
|
||||
DigestAlgorithm string
|
||||
Limit int
|
||||
|
|
|
|||
|
|
@ -132,9 +132,9 @@ func NewVerifyCmd(f *cmdutil.Factory, runF func(*Options) error) *cobra.Command
|
|||
}
|
||||
|
||||
config := verification.SigstoreConfig{
|
||||
CustomTrustedRoot: opts.CustomTrustedRoot,
|
||||
Logger: opts.Logger,
|
||||
NoPublicGood: opts.NoPublicGood,
|
||||
TrustedRoot: opts.TrustedRoot,
|
||||
Logger: opts.Logger,
|
||||
NoPublicGood: opts.NoPublicGood,
|
||||
}
|
||||
|
||||
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.MarkFlagsOneRequired("owner", "repo")
|
||||
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().StringVarP(&opts.CustomTrustedRoot, "custom-trusted-root", "", "", "Path to a custom trustedroot.json file to use for verification")
|
||||
verifyCmd.Flags().BoolVarP(&opts.NoPublicGood, "no-public-good", "", false, "Do not verify attestations signed with Sigstore public good instance")
|
||||
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")
|
||||
cmdutil.AddFormatFlags(verifyCmd, &opts.exporter)
|
||||
// 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.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.DigestAlgorithm, opts.DigestAlgorithm)
|
||||
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", "editor", "/usr/bin/vim")
|
||||
cfg.Set("HOST", "prompt", "disabled")
|
||||
cfg.Set("HOST", "prefer_editor_prompt", "enabled")
|
||||
cfg.Set("HOST", "pager", "less")
|
||||
cfg.Set("HOST", "http_unix_socket", "")
|
||||
cfg.Set("HOST", "browser", "brave")
|
||||
|
|
@ -93,6 +94,7 @@ func Test_listRun(t *testing.T) {
|
|||
stdout: `git_protocol=ssh
|
||||
editor=/usr/bin/vim
|
||||
prompt=disabled
|
||||
prefer_editor_prompt=enabled
|
||||
pager=less
|
||||
http_unix_socket=
|
||||
browser=brave
|
||||
|
|
|
|||
|
|
@ -18,16 +18,18 @@ import (
|
|||
)
|
||||
|
||||
type CreateOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Browser browser.Browser
|
||||
Prompter prShared.Prompt
|
||||
HttpClient func() (*http.Client, error)
|
||||
Config func() (gh.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Browser browser.Browser
|
||||
Prompter prShared.Prompt
|
||||
TitledEditSurvey func(string, string) (string, string, error)
|
||||
|
||||
RootDirOverride string
|
||||
|
||||
HasRepoOverride bool
|
||||
EditorMode bool
|
||||
WebMode bool
|
||||
RecoverFile string
|
||||
|
||||
|
|
@ -44,11 +46,12 @@ type CreateOptions struct {
|
|||
|
||||
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
||||
opts := &CreateOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Config: f.Config,
|
||||
Browser: f.Browser,
|
||||
Prompter: f.Prompter,
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Config: f.Config,
|
||||
Browser: f.Browser,
|
||||
Prompter: f.Prompter,
|
||||
TitledEditSurvey: prShared.TitledEditSurvey(&prShared.UserEditor{Config: f.Config, IO: f.IOStreams}),
|
||||
}
|
||||
|
||||
var bodyFile string
|
||||
|
|
@ -77,6 +80,20 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
opts.BaseRepo = f.BaseRepo
|
||||
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")
|
||||
bodyProvided := cmd.Flags().Changed("body")
|
||||
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`")
|
||||
}
|
||||
|
||||
opts.Interactive = !(titleProvided && bodyProvided)
|
||||
opts.Interactive = !opts.EditorMode && !(titleProvided && bodyProvided)
|
||||
|
||||
if opts.Interactive && !opts.IO.CanPrompt() {
|
||||
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 {
|
||||
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.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().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().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`")
|
||||
|
|
@ -285,6 +306,25 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
return
|
||||
}
|
||||
} 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 == "" {
|
||||
err = fmt.Errorf("title can't be blank")
|
||||
return
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
tty bool
|
||||
stdin string
|
||||
cli string
|
||||
config string
|
||||
wantsErr bool
|
||||
wantsOpts CreateOptions
|
||||
}{
|
||||
|
|
@ -125,6 +126,77 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
cli: `-t mytitle --template "bug report" --body-file "body_file.md"`,
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -138,6 +210,12 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
Config: func() (gh.Config, error) {
|
||||
if tt.config != "" {
|
||||
return config.NewFromString(tt.config), nil
|
||||
}
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
}
|
||||
|
||||
var opts *CreateOptions
|
||||
|
|
@ -310,6 +388,72 @@ func Test_createRun(t *testing.T) {
|
|||
},
|
||||
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 {
|
||||
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/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/jsonfieldstest"
|
||||
"github.com/cli/cli/v2/test"
|
||||
"github.com/google/shlex"
|
||||
"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) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(isTTY)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,11 @@ import (
|
|||
"strings"
|
||||
|
||||
"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/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
)
|
||||
|
||||
type Action int
|
||||
|
|
@ -317,3 +320,40 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
|
||||
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{"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 {
|
||||
Gname string `graphql:"name"`
|
||||
Gbody string `graphql:"body"`
|
||||
Gname string `graphql:"name"`
|
||||
Gbody string `graphql:"body"`
|
||||
Gtitle string `graphql:"title"`
|
||||
}
|
||||
|
||||
type pullRequestTemplate struct {
|
||||
|
|
@ -37,6 +38,10 @@ func (t *issueTemplate) Body() []byte {
|
|||
return []byte(t.Gbody)
|
||||
}
|
||||
|
||||
func (t *issueTemplate) Title() string {
|
||||
return t.Gtitle
|
||||
}
|
||||
|
||||
func (t *pullRequestTemplate) Name() string {
|
||||
return t.Gname
|
||||
}
|
||||
|
|
@ -49,6 +54,10 @@ func (t *pullRequestTemplate) Body() []byte {
|
|||
return []byte(t.Gbody)
|
||||
}
|
||||
|
||||
func (t *pullRequestTemplate) Title() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func listIssueTemplates(httpClient *http.Client, repo ghrepo.Interface) ([]Template, error) {
|
||||
var query struct {
|
||||
Repository struct {
|
||||
|
|
@ -109,6 +118,7 @@ type Template interface {
|
|||
Name() string
|
||||
NameForSubmit() string
|
||||
Body() []byte
|
||||
Title() string
|
||||
}
|
||||
|
||||
type iprompter interface {
|
||||
|
|
@ -294,3 +304,7 @@ func (t *filesystemTemplate) NameForSubmit() string {
|
|||
func (t *filesystemTemplate) Body() []byte {
|
||||
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.StringResponse(`{"data":{"repository":{
|
||||
"issueTemplates": [
|
||||
{"name": "Bug report", "body": "I found a problem"},
|
||||
{"name": "Feature request", "body": "I need a feature"}
|
||||
{"name": "Bug report", "body": "I found a problem", "title": "bug: "},
|
||||
{"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.Equal(t, "Feature request", tpl.NameForSubmit())
|
||||
assert.Equal(t, "I need a feature", string(tpl.Body()))
|
||||
assert.Equal(t, "request: ", tpl.Title())
|
||||
}
|
||||
|
||||
func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
||||
|
|
@ -112,6 +113,7 @@ func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", tpl.NameForSubmit())
|
||||
assert.Equal(t, "I fixed a problem", string(tpl.Body()))
|
||||
assert.Equal(t, "", tpl.Title())
|
||||
}
|
||||
|
||||
func TestTemplateManagerSelect(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -18,12 +18,61 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/jsonfieldstest"
|
||||
"github.com/cli/cli/v2/test"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
Use release notes from a file
|
||||
$ 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
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
|
@ -99,6 +102,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
# create a new remote repository and clone it locally
|
||||
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
|
||||
gh repo create my-project --private --source=. --remote=upstream
|
||||
`),
|
||||
|
|
|
|||
|
|
@ -98,6 +98,21 @@ func ExtractName(filePath string) string {
|
|||
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
|
||||
func ExtractContents(filePath string) []byte {
|
||||
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) {
|
||||
tmpfile, err := os.CreateTemp(t.TempDir(), "gh-cli")
|
||||
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