commit 83150219a3b6c73f55c28090663d65f81cecce62 from: Oliver Lowe date: Sun Nov 03 22:21:57 2024 UTC Gitlab: import from olowe.co/gitlab No point having these two very similar programs in two different repositories. The packages themselves aren't really intended to be imported by other projects. 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 +}