Gitlab.go (8886B)
1 package main 2 3 import ( 4 "bufio" 5 "bytes" 6 "errors" 7 "flag" 8 "fmt" 9 "io" 10 "io/fs" 11 "log" 12 "net/url" 13 "os" 14 "path" 15 "regexp" 16 "strconv" 17 "strings" 18 19 "9fans.net/go/acme" 20 ) 21 22 type awin struct { 23 *acme.Win 24 issue *Issue 25 // search query used by the Search command 26 query string 27 } 28 29 func (w *awin) name() string { 30 buf, err := w.ReadAll("tag") 31 if err != nil { 32 w.Err(err.Error()) 33 return "" 34 } 35 name := strings.Fields(string(buf))[0] 36 return path.Base(name) 37 } 38 39 func (w *awin) project() string { 40 buf, err := w.ReadAll("tag") 41 if err != nil { 42 w.Err(err.Error()) 43 return "" 44 } 45 name := strings.Fields(string(buf))[0] 46 dir := path.Dir(name) 47 return strings.TrimPrefix(dir, "/gitlab/") 48 } 49 50 func (w *awin) Execute(cmd string) bool { 51 switch cmd { 52 case "Get": 53 w.load() 54 return true 55 case "New": 56 newIssue(w.project()) 57 return true 58 case "Comment": 59 var issueID int 60 if w.issue != nil { 61 issueID = w.issue.ID 62 } 63 createComment(issueID, w.project()) 64 return true 65 case "Put": 66 buf := &bytes.Buffer{} 67 b, err := w.ReadAll("body") 68 if err != nil { 69 w.Err(err.Error()) 70 return false 71 } 72 buf.Write(b) 73 74 switch w.name() { 75 case "comment": 76 w.Err("put comment not yet implemented") 77 case "new": 78 w.issue = parseIssue(buf) 79 w.issue, err = client.Create(w.project(), w.issue.Title, w.issue.Description) 80 w.Name(path.Join("/gitlab", w.project(), strconv.Itoa(w.issue.ID))) 81 w.load() 82 default: 83 err = errors.New("file is not comment or new issue") 84 } 85 if err != nil { 86 w.Err(fmt.Sprintf("put %s: %v", w.name(), err)) 87 return false 88 } 89 w.Ctl("clean") 90 return true 91 } 92 93 if strings.HasPrefix(cmd, "Search") { 94 query := strings.TrimSpace(strings.TrimPrefix(cmd, "Search")) 95 createSearch(query, w.project()) 96 return true 97 } 98 99 return false 100 } 101 102 func (w *awin) Look(text string) bool { 103 text = strings.TrimSpace(text) 104 text = strings.TrimPrefix(text, "#") 105 if regexp.MustCompile("^[0-9]+$").MatchString(text) { 106 id, err := strconv.Atoi(text) 107 if err != nil { 108 w.Err(err.Error()) 109 return false 110 } 111 name := path.Join("/gitlab", w.project(), strconv.Itoa(id)) 112 if acme.Show(name) != nil { 113 return true 114 } 115 openIssue(id, w.project()) 116 return true 117 } 118 return false 119 } 120 121 func (w *awin) load() { 122 if w.name() == "all" || w.query != "" { 123 w.loadIssueList() 124 } else if regexp.MustCompile("^[0-9]+").MatchString(w.name()) { 125 w.loadIssue() 126 } 127 } 128 129 func (w *awin) loadIssueList() { 130 w.Ctl("dirty") 131 defer w.Ctl("clean") 132 133 search := make(map[string]string) 134 var err error 135 if w.query != "" { 136 search, err = parseSearch(w.query) 137 if err != nil { 138 w.Err(err.Error()) 139 return 140 } 141 } 142 issues, err := client.Issues(w.project(), search) 143 if err != nil { 144 w.Err(err.Error()) 145 return 146 } 147 w.Clear() 148 buf := &bytes.Buffer{} 149 printIssueList(buf, issues) 150 w.Write("body", buf.Bytes()) 151 w.Ctl("dot=addr") 152 } 153 154 func (w *awin) loadIssue() { 155 w.Ctl("dirty") 156 defer w.Ctl("clean") 157 id, err := strconv.Atoi(w.name()) 158 if err != nil { 159 w.Err(fmt.Sprintf("parse window name as issue id: %v", err)) 160 return 161 } 162 issue, err := client.Issue(w.project(), id) 163 if err != nil { 164 w.Err(err.Error()) 165 return 166 } 167 w.issue = issue 168 w.Clear() 169 buf := &bytes.Buffer{} 170 printIssue(buf, issue) 171 172 /* 173 // TODO(otl): we can't load issue notes yet. 174 asc := "asc" 175 sortAscending := &gitlab.ListIssueNotesOptions{ 176 Sort: &asc, 177 } 178 notes, _, err := client.Notes.ListIssueNotes(w.project(), id, sortAscending) 179 if err != nil { 180 w.Err(err.Error()) 181 } 182 printNotes(buf, notes) 183 */ 184 185 w.Write("body", buf.Bytes()) 186 w.Ctl("dot=addr") 187 } 188 189 func printIssueList(w io.Writer, issues []Issue) { 190 for i := range issues { 191 fmt.Fprintf(w, "%d\t%s\n", issues[i].ID, issues[i].Title) 192 } 193 } 194 195 func printIssue(w io.Writer, issue *Issue) { 196 fmt.Fprintln(w, "Title:", issue.Title) 197 fmt.Fprintln(w, "State:", issue.State) 198 fmt.Fprintln(w, "Author:", issue.Author.Username) 199 // TODO(otl): we don't store assignees in Issue yet. 200 // fmt.Fprint(w, "Assignee: ") 201 // if len(issue.Assignees) > 0 { 202 // var v []string 203 // for _, a := range issue.Assignees { 204 // v = append(v, a.Username) 205 // } 206 // fmt.Fprint(w, strings.Join(v, ", ")) 207 //} 208 //fmt.Fprintln(w) 209 fmt.Fprintln(w, "Created:", issue.Created) 210 fmt.Fprintln(w, "URL:", issue.URL) 211 if !issue.Closed.IsZero() { 212 fmt.Fprintln(w, "Closed:", issue.Closed) 213 } 214 fmt.Fprintln(w, "") 215 fmt.Fprintln(w, issue.Description) 216 } 217 218 /* 219 func printNotes(w io.Writer, notes []*gitlab.Note) { 220 for i := range notes { 221 fmt.Fprintf(w, "%s (%s):\n", notes[i].Author.Username, notes[i].CreatedAt) 222 fmt.Fprintln(w) 223 fmt.Fprintln(w, notes[i].Body) 224 fmt.Fprintln(w) 225 } 226 } 227 */ 228 229 func newIssue(project string) { 230 win, err := acme.New() 231 if err != nil { 232 log.Print(err) 233 } 234 win.Name(path.Join("/gitlab", project, "new")) 235 win.Fprintf("tag", "Put ") 236 dummy := &awin{ 237 Win: win, 238 } 239 dummy.Write("body", []byte("Title: ")) 240 go dummy.EventLoop(dummy) 241 } 242 243 func createComment(id int, project string) { 244 dummy := &awin{} 245 win, err := acme.New() 246 if err != nil { 247 log.Print(err) 248 } 249 dummy.Win = win 250 dummy.Name("/gitlab/" + project + "/comment") 251 win.Fprintf("tag", "Put ") 252 body := fmt.Sprintf("To: %d\n\n", id) 253 dummy.Write("body", []byte(body)) 254 go dummy.EventLoop(dummy) 255 } 256 257 func createSearch(query, project string) { 258 dummy := &awin{} 259 win, err := acme.New() 260 if err != nil { 261 log.Print(err) 262 } 263 dummy.Win = win 264 dummy.Name("/gitlab/" + project + "/search") 265 win.Fprintf("tag", "New Get ") 266 dummy.query = query 267 dummy.loadIssueList() 268 go dummy.EventLoop(dummy) 269 } 270 271 func openIssue(id int, project string) { 272 dummy := &awin{} 273 win, err := acme.New() 274 if err != nil { 275 log.Fatal(err) 276 } 277 dummy.Win = win 278 win.Name("/gitlab/" + project + "/" + strconv.Itoa(id)) 279 win.Fprintf("tag", "Comment Get ") 280 dummy.loadIssue() 281 go dummy.EventLoop(dummy) 282 } 283 284 func openProject(project string) { 285 dummy := &awin{} 286 win, err := acme.New() 287 if err != nil { 288 log.Fatal(err) 289 } 290 dummy.Win = win 291 win.Name("/gitlab/" + project + "/all") 292 win.Fprintf("tag", "New Get Search ") 293 dummy.loadIssueList() 294 dummy.EventLoop(dummy) 295 // root window deleted, time to exit 296 os.Exit(0) 297 } 298 299 /* 300 func parseAndPutComment(r io.Reader, project string) error { 301 id, body, err := parseIssueNote(r) 302 if err != nil { 303 return fmt.Errorf("parse issue note: %v", err) 304 } 305 return putIssueNote(id, project, body) 306 } 307 */ 308 309 func parseIssueNote(r io.Reader) (id int, body string, err error) { 310 sc := bufio.NewScanner(r) 311 builder := &strings.Builder{} 312 var issue int 313 var linenum int 314 for sc.Scan() { 315 linenum++ 316 if linenum == 1 { 317 text := strings.TrimPrefix(sc.Text(), "To:") 318 text = strings.TrimSpace(text) 319 id, err := strconv.Atoi(text) 320 if err != nil { 321 return 0, "", fmt.Errorf("parse issue id: %v", err) 322 } 323 issue = id 324 continue 325 } 326 if linenum == 2 && sc.Text() == "" { 327 // skip the first empty line between header and body 328 continue 329 } 330 builder.WriteString(sc.Text()) 331 // preserve newline stripped by scanner 332 builder.WriteString("\n") 333 } 334 if sc.Err() != nil { 335 return 0, "", err 336 } 337 return issue, builder.String(), nil 338 } 339 340 /* 341 func putIssueNote(id int, project, body string) error { 342 opt := &gitlab.CreateIssueNoteOptions{ 343 Body: &body, 344 } 345 _, _, err := client.Notes.CreateIssueNote(project, id, opt) 346 return err 347 } 348 */ 349 350 func parseIssue(r io.Reader) *Issue { 351 var issue Issue 352 sc := bufio.NewScanner(r) 353 headerDone := false 354 buf := &strings.Builder{} 355 for sc.Scan() { 356 line := sc.Text() 357 switch { 358 case strings.HasPrefix(line, "Title:"): 359 issue.Title = strings.TrimSpace(strings.TrimPrefix(line, "Title:")) 360 continue 361 } 362 if line == "" && !headerDone { 363 // hit a blank line, remaining body is the description 364 headerDone = true 365 continue 366 } 367 // can't use TrimSpace; we want to keep leading spaces. 368 line = strings.TrimRight(line, " \t") // spaces and tabs 369 buf.WriteString(line + "\n") // add back newline stripped by scanner 370 } 371 if sc.Err() != nil { 372 log.Println("parse issue:", sc.Err()) 373 } 374 issue.Description = buf.String() 375 return &issue 376 } 377 378 var client *Client 379 var hFlag = flag.String("h", "", "gitlab hostname") 380 var tFlag = flag.String("t", "", "personal access token file") 381 var pFlag = flag.String("p", "gitlab-org/gitlab", "project") 382 var debug = flag.Bool("d", false, "debug output") 383 384 func main() { 385 flag.Parse() 386 log.SetFlags(0) 387 log.SetPrefix("Gitlab: ") 388 var tokenPath string 389 if *tFlag != "" { 390 tokenPath = *tFlag 391 } else { 392 dir, err := os.UserConfigDir() 393 if err != nil { 394 log.Fatal(err) 395 } 396 host := *hFlag 397 if host == "" { 398 u, err := url.Parse(GitlabHosted) 399 if err != nil { 400 log.Fatalf("find gitlab hostname: %v", err) 401 } 402 host = u.Host 403 } 404 tokenPath = path.Join(dir, "gitlab", host) 405 } 406 b, err := os.ReadFile(tokenPath) 407 if err != nil && !errors.Is(err, fs.ErrNotExist) { 408 log.Fatalln("read token:", err) 409 } 410 client = &Client{ 411 BaseURL: *hFlag, 412 Token: strings.TrimSpace(string(b)), 413 } 414 415 project := *pFlag 416 go openProject(project) 417 418 select {} 419 }