Merge pull request #7193 from notomo/add-issue-create-editor

Add `issue create --editor`
This commit is contained in:
Andy Feller 2024-07-15 15:37:11 -04:00 committed by GitHub
commit 9b7ee3acef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 461 additions and 42 deletions

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

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

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

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