launchpad_api.go

  1package launchpad
  2
  3/*
  4 * A wrapper around the Launchpad API. The documentation can be found at:
  5 * https://launchpad.net/+apidoc/devel.html
  6 *
  7 * TODO:
  8 * - Retrieve bug status
  9 * - Retrieve activity log
 10 * - SearchTasks should yield bugs one by one
 11 *
 12 * TODO (maybe):
 13 * - Authentication (this might help retrieving email adresses)
 14 */
 15
 16import (
 17	"context"
 18	"encoding/json"
 19	"fmt"
 20	"net/http"
 21	"net/url"
 22)
 23
 24const apiRoot = "https://api.launchpad.net/devel"
 25
 26// Person describes a person on Launchpad (a bug owner, a message author, ...).
 27type LPPerson struct {
 28	Name  string `json:"display_name"`
 29	Login string `json:"name"`
 30}
 31
 32// Caching all the LPPerson we know.
 33// The keys are links to an owner page, such as
 34// https://api.launchpad.net/devel/~login
 35var personCache = make(map[string]LPPerson)
 36
 37// LPBug describes a Launchpad bug.
 38type LPBug struct {
 39	Title       string   `json:"title"`
 40	ID          int      `json:"id"`
 41	Owner       LPPerson `json:"owner_link"`
 42	Description string   `json:"description"`
 43	CreatedAt   string   `json:"date_created"`
 44	Messages    []LPMessage
 45}
 46
 47// LPMessage describes a comment on a bug report
 48type LPMessage struct {
 49	Content   string   `json:"content"`
 50	CreatedAt string   `json:"date_created"`
 51	Owner     LPPerson `json:"owner_link"`
 52	ID        string   `json:"self_link"`
 53}
 54
 55type launchpadBugEntry struct {
 56	BugLink  string `json:"bug_link"`
 57	SelfLink string `json:"self_link"`
 58}
 59
 60type launchpadAnswer struct {
 61	Entries  []launchpadBugEntry `json:"entries"`
 62	Start    int                 `json:"start"`
 63	NextLink string              `json:"next_collection_link"`
 64}
 65
 66type launchpadMessageAnswer struct {
 67	Entries  []LPMessage `json:"entries"`
 68	NextLink string      `json:"next_collection_link"`
 69}
 70
 71type launchpadAPI struct {
 72	client *http.Client
 73}
 74
 75func (lapi *launchpadAPI) Init() error {
 76	lapi.client = &http.Client{
 77		Timeout: defaultTimeout,
 78	}
 79	return nil
 80}
 81
 82func (lapi *launchpadAPI) SearchTasks(ctx context.Context, project string) ([]LPBug, error) {
 83	var bugs []LPBug
 84
 85	// First, let us build the URL. Not all statuses are included by
 86	// default, so we have to explicitely enumerate them.
 87	validStatuses := [13]string{
 88		"New", "Incomplete", "Opinion", "Invalid",
 89		"Won't Fix", "Expired", "Confirmed", "Triaged",
 90		"In Progress", "Fix Committed", "Fix Released",
 91		"Incomplete (with response)", "Incomplete (without response)",
 92	}
 93	queryParams := url.Values{}
 94	queryParams.Add("ws.op", "searchTasks")
 95	queryParams.Add("order_by", "-date_last_updated")
 96	for _, validStatus := range validStatuses {
 97		queryParams.Add("status", validStatus)
 98	}
 99	lpURL := fmt.Sprintf("%s/%s?%s", apiRoot, project, queryParams.Encode())
100
101	for {
102		req, err := http.NewRequest("GET", lpURL, nil)
103		if err != nil {
104			return nil, err
105		}
106
107		resp, err := lapi.client.Do(req)
108		if err != nil {
109			return nil, err
110		}
111
112		var result launchpadAnswer
113
114		err = json.NewDecoder(resp.Body).Decode(&result)
115		_ = resp.Body.Close()
116
117		if err != nil {
118			return nil, err
119		}
120
121		for _, bugEntry := range result.Entries {
122			bug, err := lapi.queryBug(ctx, bugEntry.BugLink)
123			if err == nil {
124				bugs = append(bugs, bug)
125			}
126		}
127
128		// Launchpad only returns 75 results at a time. We get the next
129		// page and run another query, unless there is no other page.
130		lpURL = result.NextLink
131		if lpURL == "" {
132			break
133		}
134	}
135
136	return bugs, nil
137}
138
139func (lapi *launchpadAPI) queryBug(ctx context.Context, url string) (LPBug, error) {
140	var bug LPBug
141
142	req, err := http.NewRequest("GET", url, nil)
143	if err != nil {
144		return bug, err
145	}
146	req = req.WithContext(ctx)
147
148	resp, err := lapi.client.Do(req)
149	if err != nil {
150		return bug, err
151	}
152
153	defer resp.Body.Close()
154
155	if err := json.NewDecoder(resp.Body).Decode(&bug); err != nil {
156		return bug, err
157	}
158
159	/* Fetch messages */
160	messagesCollectionLink := fmt.Sprintf("%s/bugs/%d/messages", apiRoot, bug.ID)
161	messages, err := lapi.queryMessages(ctx, messagesCollectionLink)
162	if err != nil {
163		return bug, err
164	}
165	bug.Messages = messages
166
167	return bug, nil
168}
169
170func (lapi *launchpadAPI) queryMessages(ctx context.Context, messagesURL string) ([]LPMessage, error) {
171	var messages []LPMessage
172
173	for {
174		req, err := http.NewRequest("GET", messagesURL, nil)
175		if err != nil {
176			return nil, err
177		}
178		req = req.WithContext(ctx)
179
180		resp, err := lapi.client.Do(req)
181		if err != nil {
182			return nil, err
183		}
184
185		var result launchpadMessageAnswer
186
187		err = json.NewDecoder(resp.Body).Decode(&result)
188		_ = resp.Body.Close()
189
190		if err != nil {
191			return nil, err
192		}
193
194		messages = append(messages, result.Entries...)
195
196		// Launchpad only returns 75 results at a time. We get the next
197		// page and run another query, unless there is no other page.
198		messagesURL = result.NextLink
199		if messagesURL == "" {
200			break
201		}
202	}
203	return messages, nil
204}