From 5defc15ca11a53eac97dd45a2d72a3ae07e4621c Mon Sep 17 00:00:00 2001 From: Billy Keyes Date: Sat, 6 Jan 2024 23:28:05 -0800 Subject: [PATCH 1/7] Add permission warning on 404 errors --- cmd/patch2pr/main.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmd/patch2pr/main.go b/cmd/patch2pr/main.go index 1e298e2..7e2b1ce 100644 --- a/cmd/patch2pr/main.go +++ b/cmd/patch2pr/main.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "io/ioutil" + "net/http" "net/url" "os" "strings" @@ -22,6 +23,18 @@ import ( func die(code int, err error) { fmt.Fprintln(os.Stderr, "error:", err) + + var rerr *github.ErrorResponse + if errors.As(err, &rerr) && rerr.Response.StatusCode == http.StatusNotFound { + fmt.Fprint(os.Stderr, ` +This may be because the repository does not exit or the token you are using +does not have write permission. If submitting a patch to a repository where you +do not have write access, consider using the -fork flag to submit the patch +from a fork. +`, + ) + } + os.Exit(code) } From 7dab92a73164890af54f6fffcd0df9776689e3d3 Mon Sep 17 00:00:00 2001 From: Billy Keyes Date: Sat, 6 Jan 2024 23:28:22 -0800 Subject: [PATCH 2/7] Define -fork and -fork-repository flags --- README.md | 59 +++++++++++++++------------ cmd/patch2pr/main.go | 92 +++++++++++++++++++++++++----------------- cmd/patch2pr/values.go | 20 +++++++++ 3 files changed, 108 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 78e0dc6..6f20d1e 100644 --- a/README.md +++ b/README.md @@ -66,43 +66,52 @@ Usage: patch2pr [options] [patch] Options: - -base-branch=branch The branch to target with the pull request. If unset, - use the repository's default branch. + -base-branch=branch The branch to target with the pull request. If unset, + use the repository's default branch. - -draft Create a draft pull request. + -draft Create a draft pull request. - -force Update the head branch even if it exists and is not a - fast-forward. + -force Update the head branch even if it exists and is not a + fast-forward. - -head-branch=branch The branch to create or update with the new commit. If - unset, use 'patch2pr'. + -fork Submit the pull request from a fork instead of pushing + directly to the repository. With no other flags, use a + fork in the current account with the same name as the + target repository, creating the fork if it does not exist. - -json Output information about the new commit and pull request - in JSON format. + -fork-repository=repo Submit the pull request from the named fork instead of + pushing directly to the repository, creating the fork + if it does not exist. Implies the -fork flag. - -message=message Message for the commit. Overrides the patch header. + -head-branch=branch The branch to create or update with the new commit. If + unset, use 'patch2pr'. - -no-pull-request Do not create a pull request after creating a commit. + -json Output information about the new commit and pull request + in JSON format. - -patch-base=base Base commit to apply the patch to. Can be a SHA1, a - branch, or a tag. Branches and tags must start with - 'refs/heads/' or 'refs/tags/' respectively. If unset, - use the repository's default branch. + -message=message Message for the commit. Overrides the patch header. - -pull-body=body The body for the pull request. If unset, use the body of - the commit message. + -no-pull-request Do not create a pull request after creating a commit. - -pull-title=title The title for the pull request. If unset, use the title - of the commit message. + -patch-base=base Base commit to apply the patch to. Can be a SHA1, a + branch, or a tag. Branches and tags must start with + 'refs/heads/' or 'refs/tags/' respectively. If unset, + use the repository's default branch. - -repository=repo Repository to apply the patch to in 'owner/name' format. - Required. + -pull-body=body The body for the pull request. If unset, use the body of + the commit message. - -token=token GitHub API token with 'repo' scope for authentication. - If unset, use the value of the GITHUB_TOKEN environment - variable. + -pull-title=title The title for the pull request. If unset, use the title + of the commit message. - -url=url GitHub API URL. If unset, use https://api.github.com. + -repository=repo Repository to apply the patch to in 'owner/name' format. + Required. + + -token=token GitHub API token with 'repo' scope for authentication. + If unset, use the value of the GITHUB_TOKEN environment + variable. + + -url=url GitHub API URL. If unset, use https://api.github.com. ``` ## Usage: Library diff --git a/cmd/patch2pr/main.go b/cmd/patch2pr/main.go index 7e2b1ce..ead27d4 100644 --- a/cmd/patch2pr/main.go +++ b/cmd/patch2pr/main.go @@ -39,19 +39,21 @@ from a fork. } type Options struct { - BaseBranch string - Draft bool - Force bool - HeadBranch string - OutputJSON bool - Message string - NoPullRequest bool - PatchBase string - PullTitle string - Repository *patch2pr.Repository - GitHubToken string - GitHubURL *url.URL - PullBody string + BaseBranch string + Draft bool + Force bool + Fork bool + ForkRepository *patch2pr.Repository + HeadBranch string + OutputJSON bool + Message string + NoPullRequest bool + PatchBase string + PullTitle string + Repository *patch2pr.Repository + GitHubToken string + GitHubURL *url.URL + PullBody string } func main() { @@ -66,6 +68,8 @@ func main() { fs.StringVar(&opts.BaseBranch, "base-branch", "", "base-branch") fs.BoolVar(&opts.Draft, "draft", false, "draft") fs.BoolVar(&opts.Force, "force", false, "force") + fs.BoolVar(&opts.Fork, "fork", false, "fork") + fs.Var(ForkValue{RepositoryValue{&opts.ForkRepository}, &opts.Fork}, "fork-repository", "fork-repository") fs.StringVar(&opts.HeadBranch, "head-branch", "patch2pr", "head-branch") fs.BoolVar(&opts.OutputJSON, "json", false, "json") fs.StringVar(&opts.Message, "message", "", "message") @@ -85,6 +89,9 @@ func main() { die(2, err) } + fmt.Println("fork:", opts.Fork) + fmt.Println("fork-repository:", opts.ForkRepository) + if opts.Repository == nil { die(2, errors.New("the -repository flag is required")) } @@ -358,43 +365,52 @@ Usage: patch2pr [options] [patch] Options: - -base-branch=branch The branch to target with the pull request. If unset, - use the repository's default branch. + -base-branch=branch The branch to target with the pull request. If unset, + use the repository's default branch. + + -draft Create a draft pull request. + + -force Update the head branch even if it exists and is not a + fast-forward. - -draft Create a draft pull request. + -fork Submit the pull request from a fork instead of pushing + directly to the repository. With no other flags, use a + fork in the current account with the same name as the + target repository, creating the fork if it does not exist. - -force Update the head branch even if it exists and is not a - fast-forward. + -fork-repository=repo Submit the pull request from the named fork instead of + pushing directly to the repository, creating the fork + if it does not exist. Implies the -fork flag. - -head-branch=branch The branch to create or update with the new commit. If - unset, use 'patch2pr'. + -head-branch=branch The branch to create or update with the new commit. If + unset, use 'patch2pr'. - -json Output information about the new commit and pull request - in JSON format. + -json Output information about the new commit and pull request + in JSON format. - -message=message Message for the commit. Overrides the patch header. + -message=message Message for the commit. Overrides the patch header. - -no-pull-request Do not create a pull request after creating a commit. + -no-pull-request Do not create a pull request after creating a commit. - -patch-base=base Base commit to apply the patch to. Can be a SHA1, a - branch, or a tag. Branches and tags must start with - 'refs/heads/' or 'refs/tags/' respectively. If unset, - use the repository's default branch. + -patch-base=base Base commit to apply the patch to. Can be a SHA1, a + branch, or a tag. Branches and tags must start with + 'refs/heads/' or 'refs/tags/' respectively. If unset, + use the repository's default branch. - -pull-body=body The body for the pull request. If unset, use the body of - the commit message. + -pull-body=body The body for the pull request. If unset, use the body of + the commit message. - -pull-title=title The title for the pull request. If unset, use the title - of the commit message. + -pull-title=title The title for the pull request. If unset, use the title + of the commit message. - -repository=repo Repository to apply the patch to in 'owner/name' format. - Required. + -repository=repo Repository to apply the patch to in 'owner/name' format. + Required. - -token=token GitHub API token with 'repo' scope for authentication. - If unset, use the value of the GITHUB_TOKEN environment - variable. + -token=token GitHub API token with 'repo' scope for authentication. + If unset, use the value of the GITHUB_TOKEN environment + variable. - -url=url GitHub API URL. If unset, use https://api.github.com. + -url=url GitHub API URL. If unset, use https://api.github.com. ` return strings.TrimSpace(help) diff --git a/cmd/patch2pr/values.go b/cmd/patch2pr/values.go index c010fad..0a9838c 100644 --- a/cmd/patch2pr/values.go +++ b/cmd/patch2pr/values.go @@ -49,3 +49,23 @@ func (v URLValue) Set(s string) error { *v.u = u return nil } + +type ForkValue struct { + RepositoryValue + enabled *bool +} + +func (v ForkValue) String() string { + if v.enabled == nil || !*v.enabled { + return "" + } + return v.RepositoryValue.String() +} + +func (v ForkValue) Set(s string) error { + if err := v.RepositoryValue.Set(s); err != nil { + return err + } + *v.enabled = true + return nil +} From e8c7c01c178476c2943dd5e5021e6362c712d211 Mon Sep 17 00:00:00 2001 From: Billy Keyes Date: Sun, 7 Jan 2024 22:23:26 -0800 Subject: [PATCH 3/7] Add support for submitting PRs from forks --- cmd/patch2pr/main.go | 124 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 10 deletions(-) diff --git a/cmd/patch2pr/main.go b/cmd/patch2pr/main.go index ead27d4..b50d57c 100644 --- a/cmd/patch2pr/main.go +++ b/cmd/patch2pr/main.go @@ -24,8 +24,7 @@ import ( func die(code int, err error) { fmt.Fprintln(os.Stderr, "error:", err) - var rerr *github.ErrorResponse - if errors.As(err, &rerr) && rerr.Response.StatusCode == http.StatusNotFound { + if isNotFound(err) { fmt.Fprint(os.Stderr, ` This may be because the repository does not exit or the token you are using does not have write permission. If submitting a patch to a repository where you @@ -150,11 +149,11 @@ type PullRequestResult struct { } func execute(ctx context.Context, client *github.Client, patchFile string, opts *Options) (*Result, error) { - repo := *opts.Repository + targetRepo := *opts.Repository patchBase, baseBranch, headBranch := opts.PatchBase, opts.BaseBranch, opts.HeadBranch if patchBase == "" || (baseBranch == "" && !opts.NoPullRequest) { - r, _, err := client.Repositories.Get(ctx, repo.Owner, repo.Name) + r, _, err := client.Repositories.Get(ctx, targetRepo.Owner, targetRepo.Name) if err != nil { return nil, fmt.Errorf("get repository failed: %w", err) } @@ -167,14 +166,14 @@ func execute(ctx context.Context, client *github.Client, patchFile string, opts } if strings.HasPrefix(patchBase, "refs/") { - ref, _, err := client.Git.GetRef(ctx, repo.Owner, repo.Name, strings.TrimPrefix(patchBase, "refs/")) + ref, _, err := client.Git.GetRef(ctx, targetRepo.Owner, targetRepo.Name, strings.TrimPrefix(patchBase, "refs/")) if err != nil { return nil, fmt.Errorf("get ref for patch base %q failed: %w", patchBase, err) } patchBase = ref.GetObject().GetSHA() } - commit, _, err := client.Git.GetCommit(ctx, repo.Owner, repo.Name, patchBase) + commit, _, err := client.Git.GetCommit(ctx, targetRepo.Owner, targetRepo.Name, patchBase) if err != nil { return nil, fmt.Errorf("get commit for %s failed: %w", patchBase, err) } @@ -204,7 +203,12 @@ func execute(ctx context.Context, client *github.Client, patchFile string, opts } } - applier := patch2pr.NewApplier(client, repo, commit) + sourceRepo, err := prepareSourceRepo(ctx, client, opts) + if err != nil { + return nil, err + } + + applier := patch2pr.NewApplier(client, sourceRepo, commit) for _, file := range files { if _, err := applier.Apply(ctx, file); err != nil { name := file.NewName @@ -220,7 +224,7 @@ func execute(ctx context.Context, client *github.Client, patchFile string, opts return nil, fmt.Errorf("commit failed: %w", err) } - ref := patch2pr.NewReference(client, repo, fmt.Sprintf("refs/heads/%s", headBranch)) + ref := patch2pr.NewReference(client, sourceRepo, fmt.Sprintf("refs/heads/%s", headBranch)) if err := ref.Set(ctx, newCommit.GetSHA(), opts.Force); err != nil { return nil, fmt.Errorf("set ref failed: %w", err) } @@ -242,12 +246,21 @@ func execute(ctx context.Context, client *github.Client, patchFile string, opts body = opts.PullBody } - if pr, err = ref.PullRequest(ctx, &github.NewPullRequest{ + prSpec := &github.NewPullRequest{ Title: &title, Body: &body, Base: &baseBranch, Draft: &opts.Draft, - }); err != nil { + } + + if sourceRepo == targetRepo { + prSpec.Head = &headBranch + } else { + prSpec.Head = github.String(fmt.Sprintf("%s:%s", sourceRepo.Owner, headBranch)) + prSpec.HeadRepo = &sourceRepo.Name + } + + if pr, _, err = client.PullRequests.Create(ctx, targetRepo.Owner, targetRepo.Name, prSpec); err != nil { return nil, fmt.Errorf("create pull request failed: %w", err) } } @@ -265,6 +278,92 @@ func execute(ctx context.Context, client *github.Client, patchFile string, opts return res, nil } +func prepareSourceRepo(ctx context.Context, client *github.Client, opts *Options) (patch2pr.Repository, error) { + source := patch2pr.Repository{} + target := *opts.Repository + + if !opts.Fork { + // If we're not using a fork, the source is the same as the target + return target, nil + } + + user, _, err := client.Users.Get(ctx, "") + if err != nil { + return source, fmt.Errorf("get user failed: %w", err) + } + + if opts.ForkRepository != nil { + source = *opts.ForkRepository + } else { + source = patch2pr.Repository{ + Owner: user.GetLogin(), + Name: target.Name, + } + } + + repo, _, err := client.Repositories.Get(ctx, source.Owner, source.Name) + switch { + case isNotFound(err): + isUserFork := user.GetLogin() == source.Owner + if err := createFork(ctx, client, source, target, isUserFork); err != nil { + return source, fmt.Errorf("forking repository failed: %w", err) + } + + case err != nil: + return source, fmt.Errorf("get fork repository failed: %w", err) + + default: + if !repo.GetFork() || repo.GetParent().GetFullName() != target.String() { + return source, fmt.Errorf("fork repository %q exists, but is not a fork of %q", source, target) + } + } + + return source, nil +} + +func createFork(ctx context.Context, client *github.Client, fork, parent patch2pr.Repository, isUserFork bool) error { + const pollInterval = 5 * time.Second + const maxWait = 1 * time.Minute + + organization := fork.Owner + if isUserFork { + organization = "" + } + + repo, _, err := client.Repositories.CreateFork(ctx, parent.Owner, parent.Name, &github.RepositoryCreateForkOptions{ + Organization: organization, + Name: fork.Name, + DefaultBranchOnly: true, + }) + + var aerr *github.AcceptedError + if err != nil && !errors.As(err, &aerr) { + return err + } + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + ctx, cancel := context.WithTimeout(ctx, maxWait) + defer cancel() + + // Poll the new repo until the default branch exists, indicating it is ready to use + ref := "heads/" + repo.GetDefaultBranch() + for { + select { + case <-ticker.C: + if _, _, err := client.Git.GetRef(ctx, fork.Owner, fork.Name, ref); err == nil { + return nil + } else if !isNotFound(err) && !errors.Is(err, context.DeadlineExceeded) { + fmt.Fprintf(os.Stderr, "warning: error while waiting for fork to be ready, will try again: %v", err) + } + + case <-ctx.Done(): + return fmt.Errorf("fork repository was not ready after %s", maxWait) + } + } +} + func splitMessage(m string) (title string, body string) { s := bufio.NewScanner(strings.NewReader(m)) @@ -339,6 +438,11 @@ func dateFromEnv(dateType string) time.Time { return time.Time{} } +func isNotFound(err error) bool { + var rerr *github.ErrorResponse + return errors.As(err, &rerr) && rerr.Response.StatusCode == http.StatusNotFound +} + func helpText() string { help := ` Usage: patch2pr [options] [patch] From e9773669162738dacc355ebbadb6c214c013732e Mon Sep 17 00:00:00 2001 From: Billy Keyes Date: Sun, 7 Jan 2024 22:32:09 -0800 Subject: [PATCH 4/7] Fix type and remove debug code --- cmd/patch2pr/main.go | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/cmd/patch2pr/main.go b/cmd/patch2pr/main.go index b50d57c..e55f78a 100644 --- a/cmd/patch2pr/main.go +++ b/cmd/patch2pr/main.go @@ -26,7 +26,7 @@ func die(code int, err error) { if isNotFound(err) { fmt.Fprint(os.Stderr, ` -This may be because the repository does not exit or the token you are using +This may be because the repository does not exist or the token you are using does not have write permission. If submitting a patch to a repository where you do not have write access, consider using the -fork flag to submit the patch from a fork. @@ -88,9 +88,6 @@ func main() { die(2, err) } - fmt.Println("fork:", opts.Fork) - fmt.Println("fork-repository:", opts.ForkRepository) - if opts.Repository == nil { die(2, errors.New("the -repository flag is required")) } From 6a0584fe40767ed7ccf64c95145ab90372fd38a3 Mon Sep 17 00:00:00 2001 From: Billy Keyes Date: Mon, 8 Jan 2024 20:37:51 -0800 Subject: [PATCH 5/7] Improve behavior when waiting for new forks --- README.md | 4 ++++ cmd/patch2pr/main.go | 38 ++++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 6f20d1e..ebbaac3 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,10 @@ Usage: patch2pr [options] [patch] Override the commit message by using the -message flag. + With the -fork and -fork-repository flags, the command can submit the pull + request from a fork repository. If an existing fork does not exist, the + command creates a new fork, which may take up to five minutes. + Options: -base-branch=branch The branch to target with the pull request. If unset, diff --git a/cmd/patch2pr/main.go b/cmd/patch2pr/main.go index e55f78a..8b24e88 100644 --- a/cmd/patch2pr/main.go +++ b/cmd/patch2pr/main.go @@ -319,8 +319,11 @@ func prepareSourceRepo(ctx context.Context, client *github.Client, opts *Options } func createFork(ctx context.Context, client *github.Client, fork, parent patch2pr.Repository, isUserFork bool) error { - const pollInterval = 5 * time.Second - const maxWait = 1 * time.Minute + const ( + initDelay = 1 * time.Second + maxDelay = 30 * time.Second + maxWait = 5 * time.Minute + ) organization := fork.Owner if isUserFork { @@ -338,27 +341,22 @@ func createFork(ctx context.Context, client *github.Client, fork, parent patch2p return err } - ticker := time.NewTicker(pollInterval) - defer ticker.Stop() - - ctx, cancel := context.WithTimeout(ctx, maxWait) - defer cancel() - // Poll the new repo until the default branch exists, indicating it is ready to use ref := "heads/" + repo.GetDefaultBranch() - for { - select { - case <-ticker.C: - if _, _, err := client.Git.GetRef(ctx, fork.Owner, fork.Name, ref); err == nil { - return nil - } else if !isNotFound(err) && !errors.Is(err, context.DeadlineExceeded) { - fmt.Fprintf(os.Stderr, "warning: error while waiting for fork to be ready, will try again: %v", err) - } + for delay, start := initDelay, time.Now(); time.Since(start) < maxWait; delay *= 2 { + if delay > maxDelay { + delay = maxDelay + } + time.Sleep(delay) - case <-ctx.Done(): - return fmt.Errorf("fork repository was not ready after %s", maxWait) + if _, _, err := client.Git.GetRef(ctx, fork.Owner, fork.Name, ref); err == nil { + return nil + } else if !isNotFound(err) { + fmt.Fprintf(os.Stderr, "warning: waiting for fork failed, but will try again: %v", err) } } + + return fmt.Errorf("fork repository was not ready after %s", maxWait) } func splitMessage(m string) (title string, body string) { @@ -464,6 +462,10 @@ Usage: patch2pr [options] [patch] Override the commit message by using the -message flag. + With the -fork and -fork-repository flags, the command can submit the pull + request from a fork repository. If an existing fork does not exist, the + command creates a new fork, which may take up to five minutes. + Options: -base-branch=branch The branch to target with the pull request. If unset, From 1d97380eadf2ee26d1aaec74fc8af361e2b6b31a Mon Sep 17 00:00:00 2001 From: Billy Keyes Date: Mon, 8 Jan 2024 21:05:39 -0800 Subject: [PATCH 6/7] Handle error when a fork already exists --- cmd/patch2pr/main.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cmd/patch2pr/main.go b/cmd/patch2pr/main.go index 8b24e88..0f40d88 100644 --- a/cmd/patch2pr/main.go +++ b/cmd/patch2pr/main.go @@ -340,6 +340,9 @@ func createFork(ctx context.Context, client *github.Client, fork, parent patch2p if err != nil && !errors.As(err, &aerr) { return err } + if repo.GetFullName() != fork.String() { + return fmt.Errorf("fork of %q already exists at %q, cannot create %q", parent, repo.GetFullName(), fork) + } // Poll the new repo until the default branch exists, indicating it is ready to use ref := "heads/" + repo.GetDefaultBranch() From 839b126595b84ff44bc1df1dc52bad8c55687646 Mon Sep 17 00:00:00 2001 From: Billy Keyes Date: Mon, 8 Jan 2024 21:32:15 -0800 Subject: [PATCH 7/7] Use commit listing to test for fork readiness --- cmd/patch2pr/main.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/patch2pr/main.go b/cmd/patch2pr/main.go index 0f40d88..e9dc635 100644 --- a/cmd/patch2pr/main.go +++ b/cmd/patch2pr/main.go @@ -24,7 +24,7 @@ import ( func die(code int, err error) { fmt.Fprintln(os.Stderr, "error:", err) - if isNotFound(err) { + if isCode(err, http.StatusNotFound) { fmt.Fprint(os.Stderr, ` This may be because the repository does not exist or the token you are using does not have write permission. If submitting a patch to a repository where you @@ -300,7 +300,7 @@ func prepareSourceRepo(ctx context.Context, client *github.Client, opts *Options repo, _, err := client.Repositories.Get(ctx, source.Owner, source.Name) switch { - case isNotFound(err): + case isCode(err, http.StatusNotFound): isUserFork := user.GetLogin() == source.Owner if err := createFork(ctx, client, source, target, isUserFork); err != nil { return source, fmt.Errorf("forking repository failed: %w", err) @@ -344,17 +344,19 @@ func createFork(ctx context.Context, client *github.Client, fork, parent patch2p return fmt.Errorf("fork of %q already exists at %q, cannot create %q", parent, repo.GetFullName(), fork) } - // Poll the new repo until the default branch exists, indicating it is ready to use - ref := "heads/" + repo.GetDefaultBranch() for delay, start := initDelay, time.Now(); time.Since(start) < maxWait; delay *= 2 { if delay > maxDelay { delay = maxDelay } time.Sleep(delay) - if _, _, err := client.Git.GetRef(ctx, fork.Owner, fork.Name, ref); err == nil { + if _, _, err := client.Repositories.ListCommits(ctx, fork.Owner, fork.Name, &github.CommitsListOptions{ + ListOptions: github.ListOptions{ + PerPage: 1, + }, + }); err == nil { return nil - } else if !isNotFound(err) { + } else if !isCode(err, http.StatusConflict) { fmt.Fprintf(os.Stderr, "warning: waiting for fork failed, but will try again: %v", err) } } @@ -436,9 +438,9 @@ func dateFromEnv(dateType string) time.Time { return time.Time{} } -func isNotFound(err error) bool { +func isCode(err error, code int) bool { var rerr *github.ErrorResponse - return errors.As(err, &rerr) && rerr.Response.StatusCode == http.StatusNotFound + return errors.As(err, &rerr) && rerr.Response.StatusCode == code } func helpText() string {