issues

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

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 }