Commit Diff


commit - 0952bc0ae884782cff9729f9cc72b01610708e97
commit + a3fa2e888f4f864d7d31fbacd4d5b154ec9805c3
blob - /dev/null
blob + df2f98a67815a265c94d8f4dc1983fa4c57004b2 (mode 644)
--- /dev/null
+++ 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
+}
blob - /dev/null
blob + b869b9cba5499237d42ed8b310ee5df2cf0fb7c5 (mode 644)
--- /dev/null
+++ 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))
+	}
+}
blob - /dev/null
blob + 8ed2a6fc833a953a631bea7aa8478eca282f497d (mode 644)
--- /dev/null
+++ 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
blob - /dev/null
blob + 328e2caaa79eadf4fc62b1d93671afd90599d59a (mode 644)
--- /dev/null
+++ 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>
blob - /dev/null
blob + 745e813461522857ee6ee07c40b11a37ea42ddf8 (mode 644)
--- /dev/null
+++ 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()
+}
blob - /dev/null
blob + 95172ac6193ac40666287df8279eac282695b81c (mode 644)
--- /dev/null
+++ 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
+}
blob - /dev/null
blob + 84d4f7a876faeb63eccd392cbc3a16dc816ba37f (mode 644)
--- /dev/null
+++ 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)
+}