x

Programs, configuration and documentation that don't fit anywhere else
Log | Files | Refs | README | LICENSE

commit a3fa2e888f4f864d7d31fbacd4d5b154ec9805c3
parent 0952bc0ae884782cff9729f9cc72b01610708e97
Author: Oliver Lowe <o@olowe.co>
Date:   Mon, 18 Nov 2024 12:14:04 +1100

import bitbucket pull request webfeed server

Diffstat:
Asrc/atom/atom.go | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/atom/atom_test.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/atom/testdata/1.xml | 17+++++++++++++++++
Asrc/atom/testdata/2.xml | 45+++++++++++++++++++++++++++++++++++++++++++++
Asrc/bbfeed/bbfeed.go | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/bbfeed/bitbucket.go | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/bbfeed/server.go | 45+++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 474 insertions(+), 0 deletions(-)

diff --git a/src/atom/atom.go b/src/atom/atom.go @@ -0,0 +1,57 @@ +package atom + +import ( + "encoding/xml" + "time" +) + +const xmlns = "http://www.w3.org/2005/Atom" + +type Feed struct { + ID string `xml:"id"` + Title string `xml:"title"` + Updated time.Time `xml:"updated"` + Author *Author `xml:"author,omitempty"` + Link []Link `xml:"link,omitempty"` + Subtitle string `xml:"subtitle,omitempty"` + Entries []Entry `xml:"entry"` +} + +type feed struct { + XMLName struct{} `xml:"feed"` + Namespace string `xml:"xmlns,attr"` + *Feed +} + +type Author struct { + Name string `xml:"name"` + URI string `xml:"uri,omitempty"` + Email string `xml:"email,omitempty"` +} + +type Entry struct { + ID string `xml:"id"` + Title string `xml:"title"` + Updated time.Time `xml:"updated,omitempty"` + Author Author `xml:"author"` + Links []Link `xml:"link"` + Summary string `xml:"summary,omitempty"` + Content []byte `xml:"content,omitempty"` + Published *time.Time `xml:"published,omitempty"` +} + +type Link struct { + HRef string `xml:"href,attr,omitempty"` + Rel string `xml:"rel,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` +} + +func Marshal(f *Feed) ([]byte, error) { + f1 := &feed{ + Namespace: xmlns, + Feed: f, + } + return xml.MarshalIndent(f1, "", "\t") + // b = bytes.ReplaceAll(b, []byte("></link>"), []byte("/>")) + // return b, err +} diff --git a/src/atom/atom_test.go b/src/atom/atom_test.go @@ -0,0 +1,52 @@ +package atom + +import ( + "bytes" + "encoding/xml" + "fmt" + "os" + "testing" + "time" +) + +func TestMarshal(t *testing.T) { + f := &Feed{ + Title: "Example Feed", + Link: []Link{ + {HRef: "http://example.org/"}, + }, + Updated: time.Date(2003, time.Month(12), 13, 18, 30, 2, 0, time.UTC), + Author: Author{Name: "John Doe"}, + ID: "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6", + Entries: []Entry{ + { + Title: "Atom-Powered Robots Run Amok", + ID: "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a", + Links: []Link{ + {HRef: "http://example.org/2003/12/13/atom03"}, + }, + Updated: time.Date(2003, time.Month(12), 13, 18, 30, 2, 0, time.UTC), + Summary: "Some text.", + }, + }, + } + feed1 := &feed{ + Namespace: xmlns, + Feed: f, + } + got, err := xml.MarshalIndent(feed1, "", " ") + if err != nil { + t.Fatal(err) + } + got = bytes.ReplaceAll(got, []byte("></link>"), []byte("/>")) + + want, err := os.ReadFile("testdata/1.xml") + if err != nil { + t.Fatal(err) + } + + if !bytes.Equal(got, want) { + t.Errorf("oops") + fmt.Fprintln(os.Stderr, string(got)) + } +} diff --git a/src/atom/testdata/1.xml b/src/atom/testdata/1.xml @@ -0,0 +1,16 @@ +<feed xmlns="http://www.w3.org/2005/Atom"> + <title>Example Feed</title> + <link href="http://example.org/"/> + <updated>2003-12-13T18:30:02Z</updated> + <author> + <name>John Doe</name> + </author> + <id>urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6</id> + <entry> + <title>Atom-Powered Robots Run Amok</title> + <link href="http://example.org/2003/12/13/atom03"/> + <id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id> + <updated>2003-12-13T18:30:02Z</updated> + <summary>Some text.</summary> + </entry> +</feed> +\ No newline at end of file diff --git a/src/atom/testdata/2.xml b/src/atom/testdata/2.xml @@ -0,0 +1,45 @@ +<?xml version="1.0" encoding="utf-8"?> +<feed xmlns="http://www.w3.org/2005/Atom"> + <title type="text">dive into mark</title> + <subtitle type="html"> + A &lt;em&gt;lot&lt;/em&gt; of effort + went into making this effortless + </subtitle> + <updated>2005-07-31T12:29:29Z</updated> + <id>tag:example.org,2003:3</id> + <link rel="alternate" type="text/html" + hreflang="en" href="http://example.org/"/> + <link rel="self" type="application/atom+xml" + href="http://example.org/feed.atom"/> + <rights>Copyright (c) 2003, Mark Pilgrim</rights> + <generator uri="http://www.example.com/" version="1.0"> + Example Toolkit + </generator> + <entry> + <title>Atom draft-07 snapshot</title> + <link rel="alternate" type="text/html" + href="http://example.org/2005/04/02/atom"/> + <link rel="enclosure" type="audio/mpeg" length="1337" + href="http://example.org/audio/ph34r_my_podcast.mp3"/> + <id>tag:example.org,2003:3.2397</id> + <updated>2005-07-31T12:29:29Z</updated> + <published>2003-12-13T08:29:29-04:00</published> + <author> + <name>Mark Pilgrim</name> + <uri>http://example.org/</uri> + <email>f8dy@example.com</email> + </author> + <contributor> + <name>Sam Ruby</name> + </contributor> + <contributor> + <name>Joe Gregorio</name> + </contributor> + <content type="xhtml" xml:lang="en" + xml:base="http://diveintomark.org/"> + <div xmlns="http://www.w3.org/1999/xhtml"> + <p><i>[Update: The Atom draft is finished.]</i></p> + </div> + </content> + </entry> +</feed> diff --git a/src/bbfeed/bbfeed.go b/src/bbfeed/bbfeed.go @@ -0,0 +1,135 @@ +package main + +import ( + "archive/tar" + "bytes" + "fmt" + "io/fs" + "log" + "net/http" + "os" + "os/user" + "path" + "strconv" + + "olowe.co/x/atom" +) + +func Feed(prs []PullRequest) *atom.Feed { + feed := atom.Feed{ + Entries: make([]atom.Entry, len(prs)), + } + for i, pr := range prs { + pr := pr + feed.Entries[i] = atom.Entry{ + Title: pr.Title, + ID: pr.Links.Self.HRef, + Links: []atom.Link{ + {pr.Links.Self.HRef + "/patch", "alternate", "application/mbox"}, + {pr.Links.HTML.HRef, "canonical", "text/html"}, + }, + Updated: pr.Updated, + Published: &pr.Created, + Summary: pr.Description, + Author: atom.Author{Name: pr.Author.DisplayName}, + Content: []byte(pr.Summary.HTML), + } + if pr.Updated.After(feed.Updated) { + feed.Updated = pr.Updated + } + } + return &feed +} + +func readAuth() (username, password string, err error) { + confDir, err := os.UserConfigDir() + if err != nil { + return "", "", err + } + + b, err := os.ReadFile(path.Join(confDir, "atlassian/bitbucket.org")) + if err != nil { + return "", "", err + } + b = bytes.TrimSpace(b) + u, p, ok := bytes.Cut(b, []byte(":")) + if !ok { + return "", "", fmt.Errorf("parse credentials: missing %q separator", ":") + } + return string(u), string(p), nil +} + +const usage string = "usage: bbfeed" + +func main() { + if len(os.Args) != 1 { + fmt.Println(usage) + os.Exit(2) + } + + username, password, err := readAuth() + if err != nil { + log.Fatalf("read credentials: %v", err) + } + client := &Client{username, password, http.DefaultClient} + srv := &Server{client} + http.HandleFunc("/", srv.handleReq) + log.Fatal(http.ListenAndServe(":8069", nil)) + return + + + workspace := os.Args[1] + repos, err := client.Repositories(workspace) + if err != nil { + log.Fatalf("get repositories in %s: %v", os.Args[1], err) + } + + u, err := user.Current() + if err != nil { + log.Fatalf("lookup current user: %v", err) + } + uid, err := strconv.Atoi(u.Uid) + if err != nil { + log.Fatalf("parse uid: %v", err) + } + gid, err := strconv.Atoi(u.Gid) + if err != nil { + log.Fatalf("parse gid: %v", err) + } + tw := tar.NewWriter(os.Stdout) + tw.WriteHeader(&tar.Header{ + Name: path.Dir(os.Args[1]) + "/", + Mode: int64(0o444 | fs.ModeDir), + Uid: uid, + Gid: gid, + }) + for _, repo := range repos { + name := path.Join(workspace, repo.Name) + prs, err := client.PullRequests(workspace, repo.Name) + if err != nil { + log.Printf("get %s pull requests: %v", name, err) + continue + } + feed := Feed(prs) + feed.Title = name + " pull requests" + feed.ID = fmt.Sprintf("https://bitbucket.org/" + name) + b, err := atom.Marshal(feed) + if err != nil { + log.Printf("marshal %s feed: %v", name, err) + continue + } + hdr := tar.Header{ + Name: name, + Size: int64(len(b)), + Mode: 0o644, + Uid: uid, + Gid: gid, + } + if err := tw.WriteHeader(&hdr); err != nil { + log.Fatal(err) + } + tw.Write(b) + } + tw.Flush() + tw.Close() +} diff --git a/src/bbfeed/bitbucket.go b/src/bbfeed/bitbucket.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "path" + "time" +) + +// https://developer.atlassian.com/cloud/bitbucket/rest/ + +const apiRoot string = "https://api.bitbucket.org/2.0/" + +var errNotExist = errors.New("no such repository") + +type Repository struct { + Name string + Description string +} + +type PullRequest struct { + ID int + Title string + Description string + Summary struct { + Raw string + HTML string + } + Created time.Time `json:"created_on"` + Updated time.Time `json:"updated_on"` + Author struct { + Type string + DisplayName string `json:"display_name"` + } + Links struct { + Self struct { + HRef string + } + HTML struct { + HRef string + } + } + State string +} + +type Client struct { + Username, Password string + *http.Client +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + req.Header.Set("Accept", "application/json") + req.SetBasicAuth(c.Username, c.Password) + return c.Client.Do(req) +} + +func (c *Client) PullRequests(workspace, repo string) ([]PullRequest, error) { + u, err := url.Parse(apiRoot + path.Join("repositories", workspace, repo, "pullrequests")) + if err != nil { + return nil, err + } + q := u.Query() + q.Add("state", "OPEN") + q.Add("state", "MERGED") + q.Add("state", "DECLINED") + u.RawQuery = q.Encode() + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + resp, err := c.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, errNotExist + } else if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-ok response status: %s", resp.Status) + } + v := struct { + Values []PullRequest + }{} + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return nil, err + } + return v.Values, nil +} + +func (c *Client) Repositories(workspace string) ([]Repository, error) { + var repos []Repository + type results struct { + Next string + Values []Repository + } + next := apiRoot + path.Join("repositories", workspace) + for next != "" { + req, err := http.NewRequest(http.MethodGet, next, nil) + if err != nil { + return repos, err + } + resp, err := c.Do(req) + if err != nil { + return repos, err + } + if resp.StatusCode == http.StatusNotFound { + return repos, fmt.Errorf("no such workspace") + } else if resp.StatusCode > 399 { + return repos, fmt.Errorf("non-ok status %s", resp.Status) + } + var v results + if err := json.NewDecoder(resp.Body).Decode(&v); err != nil { + return repos, fmt.Errorf("decode repositories: %w", err) + } + resp.Body.Close() + next = v.Next + repos = append(repos, v.Values...) + } + return repos, nil +} diff --git a/src/bbfeed/server.go b/src/bbfeed/server.go @@ -0,0 +1,45 @@ +package main + +import ( + "errors" + "fmt" + "log" + "net/http" + "path" + "strings" + + "olowe.co/x/atom" +) + +type Server struct { + client *Client +} + +func (srv *Server) handleReq(w http.ResponseWriter, r *http.Request) { + log.Println(r.Method, r.URL) + workspace := strings.TrimPrefix(path.Dir(r.URL.String()), "/") + repo := path.Base(r.URL.String()) + prs, err := srv.client.PullRequests(workspace, repo) + if errors.Is(err, errNotExist) { + http.NotFound(w, r) + return + } else if err != nil { + msg := fmt.Sprintf("get pull requests for %s/%s: %v", workspace, repo, err) + log.Println(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + feed := Feed(prs) + feed.Title = fmt.Sprintf("%s/%s pull requests", workspace, repo) + feed.ID = fmt.Sprintf("https://bitbucket.org/%s/%s", workspace, repo) + feed.Link = []atom.Link{{feed.ID, "alternate", "text/html"}} + b, err := atom.Marshal(feed) + if err != nil { + msg := fmt.Sprintf("marshal atom feed: %v", err) + log.Println(msg) + http.Error(w, msg, http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/atom+xml") + w.Write(b) +}