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 }