From e945fe24b7244945c1be2b0e9a760545d2c686c0 Mon Sep 17 00:00:00 2001 From: "tbhb.dev Agent" Date: Sun, 1 Nov 2026 01:22:50 -0400 Subject: [PATCH] feat(ghpost): set an issue type through gh-post Refinement requires a type before anyone can mark an issue ready, and gh-post could create issues and edit their bodies and labels but not set a type. An agent that filed or revised an issue had to wait for a person to set it, or make a raw API write that the posting rule bars. Adding a type flag to issue edit puts this write behind the same identity, leak, and audit checks as every other posting operation. An edit that changes only the type and metadata must not read stdin as a replacement body, or it would blank the description. Fixes #1030 Assisted-by: claude-code:claude-sonnet-5-5 Signed-off-by: Tony Burns --- internal/ghpost/args.go | 14 +- internal/ghpost/help.go | 2 + internal/ghpost/issue_type.go | 168 ++++++++++ internal/ghpost/issue_type_internal_test.go | 320 ++++++++++++++++++++ internal/ghpost/label.go | 5 +- internal/ghpost/run.go | 27 +- internal/ghpost/state.go | 4 +- 7 files changed, 531 insertions(+), 9 deletions(-) create mode 100644 internal/ghpost/issue_type.go create mode 100644 internal/ghpost/issue_type_internal_test.go diff --git a/internal/ghpost/args.go b/internal/ghpost/args.go index 5c2e219e..16e53a8e 100644 --- a/internal/ghpost/args.go +++ b/internal/ghpost/args.go @@ -30,11 +30,14 @@ type request struct { flags []string text []string private []string + issueType string + typeSet bool secretsOnly bool } const ( bodyFileFlag = "--body-file" + typeFlag = "--type" headFlag = "--head" repoFlag = "--repo" prReview = "pr review" @@ -87,10 +90,16 @@ func (r *request) take(args []string, values, switches map[string]bool) (int, er if !values[key] { return 0, errUsage } + if key == typeFlag { + r.typeSet = true + } if inline { return 1, r.addValue(key, value) } if len(args) < operationWords { + if key == typeFlag { + return 0, errIssueType + } return 0, errUsage } return operationWords, r.addValue(key, args[1]) @@ -109,6 +118,9 @@ func (r *request) addOperand(value string) error { } func (r *request) addValue(key, value string) error { + if key == typeFlag { + return r.addIssueType(value) + } if value == "" || strings.HasPrefix(value, "-") && (key != bodyFileFlag || value != "-") { return errUsage } @@ -171,7 +183,7 @@ func allowed(operation string) (map[string]bool, map[string]bool, bool) { case "issue edit": values = append( values, - strings.Fields("--title --add-label --remove-label --add-assignee --remove-assignee --milestone")..., + strings.Fields("--type --title --add-label --remove-label --add-assignee --remove-assignee --milestone")..., ) switches = []string{"--remove-milestone"} case issueComment, "pr comment": diff --git a/internal/ghpost/help.go b/internal/ghpost/help.go index 831be789..1064f248 100644 --- a/internal/ghpost/help.go +++ b/internal/ghpost/help.go @@ -21,6 +21,7 @@ Global flags: All operations accept -R, --repo OWNER/REPO. Content operations accept --body-file PATH and default to stdin; --body-file - also reads stdin. PR ready and close never read stdin. +An issue edit with --type reads no stdin unless --body-file is given. Operations and additional flags: issue create --title TEXT --label NAME --assignee LOGIN --milestone NAME @@ -28,6 +29,7 @@ Operations and additional flags: issue close --reason completed|"not planned" --body-file PATH issue reopen --body-file PATH issue edit --title TEXT --add-label NAME --remove-label NAME + --type Feature|Defect|Chore|Spike --add-assignee LOGIN --remove-assignee LOGIN --milestone NAME --remove-milestone pr create --title TEXT --base BRANCH --head BRANCH --label NAME diff --git a/internal/ghpost/issue_type.go b/internal/ghpost/issue_type.go new file mode 100644 index 00000000..dfadebe7 --- /dev/null +++ b/internal/ghpost/issue_type.go @@ -0,0 +1,168 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package ghpost + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + "time" +) + +var errIssueType = errors.New("--type requires Feature, Defect, Chore or Spike") + +func (r *request) addIssueType(value string) error { + if r.issueType != "" { + return errIssueType + } + r.issueType = value + if !slices.Contains([]string{"Feature", "Defect", "Chore", "Spike"}, value) { + return errIssueType + } + return nil +} + +func (r *request) startTypeAudit(errOut io.Writer) (func(*int), error) { + if !r.typeSet { + return func(*int) {}, nil + } + log, err := openAudit() + if err != nil { + return nil, err + } + return func(code *int) { + defer log.Close() + if appendErr := r.auditIssueType(log, *code); appendErr != nil { + fmt.Fprintln(errOut, "gh-post: audit append failed; check GitHub before retrying") + *code = exitEnvironment + } + }, nil +} + +type issueType struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type typedIssue struct { + ID string `json:"id"` + ViewerCanUpdate bool `json:"viewerCanUpdateMetadata"` + Type issueType `json:"issueType"` +} + +func (r *request) auditIssueType(log io.Writer, code int) error { + outcome := "refused" + if code == 0 { + outcome = "success" + } + base := auditRecord{ + Time: time.Now().UTC().Format(time.RFC3339Nano), Operation: r.operation, + URL: "https://github.com/" + r.repo + "/issues/" + r.operand, + } + record := struct { + auditRecord + + Issue string `json:"issue"` + Type string `json:"type"` + Outcome string `json:"outcome"` + }{ + auditRecord: base, + Issue: r.operand, Type: r.issueType, Outcome: outcome, + } + if err := json.NewEncoder(log).Encode(record); err != nil { + return fmt.Errorf("audit issue type: %w", err) + } + return nil +} + +func (r *request) typeQuery(ctx context.Context, field, filter string) ([]byte, error) { + owner, name, _ := strings.Cut(r.repo, "/") + return gh(ctx, []string{ + apiCommand, graphqlCommand, "-f", + "query=query($owner:String!,$name:String!){repository(owner:$owner,name:$name){" + field + "}}", + "-f", "owner=" + owner, "-f", "name=" + name, jqFlag, ".data.repository." + filter, + }) +} + +func (r *request) readTypedIssue(ctx context.Context) (typedIssue, error) { + data, err := r.typeQuery(ctx, "issue(number:"+r.operand+"){id viewerCanUpdateMetadata issueType{id name}}", "issue") + var issue typedIssue + if err != nil || json.Unmarshal(data, &issue) != nil || issue.ID == "" { + return issue, errEnvironment + } + return issue, nil +} + +func (r *request) resolveIssueType(ctx context.Context) (string, error) { + data, err := r.typeQuery(ctx, "issueTypes(first:100){nodes{id name}}", "issueTypes.nodes") + var types []issueType + if err != nil || json.Unmarshal(data, &types) != nil { + return "", errEnvironment + } + for _, kind := range types { + if kind.Name == r.issueType && kind.ID != "" { + return kind.ID, nil + } + } + return "", errEnvironment +} + +func (r *request) changeIssueType(ctx context.Context, body []byte, out, errOut io.Writer) int { + id, err := r.resolveIssueType(ctx) + if err != nil { + fmt.Fprintf(errOut, "gh-post: cannot resolve issue type %q in %s\n", r.issueType, r.repo) + return exitEnvironment + } + issue, err := r.readTypedIssue(ctx) + if err != nil { + fmt.Fprintf(errOut, "gh-post: cannot read issue %s in %s\n", r.operand, r.repo) + return exitEnvironment + } + if !issue.ViewerCanUpdate { + fmt.Fprintf(errOut, "gh-post: cannot update issue %s in %s\n", r.operand, r.repo) + return exitEnvironment + } + if r.bodyFile != "" || len(r.flags) > 0 { + log, auditErr := openAudit() + if auditErr != nil { + fmt.Fprintln(errOut, "gh-post: cannot open audit log") + return exitEnvironment + } + defer log.Close() + if code := postContent(ctx, *r, body, log, out, errOut); code != 0 { + return code + } + } + if issue.Type.Name == r.issueType { + fmt.Fprintln(out, "gh-post: issue type already as requested, nothing changed") + return 0 + } + args := []string{ + apiCommand, graphqlCommand, "-f", + "query=mutation($id:ID!,$issueTypeId:ID!){updateIssue(input:{id:$id,issueTypeId:$issueTypeId}){issue{id}}}", + "-f", "id=" + issue.ID, "-f", "issueTypeId=" + id, + } + data, stderr, writeErr := ghRun(ctx, args, nil) + if writeErr != nil { + fmt.Fprintln(errOut, "gh-post: issue type write failed; check GitHub before retrying") + r.printWriteError(stderr, body, errOut) + r.printWriteError(errorBody(data), body, errOut) + return exitEnvironment + } + after, err := r.readTypedIssue(ctx) + if err != nil || after.ID != issue.ID || after.Type.Name != r.issueType { + fmt.Fprintln(errOut, "gh-post: cannot confirm issue type; check GitHub before retrying") + return exitEnvironment + } + old := issue.Type.Name + if old == "" { + old = "untyped" + } + fmt.Fprintf(out, "gh-post: issue %s type changed from %s to %s\n", r.operand, old, after.Type.Name) + return 0 +} diff --git a/internal/ghpost/issue_type_internal_test.go b/internal/ghpost/issue_type_internal_test.go new file mode 100644 index 00000000..856e5beb --- /dev/null +++ b/internal/ghpost/issue_type_internal_test.go @@ -0,0 +1,320 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright Tony Burns + +package ghpost + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const fakeGHEnv = "GHPOST_FAKE_GH" + +// TestMain runs the test binary as the GitHub CLI when the fixture links it to +// that name and sets the fake-CLI variable. The command still runs as a child process. +func TestMain(m *testing.M) { + if os.Getenv(fakeGHEnv) == "1" { + os.Exit(fakeGH(os.Args[1:], os.Stdin, os.Stdout)) + } + os.Exit(m.Run()) +} + +func fakeGH(args []string, stdin io.Reader, stdout io.Writer) int { + root, err := os.OpenRoot(filepath.Dir(os.Getenv("CALLS"))) + if err != nil { + return 1 + } + defer root.Close() + code, err := serveFakeGH(root, strings.Join(args, " "), stdin, stdout) + if err != nil { + return 1 + } + return code +} + +func serveFakeGH(root *os.Root, joined string, stdin io.Reader, stdout io.Writer) (int, error) { + if err := fakeAppend(root, "calls", joined+"\n"); err != nil { + return 1, err + } + switch { + case joined == "api user --jq .login": + login := os.Getenv("LOGIN") + if login == "" { + login = "agent" + } + fmt.Fprintln(stdout, login) + case strings.Contains(joined, "issueTypes("): + fmt.Fprint(stdout, fakeRead(root, "types")) + case strings.Contains(joined, "issue(number:"): + if _, err := root.Stat("missing"); err == nil { + return 1, nil + } + current := fakeRead(root, "current") + fmt.Fprintf(stdout, + `{"id":"ISSUE","viewerCanUpdateMetadata":%s,"issueType":{"id":%q,"name":%q}}`, + fakeRead(root, "access"), current, current) + case strings.Contains(joined, "mutation("): + err := fakeAppend(root, "type-writes", joined+"\n") + if err == nil { + err = root.WriteFile("current", []byte(fakeRead(root, "requested")), 0o600) + } + if err != nil { + return 1, fmt.Errorf("record mutation: %w", err) + } + fmt.Fprintln(stdout, "{}") + case strings.HasPrefix(joined, "issue edit 12 --repo owner/repo"): + if err := fakeIssueEdit(root, joined, stdin); err != nil { + return 1, err + } + fmt.Fprintln(stdout, "https://github.com/owner/repo/issues/12") + default: + return 99, nil + } + return 0, nil +} + +func fakeRead(root *os.Root, name string) string { + data, err := root.ReadFile(name) + if err != nil { + return "" + } + return string(data) +} + +func fakeAppend(root *os.Root, name, text string) error { + file, err := root.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("open %s: %w", name, err) + } + _, err = file.WriteString(text) + return errors.Join(err, file.Close()) +} + +func fakeIssueEdit(root *os.Root, joined string, stdin io.Reader) error { + if !strings.HasSuffix(joined, "--body-file -") { + return fakeAppend(root, "no-body-edits", joined+"\n") + } + data, err := io.ReadAll(stdin) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + if err = root.WriteFile("payload", data, 0o600); err != nil { + return fmt.Errorf("write payload: %w", err) + } + return nil +} + +// setupIssueType replaces the package's shell fake with a link to the test +// binary. Each caller sets the fake-CLI variable itself. +func setupIssueType(t *testing.T, current string) string { + t.Helper() + dir := setup(t) + executable, err := os.Executable() + require.NoError(t, err) + require.NoError(t, os.Remove(filepath.Join(dir, "gh"))) + require.NoError(t, os.Symlink(executable, filepath.Join(dir, "gh"))) + write(t, filepath.Join(dir, "current"), current) + write(t, filepath.Join(dir, "types"), `[{"id":"TYPE","name":"Feature"}]`) + write(t, filepath.Join(dir, "access"), "true") + write(t, filepath.Join(dir, "requested"), "Feature") + return dir +} + +func issueTypeArgs(name string) []string { + return []string{"issue", "edit", "12", "--repo", "owner/repo", "--type", name} +} + +func TestIssueEditTypeAC1(t *testing.T) { + for _, name := range []string{"Feature", "Defect", "Chore", "Spike"} { + t.Run(name, func(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + write(t, filepath.Join(dir, "types"), `[{"id":"TYPE","name":"`+name+`"}]`) + write(t, filepath.Join(dir, "requested"), name) + code, _, errOut := run(t, "ignored", issueTypeArgs(name)...) + require.Equal(t, 0, code, errOut) + require.Equal(t, name, strings.TrimSpace(fileText(t, filepath.Join(dir, "current")))) + calls := fileText(t, filepath.Join(dir, "calls")) + require.Equal(t, 2, strings.Count(calls, "issue(number:")) + require.Contains(t, calls, "issueTypeId=TYPE") + }) + } +} + +func TestIssueEditTypeAC2(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "Defect") + code, out, errOut := run(t, "", issueTypeArgs("Feature")...) + require.Equal(t, 0, code, errOut) + require.Contains(t, out, "Defect") + require.Contains(t, out, "Feature") + require.Equal(t, "Feature", strings.TrimSpace(fileText(t, filepath.Join(dir, "current")))) +} + +func TestIssueEditTypeAC3(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "Feature") + code, out, errOut := run(t, "", issueTypeArgs("Feature")...) + require.Equal(t, 0, code, errOut) + require.Contains(t, out, "nothing changed") + require.NoFileExists(t, filepath.Join(dir, "type-writes")) + require.NoFileExists(t, filepath.Join(dir, "payload")) +} + +func TestIssueEditTypeAC4(t *testing.T) { + for _, name := range []string{"Unknown", "feature", "", "none"} { + t.Run(name, func(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + code, _, errOut := run(t, "", issueTypeArgs(name)...) + require.Equal(t, 2, code) + require.Contains(t, errOut, "--type") + require.NoFileExists(t, filepath.Join(dir, "calls")) + }) + } +} + +func TestIssueEditTypeAC5(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + write(t, filepath.Join(dir, "types"), "[]") + code, _, errOut := run(t, "", issueTypeArgs("Feature")...) + require.Equal(t, 2, code) + require.Contains(t, errOut, "Feature") + require.Contains(t, fileText(t, filepath.Join(dir, "calls")), "issueTypes(") + require.NoFileExists(t, filepath.Join(dir, "type-writes")) +} + +func TestIssueEditTypeAC6(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + write(t, filepath.Join(dir, "missing"), "yes") + code, _, errOut := run(t, "", issueTypeArgs("Feature")...) + require.Equal(t, 2, code) + require.Contains(t, errOut, "12") + require.NoFileExists(t, filepath.Join(dir, "type-writes")) +} + +func TestIssueEditTypeAC7(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + write(t, filepath.Join(dir, "access"), "false") + code, _, errOut := run(t, "", issueTypeArgs("Feature")...) + require.Equal(t, 2, code) + require.Contains(t, errOut, "owner/repo") + require.Contains(t, fileText(t, filepath.Join(dir, "calls")), "issue(number:") + require.NoFileExists(t, filepath.Join(dir, "type-writes")) +} + +func TestIssueEditTypeAC8(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + code, _, errOut := run(t, "ignored", issueTypeArgs("Feature")...) + require.Equal(t, 0, code, errOut) + require.Equal(t, 1, strings.Count(fileText(t, filepath.Join(dir, "type-writes")), "mutation(")) + require.NoFileExists(t, filepath.Join(dir, "payload")) + require.NotContains(t, fileText(t, filepath.Join(dir, "calls")), "issue edit") +} + +func TestIssueEditTypeAC9(t *testing.T) { + for _, body := range []string{"Public explanation.", "ghp_" + strings.Repeat("a", 30)} { + t.Run(body[:6], func(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + write(t, filepath.Join(dir, "body"), body) + args := append(issueTypeArgs("Feature"), "--body-file", filepath.Join(dir, "body")) + code, _, errOut := run(t, "", args...) + if strings.HasPrefix(body, "ghp_") { + require.Equal(t, 1, code, errOut) + require.Contains(t, errOut, "secret") + require.NoFileExists(t, filepath.Join(dir, "type-writes")) + require.NoFileExists(t, filepath.Join(dir, "payload")) + } else { + require.Equal(t, 0, code, errOut) + require.FileExists(t, filepath.Join(dir, "type-writes")) + require.Equal(t, body, fileText(t, filepath.Join(dir, "payload"))) + } + }) + } +} + +func TestIssueEditTypeAC10(t *testing.T) { + for _, outcome := range []string{"success", "unchanged", "invalid", "undefined", "missing", "access", "secret", "identity", "input"} { + t.Run(outcome, func(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, "") + name := "Feature" + args := issueTypeArgs(name) + switch outcome { + case "unchanged": + write(t, filepath.Join(dir, "current"), name) + case "invalid": + name = "Unknown" + args = issueTypeArgs(name) + case "undefined": + write(t, filepath.Join(dir, "types"), "[]") + case "missing": + write(t, filepath.Join(dir, "missing"), "yes") + case "access": + write(t, filepath.Join(dir, "access"), "false") + case "secret": + write(t, filepath.Join(dir, "body"), "ghp_"+strings.Repeat("a", 30)) + args = append(args, "--body-file", filepath.Join(dir, "body")) + case "identity": + t.Setenv("LOGIN", "stranger") + case "input": + args = append(args, "--body-file", filepath.Join(dir, "absent")) + } + code, _, _ := run(t, "", args...) + var record struct { + Issue string `json:"issue"` + Type string `json:"type"` + Outcome string `json:"outcome"` + } + lines := strings.Split( + strings.TrimSpace(fileText(t, filepath.Join(dir, "state", "devtools", "gh-post.log"))), + "\n", + ) + require.NoError(t, json.Unmarshal([]byte(lines[len(lines)-1]), &record)) + require.Equal(t, "12", record.Issue) + require.Equal(t, name, record.Type) + want := "refused" + if code == 0 { + want = "success" + } + require.Equal(t, want, record.Outcome) + }) + } +} + +func TestIssueEditTypeAC11(t *testing.T) { + t.Parallel() + require.Contains(t, usage, "--type") + entries, err := os.ReadDir(".") + require.NoError(t, err) + for _, entry := range entries { + require.NotRegexp(t, `\.(sh|bash)$`, entry.Name()) + } +} + +func TestIssueEditTypeMetadataKeepsBody(t *testing.T) { + for _, current := range []string{"", "Feature"} { + t.Run("current="+current, func(t *testing.T) { + t.Setenv(fakeGHEnv, "1") + dir := setupIssueType(t, current) + args := append(issueTypeArgs("Feature"), "--add-label", "triaged") + code, _, errOut := run(t, "ignored", args...) + require.Equal(t, 0, code, errOut) + require.NoFileExists(t, filepath.Join(dir, "payload")) + require.Contains(t, fileText(t, filepath.Join(dir, "no-body-edits")), "--add-label triaged") + }) + } +} diff --git a/internal/ghpost/label.go b/internal/ghpost/label.go index bb8769d6..2532361e 100644 --- a/internal/ghpost/label.go +++ b/internal/ghpost/label.go @@ -42,8 +42,11 @@ func labelFlags(operation string) (map[string]bool, map[string]bool, bool) { return values, map[string]bool{}, true } -// parseFailure keeps a label refusal's own message and gives every other parse failure the usage text. +// parseFailure preserves argument refusal messages. func parseFailure(err error) error { + if errors.Is(err, errIssueType) { + return errIssueType + } if refusal, ok := errors.AsType[labelError](err); ok { return refusal } diff --git a/internal/ghpost/run.go b/internal/ghpost/run.go index 009861b7..050fde65 100644 --- a/internal/ghpost/run.go +++ b/internal/ghpost/run.go @@ -28,6 +28,7 @@ const ( jsonFlag = "--json" methodFlag = "--method" viewVerb = "view" + graphqlCommand = "graphql" ) var errEnvironment = errors.New("cannot resolve repository, read input, or execute gh") @@ -38,6 +39,17 @@ func Run(ctx context.Context, args []string, in io.Reader, out, errOut io.Writer return code } r, err := parse(args) + finish, auditErr := r.startTypeAudit(errOut) + if auditErr != nil { + fmt.Fprintln(errOut, "gh-post: cannot open audit log") + return exitEnvironment + } + code := runRequest(ctx, &r, err, in, out, errOut) + finish(&code) + return code +} + +func runRequest(ctx context.Context, r *request, err error, in io.Reader, out, errOut io.Writer) int { if err != nil { fmt.Fprintln(errOut, "gh-post:", parseFailure(err)) return exitEnvironment @@ -85,7 +97,7 @@ func Run(ctx context.Context, args []string, in io.Reader, out, errOut io.Writer } r.private = private r.secretsOnly = config["scan_mode"] == secretsOnlyMode - return post(ctx, r, body, config["post_identities"], out, errOut) + return post(ctx, *r, body, config["post_identities"], out, errOut) } func scanConfig(ctx context.Context, config map[string]string) ([]string, error) { @@ -175,7 +187,7 @@ func identityAllowed(ctx context.Context, allowlist string) (string, bool, error if err != nil { data, err = gh( ctx, - []string{apiCommand, "graphql", "-f", "query={viewer{login}}", jqFlag, ".data.viewer.login"}, + []string{apiCommand, graphqlCommand, "-f", "query={viewer{login}}", jqFlag, ".data.viewer.login"}, ) } if err != nil { @@ -195,6 +207,9 @@ func post(ctx context.Context, r request, body []byte, identities string, out, e fmt.Fprintln(errOut, "gh-post: identity refused") return 1 } + if r.issueType != "" { + return r.changeIssueType(ctx, body, out, errOut) + } log, err := openAudit() if err != nil { fmt.Fprintln(errOut, "gh-post: cannot open audit log") @@ -333,9 +348,11 @@ func (r *request) command(body []byte) ([]string, []byte, error) { } args = append(args, repoFlag, r.repo) args = append(args, r.flags...) - // Metadata-only PR edits must preserve the existing description. An - // explicit body file still allows callers to clear it deliberately. - if r.operation == "pr edit" && r.bodyFile == "" && len(body) == 0 { + // Metadata-only PR edits, and issue edits that change the type, must + // preserve the existing description. An explicit body file still allows + // callers to clear it deliberately. + keepsBody := r.operation == "pr edit" || r.operation == "issue edit" && r.issueType != "" + if keepsBody && r.bodyFile == "" && len(body) == 0 { return args, nil, nil } bodyFlag := bodyFileFlag diff --git a/internal/ghpost/state.go b/internal/ghpost/state.go index 60981701..30b998a6 100644 --- a/internal/ghpost/state.go +++ b/internal/ghpost/state.go @@ -27,7 +27,7 @@ var ( ) func (r *request) readInput(in io.Reader) ([]byte, error) { - if r.operation == prReady || r.isLabelChange() { + if r.operation == prReady || r.isLabelChange() || r.issueType != "" && r.bodyFile == "" { return nil, nil } path := r.bodyFile @@ -95,7 +95,7 @@ func (r *request) changePRState(ctx context.Context, body []byte, login string, args := []string{"pr", "close", r.operand, repoFlag, r.repo} if r.operation == prReady { args = []string{ - apiCommand, "graphql", "-f", + apiCommand, graphqlCommand, "-f", "query=mutation($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:$id}){pullRequest{url}}}", "-f", "id=" + state.ID, }