Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion internal/ghpost/args.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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])
Expand All @@ -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
}
Expand Down Expand Up @@ -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":
Expand Down
2 changes: 2 additions & 0 deletions internal/ghpost/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@ 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
issue comment <issue> --edit-last --create-if-none
issue close <issue> --reason completed|"not planned" --body-file PATH
issue reopen <issue> --body-file PATH
issue edit <issue> --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
Expand Down
168 changes: 168 additions & 0 deletions internal/ghpost/issue_type.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading