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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

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

View file

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

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) {
tmpfile, err := os.CreateTemp(t.TempDir(), "gh-cli")
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)
}