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}