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