Commit Diff


commit - e82076b19f23f436c28f921ebbea74e2703ea0cc
commit + 83150219a3b6c73f55c28090663d65f81cecce62
blob - /dev/null
blob + 16bfbc55e6c4131ab146b614d03342e6f232776d (mode 644)
--- /dev/null
+++ Gitlab/Gitlab.go
@@ -0,0 +1,412 @@
+package main
+
+import (
+	"bufio"
+	"bytes"
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"path"
+	"regexp"
+	"strconv"
+	"strings"
+
+	"9fans.net/go/acme"
+)
+
+type awin struct {
+	*acme.Win
+	issue *Issue
+	// search query used by the Search command
+	query string
+}
+
+func (w *awin) name() string {
+	buf, err := w.ReadAll("tag")
+	if err != nil {
+		w.Err(err.Error())
+		return ""
+	}
+	name := strings.Fields(string(buf))[0]
+	return path.Base(name)
+}
+
+func (w *awin) project() string {
+	buf, err := w.ReadAll("tag")
+	if err != nil {
+		w.Err(err.Error())
+		return ""
+	}
+	name := strings.Fields(string(buf))[0]
+	dir := path.Dir(name)
+	return strings.TrimPrefix(dir, "/gitlab/")
+}
+
+func (w *awin) Execute(cmd string) bool {
+	switch cmd {
+	case "Get":
+		w.load()
+		return true
+	case "New":
+		newIssue(w.project())
+		return true
+	case "Comment":
+		var issueID int
+		if w.issue != nil {
+			issueID = w.issue.ID
+		}
+		createComment(issueID, w.project())
+		return true
+	case "Put":
+		buf := &bytes.Buffer{}
+		b, err := w.ReadAll("body")
+		if err != nil {
+			w.Err(err.Error())
+			return false
+		}
+		buf.Write(b)
+
+		switch w.name() {
+		case "comment":
+			w.Err("put comment not yet implemented")
+		case "new":
+			w.issue = parseIssue(buf)
+			w.issue, err = client.Create(w.project(), w.issue.Title, w.issue.Description)
+			w.Name(path.Join("/gitlab", w.project(), strconv.Itoa(w.issue.ID)))
+			w.load()
+		default:
+			err = errors.New("file is not comment or new issue")
+		}
+		if err != nil {
+			w.Err(fmt.Sprintf("put %s: %v", w.name(), err))
+			return false
+		}
+		w.Ctl("clean")
+		return true
+	}
+
+	if strings.HasPrefix(cmd, "Search") {
+		query := strings.TrimSpace(strings.TrimPrefix(cmd, "Search"))
+		createSearch(query, w.project())
+		return true
+	}
+
+	return false
+}
+
+func (w *awin) Look(text string) bool {
+	text = strings.TrimSpace(text)
+	text = strings.TrimPrefix(text, "#")
+	if regexp.MustCompile("^[0-9]+$").MatchString(text) {
+		id, err := strconv.Atoi(text)
+		if err != nil {
+			w.Err(err.Error())
+			return false
+		}
+		name := path.Join("/gitlab", w.project(), strconv.Itoa(id))
+		if acme.Show(name) != nil {
+			return true
+		}
+		openIssue(id, w.project())
+		return true
+	}
+	return false
+}
+
+func (w *awin) load() {
+	if w.name() == "all" || w.query != "" {
+		w.loadIssueList()
+	} else if regexp.MustCompile("^[0-9]+").MatchString(w.name()) {
+		w.loadIssue()
+	}
+}
+
+func (w *awin) loadIssueList() {
+	w.Ctl("dirty")
+	defer w.Ctl("clean")
+
+	search := make(map[string]string)
+	var err error
+	if w.query != "" {
+		search, err = parseSearch(w.query)
+		if err != nil {
+			w.Err(err.Error())
+			return
+		}
+	}
+	issues, err := client.Issues(w.project(), search)
+	if err != nil {
+		w.Err(err.Error())
+		return
+	}
+	w.Clear()
+	buf := &bytes.Buffer{}
+	printIssueList(buf, issues)
+	w.Write("body", buf.Bytes())
+	w.Ctl("dot=addr")
+}
+
+func (w *awin) loadIssue() {
+	w.Ctl("dirty")
+	defer w.Ctl("clean")
+	id, err := strconv.Atoi(w.name())
+	if err != nil {
+		w.Err(fmt.Sprintf("parse window name as issue id: %v", err))
+		return
+	}
+	issue, err := client.Issue(w.project(), id)
+	if err != nil {
+		w.Err(err.Error())
+		return
+	}
+	w.issue = issue
+	w.Clear()
+	buf := &bytes.Buffer{}
+	printIssue(buf, issue)
+
+	/*
+		// TODO(otl): we can't load issue notes yet.
+		asc := "asc"
+		sortAscending := &gitlab.ListIssueNotesOptions{
+			Sort: &asc,
+		}
+		notes, _, err := client.Notes.ListIssueNotes(w.project(), id, sortAscending)
+		if err != nil {
+			w.Err(err.Error())
+		}
+		printNotes(buf, notes)
+	*/
+
+	w.Write("body", buf.Bytes())
+	w.Ctl("dot=addr")
+}
+
+func printIssueList(w io.Writer, issues []Issue) {
+	for i := range issues {
+		fmt.Fprintln(w, issues[i].ID, issues[i].Title)
+	}
+}
+
+func printIssue(w io.Writer, issue *Issue) {
+	fmt.Fprintln(w, "Title:", issue.Title)
+	fmt.Fprintln(w, "State:", issue.State)
+	fmt.Fprintln(w, "Author:", issue.Author.Username)
+	// TODO(otl): we don't store assignees in Issue yet.
+	// fmt.Fprint(w, "Assignee: ")
+	// if len(issue.Assignees) > 0 {
+	//	var v []string
+	//	for _, a := range issue.Assignees {
+	//		v = append(v, a.Username)
+	//	}
+	//	fmt.Fprint(w, strings.Join(v, ", "))
+	//}
+	//fmt.Fprintln(w)
+	fmt.Fprintln(w, "Created:", issue.Created)
+	fmt.Fprintln(w, "URL:", issue.URL)
+	if !issue.Closed.IsZero() {
+		fmt.Fprintln(w, "Closed:", issue.Closed)
+	}
+	fmt.Fprintln(w, "")
+	fmt.Fprintln(w, issue.Description)
+}
+
+/*
+func printNotes(w io.Writer, notes []*gitlab.Note) {
+	for i := range notes {
+		fmt.Fprintf(w, "%s (%s):\n", notes[i].Author.Username, notes[i].CreatedAt)
+		fmt.Fprintln(w)
+		fmt.Fprintln(w, notes[i].Body)
+		fmt.Fprintln(w)
+	}
+}
+*/
+
+func newIssue(project string) {
+	win, err := acme.New()
+	if err != nil {
+		log.Print(err)
+	}
+	win.Name(path.Join("/gitlab", project, "new"))
+	win.Fprintf("tag", "Put ")
+	dummy := &awin{
+		Win: win,
+	}
+	dummy.Write("body", []byte("Title: "))
+	go dummy.EventLoop(dummy)
+}
+
+func createComment(id int, project string) {
+	dummy := &awin{}
+	win, err := acme.New()
+	if err != nil {
+		log.Print(err)
+	}
+	dummy.Win = win
+	dummy.Name("/gitlab/" + project + "/comment")
+	win.Fprintf("tag", "Put ")
+	body := fmt.Sprintf("To: %d\n\n", id)
+	dummy.Write("body", []byte(body))
+	go dummy.EventLoop(dummy)
+}
+
+func createSearch(query, project string) {
+	dummy := &awin{}
+	win, err := acme.New()
+	if err != nil {
+		log.Print(err)
+	}
+	dummy.Win = win
+	dummy.Name("/gitlab/" + project + "/search")
+	win.Fprintf("tag", "New Get ")
+	dummy.query = query
+	dummy.loadIssueList()
+	go dummy.EventLoop(dummy)
+}
+
+func openIssue(id int, project string) {
+	dummy := &awin{}
+	win, err := acme.New()
+	if err != nil {
+		log.Fatal(err)
+	}
+	dummy.Win = win
+	win.Name("/gitlab/" + project + "/" + strconv.Itoa(id))
+	win.Fprintf("tag", "Comment Get ")
+	dummy.loadIssue()
+	go dummy.EventLoop(dummy)
+}
+
+func openProject(project string) {
+	dummy := &awin{}
+	win, err := acme.New()
+	if err != nil {
+		log.Fatal(err)
+	}
+	dummy.Win = win
+	win.Name("/gitlab/" + project + "/all")
+	win.Fprintf("tag", "New Get Search ")
+	dummy.loadIssueList()
+	dummy.EventLoop(dummy)
+	// root window deleted, time to exit
+	os.Exit(0)
+}
+
+/*
+func parseAndPutComment(r io.Reader, project string) error {
+	id, body, err := parseIssueNote(r)
+	if err != nil {
+		return fmt.Errorf("parse issue note: %v", err)
+	}
+	return putIssueNote(id, project, body)
+}
+*/
+
+func parseIssueNote(r io.Reader) (id int, body string, err error) {
+	sc := bufio.NewScanner(r)
+	builder := &strings.Builder{}
+	var issue int
+	var linenum int
+	for sc.Scan() {
+		linenum++
+		if linenum == 1 {
+			text := strings.TrimPrefix(sc.Text(), "To:")
+			text = strings.TrimSpace(text)
+			id, err := strconv.Atoi(text)
+			if err != nil {
+				return 0, "", fmt.Errorf("parse issue id: %v", err)
+			}
+			issue = id
+			continue
+		}
+		if linenum == 2 && sc.Text() == "" {
+			// skip the first empty line between header and body
+			continue
+		}
+		builder.WriteString(sc.Text())
+		// preserve newline stripped by scanner
+		builder.WriteString("\n")
+	}
+	if sc.Err() != nil {
+		return 0, "", err
+	}
+	return issue, builder.String(), nil
+}
+
+/*
+func putIssueNote(id int, project, body string) error {
+	opt := &gitlab.CreateIssueNoteOptions{
+		Body: &body,
+	}
+	_, _, err := client.Notes.CreateIssueNote(project, id, opt)
+	return err
+}
+*/
+
+func parseIssue(r io.Reader) *Issue {
+	var issue Issue
+	sc := bufio.NewScanner(r)
+	headerDone := false
+	buf := &strings.Builder{}
+	for sc.Scan() {
+		line := sc.Text()
+		switch {
+		case strings.HasPrefix(line, "Title:"):
+			issue.Title = strings.TrimSpace(strings.TrimPrefix(line, "Title:"))
+			continue
+		}
+		if line == "" && !headerDone {
+			// hit a blank line, remaining body is the description
+			headerDone = true
+			continue
+		}
+		// can't use TrimSpace; we want to keep leading spaces.
+		line = strings.TrimRight(line, " \t") // spaces and tabs
+		buf.WriteString(line + "\n")          // add back newline stripped by scanner
+	}
+	if sc.Err() != nil {
+		log.Println("parse issue:", sc.Err())
+	}
+	issue.Description = buf.String()
+	return &issue
+}
+
+var client *Client
+var hFlag = flag.String("h", "", "gitlab hostname")
+var tFlag = flag.String("t", "", "personal access token file")
+var pFlag = flag.String("p", "gitlab-org/gitlab", "project")
+
+func main() {
+	flag.Parse()
+	log.SetFlags(0)
+	log.SetPrefix("Gitlab:")
+	var tokenPath string
+	if *tFlag != "" {
+		tokenPath = *tFlag
+	} else {
+		dir, err := os.UserConfigDir()
+		if err != nil {
+			log.Fatal(err)
+		}
+		host := *hFlag
+		tokenPath = path.Join(dir, "gitlab", host)
+	}
+	b, err := os.ReadFile(tokenPath)
+	if err != nil {
+		log.Fatal("read token:", err)
+	}
+	client = &Client{
+		BaseURL: *hFlag,
+		Token:   strings.TrimSpace(string(b)),
+	}
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	project := *pFlag
+	go openProject(project)
+
+	select {}
+}
blob - /dev/null
blob + 95e0b272a29a985a837859dff519e07fc9b730f1 (mode 644)
--- /dev/null
+++ Gitlab/client.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"path"
+	"strconv"
+	"time"
+)
+
+const GitlabHosted string = "https://gitlab.com/api/v4"
+
+type gError struct {
+	Message any
+}
+
+func (e gError) Error() string {
+	switch v := e.Message.(type) {
+	case string:
+		return v
+	}
+	return "unknown"
+}
+
+type Issue struct {
+	ID      int       `json:"iid"`
+	Title   string    `json:"title"`
+	Created time.Time `json:"created_at"`
+	Updated time.Time `json:"updated_at"`
+	Closed  time.Time `json:"closed_at"`
+	State   string    `json:"state"`
+	Author  struct {
+		ID       int    `json:"id"`
+		Username string `json:"username"`
+	}
+	Description string   `json:"description"`
+	Labels      []string `json:"labels"`
+	URL         string   `json:"web_url"`
+}
+
+type Client struct {
+	*http.Client
+	BaseURL string
+	Token   string
+}
+
+func (c *Client) Issues(project string, search map[string]string) ([]Issue, error) {
+	p := path.Join("projects", url.PathEscape(project), "issues")
+	resp, err := c.get(p)
+	if err != nil {
+		return nil, err
+	}
+	defer resp.Body.Close()
+	var issues []Issue
+	if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil {
+		return nil, fmt.Errorf("decode response: %w", err)
+	}
+	return issues, nil
+}
+
+func (c *Client) Issue(project string, id int) (*Issue, error) {
+	p := path.Join("projects", url.PathEscape(project), "issues", strconv.Itoa(id))
+	resp, err := c.get(p)
+	if err != nil {
+		return nil, err
+	}
+	var issue Issue
+	if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
+		return nil, fmt.Errorf("decode response: %w", err)
+	}
+	return &issue, nil
+}
+
+func (c *Client) Create(project, title, desc string) (*Issue, error) {
+	m := map[string]string{
+		"title":       title,
+		"description": desc,
+	}
+	b, err := json.Marshal(m)
+	if err != nil {
+		return nil, err
+	}
+	p := path.Join("projects", url.PathEscape(project), "issues")
+	resp, err := c.post(p, "application/json", bytes.NewReader(b))
+	if err != nil {
+		return nil, err
+	}
+	var issue Issue
+	if err := json.NewDecoder(resp.Body).Decode(&issue); err != nil {
+		return nil, fmt.Errorf("decode created issue: %w", err)
+	}
+	return &issue, nil
+}
+
+func (c *Client) get(path string) (*http.Response, error) {
+	if c.BaseURL == "" {
+		c.BaseURL = GitlabHosted
+	}
+	u := fmt.Sprintf("%s/%s", c.BaseURL, path)
+	req, err := http.NewRequest(http.MethodGet, u, nil)
+	if err != nil {
+		return nil, err
+	}
+	return c.do(req)
+}
+
+func (c *Client) post(path, contentType string, body io.Reader) (*http.Response, error) {
+	if c.BaseURL == "" {
+		c.BaseURL = GitlabHosted
+	}
+	u := fmt.Sprintf("%s/%s", c.BaseURL, path)
+	req, err := http.NewRequest(http.MethodPost, u, body)
+	if err != nil {
+		return nil, err
+	}
+	req.Header.Set("Content-Type", contentType)
+	resp, err := c.do(req)
+	if err != nil {
+		return nil, err
+	}
+	if resp.StatusCode != http.StatusCreated {
+		return nil, fmt.Errorf("unexpected status %s", resp.Status)
+	}
+	return resp, nil
+}
+
+func (c *Client) do(req *http.Request) (*http.Response, error) {
+	if c.Client == nil {
+		c.Client = http.DefaultClient
+	}
+	if c.BaseURL == "" {
+		c.BaseURL = "https://gitlab.com/api/v4"
+	}
+
+	if c.Token != "" {
+		req.Header.Set("Authorization", "Bearer "+c.Token)
+	}
+
+	req.Header.Set("Accept", "application/json")
+	resp, err := c.Do(req)
+	if err != nil {
+		return nil, err
+	}
+	if resp.StatusCode >= http.StatusBadRequest {
+		var e gError
+		if err := json.NewDecoder(resp.Body).Decode(&e); err != nil {
+			resp.Body.Close()
+			return nil, fmt.Errorf("%s %s: %s: decode error message: %w", req.Method, req.URL, resp.Status, err)
+		}
+		resp.Body.Close()
+		return nil, fmt.Errorf("%s %s: %s: %w", req.Method, req.URL, resp.Status, e)
+	}
+	return resp, err
+}
blob - /dev/null
blob + 1f2512682687248015c9446f42578ebf959f8843 (mode 644)
--- /dev/null
+++ Gitlab/gitlab_test.go
@@ -0,0 +1,21 @@
+package main
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"testing"
+)
+
+func TestIssues(t *testing.T) {
+	f, err := os.Open("issue.json")
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	var issue Issue
+	if err := json.NewDecoder(f).Decode(&issue); err != nil {
+		t.Fatalf("decode issue: %v", err)
+	}
+	fmt.Println(issue)
+}
blob - /dev/null
blob + 6277a0ca905025ea4bd9d5416c259a2e5f64f627 (mode 644)
--- /dev/null
+++ Gitlab/issue.json
@@ -0,0 +1,67 @@
+{
+    "id": 148748291,
+    "iid": 235,
+    "project_id": 34707535,
+    "title": "Make github_issue_add_labels use gcli_jsongen",
+    "description": "I missed this one for some reason.",
+    "state": "opened",
+    "created_at": "2024-07-04T17:30:47.401Z",
+    "updated_at": "2024-07-08T20:05:09.970Z",
+    "closed_at": null,
+    "closed_by": null,
+    "labels": [
+        "good-first-issue"
+    ],
+    "milestone": null,
+    "assignees": [],
+    "author": {
+        "id": 5980462,
+        "username": "herrhotzenplotz",
+        "name": "Nico Sonack",
+        "state": "active",
+        "locked": false,
+        "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/5980462/avatar.png",
+        "web_url": "https://gitlab.com/herrhotzenplotz"
+    },
+    "type": "ISSUE",
+    "assignee": null,
+    "user_notes_count": 0,
+    "merge_requests_count": 0,
+    "upvotes": 0,
+    "downvotes": 0,
+    "due_date": null,
+    "confidential": false,
+    "discussion_locked": null,
+    "issue_type": "issue",
+    "web_url": "https://gitlab.com/herrhotzenplotz/gcli/-/issues/235",
+    "time_stats": {
+        "time_estimate": 0,
+        "total_time_spent": 0,
+        "human_time_estimate": null,
+        "human_total_time_spent": null
+    },
+    "task_completion_status": {
+        "count": 0,
+        "completed_count": 0
+    },
+    "blocking_issues_count": 0,
+    "has_tasks": true,
+    "task_status": "0 of 0 checklist items completed",
+    "_links": {
+        "self": "https://gitlab.com/api/v4/projects/34707535/issues/235",
+        "notes": "https://gitlab.com/api/v4/projects/34707535/issues/235/notes",
+        "award_emoji": "https://gitlab.com/api/v4/projects/34707535/issues/235/award_emoji",
+        "project": "https://gitlab.com/api/v4/projects/34707535",
+        "closed_as_duplicate_of": null
+    },
+    "references": {
+        "short": "#235",
+        "relative": "#235",
+        "full": "herrhotzenplotz/gcli#235"
+    },
+    "severity": "UNKNOWN",
+    "moved_to_id": null,
+    "imported": false,
+    "imported_from": "none",
+    "service_desk_reply_to": null
+}
blob - /dev/null
blob + b5e8efaedaf76f2ab4d649a368588532c54565d9 (mode 644)
--- /dev/null
+++ Gitlab/issue_test.go
@@ -0,0 +1,26 @@
+package main
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestParseIssue(t *testing.T) {
+	r := strings.NewReader(`Title: VizOne asset metadata fetched as XML, can get JSON
+State: opened
+Assignee:
+Created: 2022-08-04 03:04:01.233 +0000 UTC
+URL: https://gitlab.skyracing.cloud/sol1-software/ai/job-service/-/issues/22
+
+Woody helpfully showed us that we can get asset metadata as JSON instead of atom/xml! Cool!
+
+Right now we're importing a package xml2js then doing tedious object attribute traversal.
+
+const meta = {
+  title: data['atom:entry']['atom:title'] ? data['atom:entry']['atom:title'][0] : null,
+  video: data['atom:entry']['media:group'] ? data['atom:entry']['media:group'][0]['media:content'][0]['$'] : null,
+  updated: data['atom:entry']['atom:updated'] ? data['atom:entry']['atom:updated'][0] : null,
+  author: data['atom:entry']['atom:author'] ? data['atom:entry']['atom:author'][0]['atom:name'][0] : null,
+  mediastatus: data['atom:entry']['mam:mediastatus'] ? data['atom:entry']['mam:mediastatus'][0] : null,`)
+	parseIssue(r)
+}
blob - /dev/null
blob + 9d854174867ad404f639ea8c69e88e0a12a3c06e (mode 644)
--- /dev/null
+++ Gitlab/search.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+	"fmt"
+	"strings"
+)
+
+// parseSearch parses a search from a query string.
+// A query string has a form similar to a search query of the Github REST API;
+// it consists of keywords and qualifiers separated by whitespace.
+// A qualifier is a string of the form "param:value". A keyword is a plain string.
+// An example query: "database crash assignee:oliver"
+// Another: "state:closed panic fatal"
+func parseSearch(query string) (map[string]string, error) {
+	search := make(map[string]string)
+	for _, field := range strings.Fields(query) {
+		arg := strings.SplitN(field, ":", 2)
+		if len(arg) == 1 {
+			// concatenate keywords with spaces between each
+			search["search"] = search["search"] + " " + arg[0]
+			continue
+		}
+		switch arg[0] {
+		case "state", "assignee":
+			search[arg[0]] = arg[1]
+		default:
+			return nil, fmt.Errorf("unknown qualifier %s", arg[0])
+		}
+	}
+	return search, nil
+}