commit - 0952bc0ae884782cff9729f9cc72b01610708e97
commit + a3fa2e888f4f864d7d31fbacd4d5b154ec9805c3
blob - /dev/null
blob + df2f98a67815a265c94d8f4dc1983fa4c57004b2 (mode 644)
--- /dev/null
+++ src/atom/atom.go
+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
+}
blob - /dev/null
blob + b869b9cba5499237d42ed8b310ee5df2cf0fb7c5 (mode 644)
--- /dev/null
+++ src/atom/atom_test.go
+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))
+ }
+}
blob - /dev/null
blob + 8ed2a6fc833a953a631bea7aa8478eca282f497d (mode 644)
--- /dev/null
+++ src/atom/testdata/1.xml
+<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
blob - /dev/null
blob + 328e2caaa79eadf4fc62b1d93671afd90599d59a (mode 644)
--- /dev/null
+++ src/atom/testdata/2.xml
+<?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 <em>lot</em> 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>
blob - /dev/null
blob + 745e813461522857ee6ee07c40b11a37ea42ddf8 (mode 644)
--- /dev/null
+++ src/bbfeed/bbfeed.go
+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()
+}
blob - /dev/null
blob + 95172ac6193ac40666287df8279eac282695b81c (mode 644)
--- /dev/null
+++ src/bbfeed/bitbucket.go
+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
+}
blob - /dev/null
blob + 84d4f7a876faeb63eccd392cbc3a16dc816ba37f (mode 644)
--- /dev/null
+++ src/bbfeed/server.go
+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)
+}