commit a3fa2e888f4f864d7d31fbacd4d5b154ec9805c3 from: Oliver Lowe date: Mon Nov 18 01:14:04 2024 UTC import bitbucket pull request webfeed server 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(">"), []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(">"), []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 @@ + + Example Feed + + 2003-12-13T18:30:02Z + + John Doe + + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + + Atom-Powered Robots Run Amok + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2003-12-13T18:30:02Z + Some text. + + \ No newline at end of file blob - /dev/null blob + 328e2caaa79eadf4fc62b1d93671afd90599d59a (mode 644) --- /dev/null +++ src/atom/testdata/2.xml @@ -0,0 +1,45 @@ + + + dive into mark + + A <em>lot</em> of effort + went into making this effortless + + 2005-07-31T12:29:29Z + tag:example.org,2003:3 + + + Copyright (c) 2003, Mark Pilgrim + + Example Toolkit + + + Atom draft-07 snapshot + + + tag:example.org,2003:3.2397 + 2005-07-31T12:29:29Z + 2003-12-13T08:29:29-04:00 + + Mark Pilgrim + http://example.org/ + f8dy@example.com + + + Sam Ruby + + + Joe Gregorio + + +
+

[Update: The Atom draft is finished.]

+
+
+
+
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) +}