issues

Github, Jira, Gitlab issues from Plan 9's acme
Log | Files | Refs | README | LICENSE

hub.go (6149B)


      1 package hub
      2 
      3 import (
      4 	"bytes"
      5 	"encoding/json"
      6 	"errors"
      7 	"fmt"
      8 	"io"
      9 	"io/fs"
     10 	"net/http"
     11 	"net/url"
     12 	"path"
     13 	"strconv"
     14 	"strings"
     15 	"time"
     16 )
     17 
     18 const defaultBaseURL = "https://api.github.com"
     19 
     20 type Client struct {
     21 	baseURL string
     22 	Token   string
     23 	*http.Client
     24 }
     25 
     26 type Issue struct {
     27 	Number  int
     28 	Title   string
     29 	Creator struct {
     30 		Name string `json:"login"`
     31 	} `json:"user"`
     32 	Assignee struct {
     33 		Name string `json:"login"`
     34 	}
     35 	Labels   []Label
     36 	State    string
     37 	Created  time.Time `json:"created_at"`
     38 	Updated  time.Time `json:"updated_at"`
     39 	Closed   time.Time `json:"closed_at"`
     40 	HTMLURL  string    `json:"html_url"`
     41 	Body     string
     42 	Comments int
     43 }
     44 
     45 type Label struct {
     46 	Name        string
     47 	Description string
     48 }
     49 
     50 func printIssue(w io.Writer, issue *Issue) error {
     51 	buf := &bytes.Buffer{}
     52 	fmt.Fprintln(buf, "Title:", issue.Title)
     53 	fmt.Fprintln(buf, "State:", issue.State)
     54 	fmt.Fprintln(buf, "From:", issue.Creator.Name)
     55 	fmt.Fprintln(buf, "Date:", issue.Updated.Format(time.DateTime))
     56 	if !issue.Closed.IsZero() {
     57 		fmt.Fprintln(buf, "Closed:", issue.Closed.Format(time.DateTime))
     58 	}
     59 	fmt.Fprintln(buf, "Assignee:", issue.Assignee.Name)
     60 
     61 	labels := make([]string, len(issue.Labels))
     62 	for i := range issue.Labels {
     63 		labels[i] = issue.Labels[i].Name
     64 	}
     65 	fmt.Fprintln(buf, "Labels:", strings.Join(labels, ", "))
     66 
     67 	fmt.Fprintln(buf, "URL:", issue.HTMLURL)
     68 	fmt.Fprintln(buf)
     69 	fmt.Fprintln(buf, strings.TrimSpace(issue.Body))
     70 	_, err := io.Copy(w, buf)
     71 	return err
     72 }
     73 
     74 type Comment struct {
     75 	ID      int
     76 	Created time.Time `json:"created_at"`
     77 	Updated time.Time `json:"updated_at"`
     78 	Body    string
     79 	User    struct {
     80 		Name string `json:"login"`
     81 	}
     82 }
     83 
     84 func printComment(w io.Writer, c Comment) error {
     85 	buf := &bytes.Buffer{}
     86 	fmt.Fprintln(buf, "From:", c.User.Name)
     87 	fmt.Fprintln(buf, "Date:", c.Updated)
     88 	fmt.Fprintln(buf)
     89 	fmt.Fprintln(buf, strings.TrimSpace(c.Body))
     90 	_, err := io.Copy(w, buf)
     91 	return err
     92 }
     93 
     94 func printComments(w io.Writer, comments []Comment) error {
     95 	buf := &bytes.Buffer{}
     96 	for _, c := range comments {
     97 		fmt.Fprintf(buf, "Comment by %s (%s)\n", c.User.Name, c.Updated.Format(time.DateTime))
     98 		fmt.Fprintln(buf)
     99 		fmt.Fprintln(buf, strings.TrimSpace(c.Body))
    100 		fmt.Fprintln(buf)
    101 	}
    102 	_, err := io.Copy(w, buf)
    103 	return err
    104 }
    105 
    106 type Error struct {
    107 	Message string
    108 }
    109 
    110 func (e *Error) Error() string { return e.Message }
    111 
    112 func (c *Client) CheckIssue(owner, repo string, number int) (bool, error) {
    113 	p := path.Join("/repos", owner, repo, "issues", strconv.Itoa(number))
    114 	resp, err := c.head(p)
    115 	if err != nil {
    116 		return false, err
    117 	}
    118 	// TODO: don't reuse ErrNotExist?
    119 	if resp.StatusCode == http.StatusNotFound {
    120 		return false, fs.ErrNotExist
    121 	} else if resp.StatusCode != http.StatusOK {
    122 		return false, errors.New(resp.Status)
    123 	}
    124 	return true, nil
    125 }
    126 
    127 func (c *Client) LookupIssue(owner, repo string, number int) (*Issue, error) {
    128 	var issue Issue
    129 	p := path.Join("/repos", owner, repo, "issues", strconv.Itoa(number))
    130 	err := c.get(p, &issue)
    131 	return &issue, err
    132 }
    133 
    134 func (c *Client) Issues(owner, repo string) ([]Issue, error) {
    135 	var issues []Issue
    136 	p := path.Join("/repos", owner, repo, "issues")
    137 	err := c.get(p, &issues)
    138 	return issues, err
    139 }
    140 
    141 func (c *Client) SearchIssues(owner, repo, query string) ([]Issue, error) {
    142 	if len(query) > 256 {
    143 		return nil, fmt.Errorf("query length %d longer than max 256", len(query))
    144 	}
    145 
    146 	q := fmt.Sprintf("type:issue repo:%s/%s %s", owner, repo, query)
    147 	reqPath := "/search/issues?per_page=50&q=" + url.QueryEscape(q)
    148 	hits := struct {
    149 		TotalCount int
    150 		Items      []Issue
    151 	}{
    152 		TotalCount: 0,
    153 		Items:      make([]Issue, 0),
    154 	}
    155 	if err := c.get(reqPath, &hits); err != nil {
    156 		return nil, fmt.Errorf("execute search: %w", err)
    157 	}
    158 	return hits.Items, nil
    159 }
    160 
    161 func (c *Client) CreateIssue(owner, repo, title, body string) (*Issue, error) {
    162 	m := map[string]string{
    163 		"title": title,
    164 		"body":  body,
    165 	}
    166 	b, err := json.Marshal(&m)
    167 	if err != nil {
    168 		return nil, fmt.Errorf("encode issue: %w", err)
    169 	}
    170 	p := path.Join("/repos", owner, repo, "issues")
    171 	resp, err := c.post(p, bytes.NewReader(b))
    172 	if resp.StatusCode != http.StatusCreated {
    173 		// TODO(otl): decode error message
    174 		return nil, errors.New(resp.Status)
    175 	}
    176 	defer resp.Body.Close()
    177 	var issue Issue
    178 	err = json.NewDecoder(resp.Body).Decode(&issue)
    179 	return &issue, err
    180 }
    181 
    182 func (c *Client) Comments(owner, repo string, issue int) ([]Comment, error) {
    183 	var comments []Comment
    184 	p := path.Join("/repos", owner, repo, "issues", strconv.Itoa(issue), "comments")
    185 	err := c.get(p, &comments)
    186 	return comments, err
    187 }
    188 
    189 func (c *Client) LookupComment(owner, repo string, number int) (*Comment, error) {
    190 	p := path.Join("/repos", owner, repo, "issues", "comments", strconv.Itoa(number))
    191 	var comment Comment
    192 	err := c.get(p, &comment)
    193 	return &comment, err
    194 }
    195 
    196 func (c *Client) get(path string, v any) error {
    197 	if c.baseURL == "" {
    198 		c.baseURL = defaultBaseURL
    199 	}
    200 
    201 	u := c.baseURL + path
    202 	req, err := http.NewRequest(http.MethodGet, u, nil)
    203 	if err != nil {
    204 		return err
    205 	}
    206 
    207 	resp, err := c.do(req)
    208 	if err != nil {
    209 		return err
    210 	}
    211 	defer resp.Body.Close()
    212 	if resp.StatusCode != http.StatusOK {
    213 		var e Error
    214 		if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
    215 			return fmt.Errorf("response status %s: decode error message: %w", resp.Status, err)
    216 		}
    217 		return &e
    218 	}
    219 
    220 	if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
    221 		return fmt.Errorf("decode response: %w", err)
    222 	}
    223 	return nil
    224 }
    225 
    226 func (c *Client) post(path string, body io.Reader) (*http.Response, error) {
    227 	if c.baseURL == "" {
    228 		c.baseURL = defaultBaseURL
    229 	}
    230 
    231 	u := c.baseURL + path
    232 	req, err := http.NewRequest(http.MethodPost, u, body)
    233 	if err != nil {
    234 		return nil, err
    235 	}
    236 	return c.do(req)
    237 }
    238 
    239 func (c *Client) head(path string) (*http.Response, error) {
    240 	if c.baseURL == "" {
    241 		c.baseURL = defaultBaseURL
    242 	}
    243 
    244 	u := c.baseURL + path
    245 	req, err := http.NewRequest(http.MethodHead, u, nil)
    246 	if err != nil {
    247 		return nil, err
    248 	}
    249 	return c.do(req)
    250 }
    251 
    252 func (c *Client) do(req *http.Request) (*http.Response, error) {
    253 	if c.Client == nil {
    254 		c.Client = http.DefaultClient
    255 	}
    256 	if c.Token != "" {
    257 		req.Header.Set("Authorization", "Bearer "+c.Token)
    258 	}
    259 	return c.Do(req)
    260 }