Merge pull request #79 from Steap/feature/bridge-launchpad

Michael Muré created

WIP: Initial Launchpad bridge.

Change summary

bridge/bridges.go                 |   1 
bridge/launchpad/config.go        |  50 +++++++++
bridge/launchpad/import.go        |  80 ++++++++++++++
bridge/launchpad/launchpad.go     |  24 ++++
bridge/launchpad/launchpad_api.go | 178 +++++++++++++++++++++++++++++++++
5 files changed, 333 insertions(+)

Detailed changes

bridge/bridges.go 🔗

@@ -4,6 +4,7 @@ package bridge
 import (
 	"github.com/MichaelMure/git-bug/bridge/core"
 	_ "github.com/MichaelMure/git-bug/bridge/github"
+	_ "github.com/MichaelMure/git-bug/bridge/launchpad"
 	"github.com/MichaelMure/git-bug/cache"
 	"github.com/MichaelMure/git-bug/repository"
 )

bridge/launchpad/config.go 🔗

@@ -0,0 +1,50 @@
+package launchpad
+
+import (
+	"bufio"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/repository"
+)
+
+const keyProject = "project"
+
+func (*Launchpad) Configure(repo repository.RepoCommon) (core.Configuration, error) {
+	conf := make(core.Configuration)
+
+	projectName, err := promptProjectName()
+	if err != nil {
+		return nil, err
+	}
+
+	conf[keyProject] = projectName
+
+	return conf, nil
+}
+
+func promptProjectName() (string, error) {
+	for {
+		fmt.Print("Launchpad project name: ")
+
+		line, err := bufio.NewReader(os.Stdin).ReadString('\n')
+		if err != nil {
+			return "", err
+		}
+
+		line = strings.TrimRight(line, "\n")
+
+		if line == "" {
+			fmt.Println("Project name is empty")
+			continue
+		}
+
+		return line, nil
+	}
+}
+
+func (*Launchpad) ValidateConfig(conf core.Configuration) error {
+	return nil
+}

bridge/launchpad/import.go 🔗

@@ -0,0 +1,80 @@
+package launchpad
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/MichaelMure/git-bug/bridge/core"
+	"github.com/MichaelMure/git-bug/bug"
+	"github.com/MichaelMure/git-bug/cache"
+	"github.com/pkg/errors"
+)
+
+type launchpadImporter struct {
+	conf core.Configuration
+}
+
+func (li *launchpadImporter) Init(conf core.Configuration) error {
+	li.conf = conf
+	return nil
+}
+
+const keyLaunchpadID = "launchpad-id"
+
+func (li *launchpadImporter) makePerson(owner LPPerson) bug.Person {
+	return bug.Person{
+		Name:      owner.Name,
+		Email:     "",
+		Login:     owner.Login,
+		AvatarUrl: "",
+	}
+}
+
+func (li *launchpadImporter) ImportAll(repo *cache.RepoCache) error {
+	lpAPI := new(launchpadAPI)
+
+	err := lpAPI.Init()
+	if err != nil {
+		return err
+	}
+
+	lpBugs, err := lpAPI.SearchTasks(li.conf["project"])
+	if err != nil {
+		return err
+	}
+
+	for _, lpBug := range lpBugs {
+		lpBugID := fmt.Sprintf("%d", lpBug.ID)
+		_, err := repo.ResolveBugCreateMetadata(keyLaunchpadID, lpBugID)
+		if err != nil && err != bug.ErrBugNotExist {
+			return err
+		}
+
+		if err == bug.ErrBugNotExist {
+			createdAt, _ := time.Parse(time.RFC3339, lpBug.CreatedAt)
+			_, err := repo.NewBugRaw(
+				li.makePerson(lpBug.Owner),
+				createdAt.Unix(),
+				lpBug.Title,
+				lpBug.Description,
+				nil,
+				map[string]string{
+					keyLaunchpadID: lpBugID,
+				},
+			)
+			if err != nil {
+				return errors.Wrapf(err, "failed to add bug id #%s", lpBugID)
+			}
+		} else {
+			/* TODO: Update bug */
+			fmt.Println("TODO: Update bug")
+		}
+
+	}
+	return nil
+}
+
+func (li *launchpadImporter) Import(repo *cache.RepoCache, id string) error {
+	fmt.Println("IMPORT")
+	return nil
+}

bridge/launchpad/launchpad.go 🔗

@@ -0,0 +1,24 @@
+// Package launchad contains the Launchpad bridge implementation
+package launchpad
+
+import (
+	"github.com/MichaelMure/git-bug/bridge/core"
+)
+
+func init() {
+	core.Register(&Launchpad{})
+}
+
+type Launchpad struct{}
+
+func (*Launchpad) Target() string {
+	return "launchpad-preview"
+}
+
+func (*Launchpad) NewImporter() core.Importer {
+	return &launchpadImporter{}
+}
+
+func (*Launchpad) NewExporter() core.Exporter {
+	return nil
+}

bridge/launchpad/launchpad_api.go 🔗

@@ -0,0 +1,178 @@
+package launchpad
+
+/*
+ * A wrapper around the Launchpad API. The documentation can be found at:
+ * https://launchpad.net/+apidoc/devel.html
+ *
+ * TODO:
+ * - Retrieve all messages associated to bugs
+ * - Retrieve bug status
+ * - Retrieve activity log
+ * - SearchTasks should yield bugs one by one
+ *
+ * TODO (maybe):
+ * - Authentication (this might help retrieving email adresses)
+ */
+
+import (
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+)
+
+const apiRoot = "https://api.launchpad.net/devel"
+
+// Person describes a person on Launchpad (a bug owner, a message author, ...).
+type LPPerson struct {
+	Name  string `json:"display_name"`
+	Login string `json:"name"`
+}
+
+// Caching all the LPPerson we know.
+// The keys are links to an owner page, such as
+// https://api.launchpad.net/devel/~login
+var personCache = make(map[string]LPPerson)
+
+func (owner *LPPerson) UnmarshalJSON(data []byte) error {
+	type LPPersonX LPPerson // Avoid infinite recursion
+	var ownerLink string
+	if err := json.Unmarshal(data, &ownerLink); err != nil {
+		return err
+	}
+
+	// First, try to gather info about the bug owner using our cache.
+	if cachedPerson, hasKey := personCache[ownerLink]; hasKey {
+		*owner = cachedPerson
+		return nil
+	}
+
+	// If the bug owner is not already known, we have to send a request.
+	req, err := http.NewRequest("GET", ownerLink, nil)
+	if err != nil {
+		return nil
+	}
+
+	client := &http.Client{}
+	resp, err := client.Do(req)
+	if err != nil {
+		return nil
+	}
+
+	defer resp.Body.Close()
+
+	var p LPPersonX
+	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
+		return nil
+	}
+	*owner = LPPerson(p)
+	// Do not forget to update the cache.
+	personCache[ownerLink] = *owner
+	return nil
+}
+
+// LPBug describes a Launchpad bug.
+type LPBug struct {
+	Title       string   `json:"title"`
+	ID          int      `json:"id"`
+	Owner       LPPerson `json:"owner_link"`
+	Description string   `json:"description"`
+	CreatedAt   string   `json:"date_created"`
+}
+
+type launchpadBugEntry struct {
+	BugLink  string `json:"bug_link"`
+	SelfLink string `json:"self_link"`
+}
+
+type launchpadAnswer struct {
+	Entries  []launchpadBugEntry `json:"entries"`
+	Start    int                 `json:"start"`
+	NextLink string              `json:"next_collection_link"`
+}
+
+type launchpadAPI struct {
+	client *http.Client
+}
+
+func (lapi *launchpadAPI) Init() error {
+	lapi.client = &http.Client{}
+	return nil
+}
+
+func (lapi *launchpadAPI) SearchTasks(project string) ([]LPBug, error) {
+	var bugs []LPBug
+
+	// First, let us build the URL. Not all statuses are included by
+	// default, so we have to explicitely enumerate them.
+	validStatuses := [13]string{
+		"New", "Incomplete", "Opinion", "Invalid",
+		"Won't Fix", "Expired", "Confirmed", "Triaged",
+		"In Progress", "Fix Committed", "Fix Released",
+		"Incomplete (with response)", "Incomplete (without response)",
+	}
+	queryParams := url.Values{}
+	queryParams.Add("ws.op", "searchTasks")
+	for _, validStatus := range validStatuses {
+		queryParams.Add("status", validStatus)
+	}
+	lpURL := fmt.Sprintf("%s/%s?%s", apiRoot, project, queryParams.Encode())
+
+	for {
+		req, err := http.NewRequest("GET", lpURL, nil)
+		if err != nil {
+			return nil, err
+		}
+
+		resp, err := lapi.client.Do(req)
+		if err != nil {
+			return nil, err
+		}
+
+		defer resp.Body.Close()
+
+		var result launchpadAnswer
+
+		if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
+			return nil, err
+		}
+
+		for _, bugEntry := range result.Entries {
+			bug, err := lapi.queryBug(bugEntry.BugLink)
+			if err == nil {
+				bugs = append(bugs, bug)
+			}
+		}
+
+		// Launchpad only returns 75 results at a time. We get the next
+		// page and run another query, unless there is no other page.
+		lpURL = result.NextLink
+		if lpURL == "" {
+			break
+		}
+	}
+
+	return bugs, nil
+}
+
+func (lapi *launchpadAPI) queryBug(url string) (LPBug, error) {
+	var bug LPBug
+
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		return bug, err
+	}
+
+	resp, err := lapi.client.Do(req)
+	if err != nil {
+		return bug, err
+	}
+
+	defer resp.Body.Close()
+
+	if err := json.NewDecoder(resp.Body).Decode(&bug); err != nil {
+		return bug, err
+	}
+
+	return bug, nil
+}