commit - e82076b19f23f436c28f921ebbea74e2703ea0cc
commit + 83150219a3b6c73f55c28090663d65f81cecce62
blob - /dev/null
blob + 16bfbc55e6c4131ab146b614d03342e6f232776d (mode 644)
--- /dev/null
+++ Gitlab/Gitlab.go
+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
+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
+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
+{
+ "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
+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
+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
+}