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 "encoding/json"
18 "fmt"
19 "net/http"
20 "net/url"
21)
22
23const apiRoot = "https://api.launchpad.net/devel"
24
25// Person describes a person on Launchpad (a bug owner, a message author, ...).
26type LPPerson struct {
27 Name string `json:"display_name"`
28 Login string `json:"name"`
29}
30
31// Caching all the LPPerson we know.
32// The keys are links to an owner page, such as
33// https://api.launchpad.net/devel/~login
34var personCache = make(map[string]LPPerson)
35
36func (owner *LPPerson) UnmarshalJSON(data []byte) error {
37 type LPPersonX LPPerson // Avoid infinite recursion
38 var ownerLink string
39 if err := json.Unmarshal(data, &ownerLink); err != nil {
40 return err
41 }
42
43 // First, try to gather info about the bug owner using our cache.
44 if cachedPerson, hasKey := personCache[ownerLink]; hasKey {
45 *owner = cachedPerson
46 return nil
47 }
48
49 // If the bug owner is not already known, we have to send a request.
50 req, err := http.NewRequest("GET", ownerLink, nil)
51 if err != nil {
52 return nil
53 }
54
55 client := &http.Client{}
56 resp, err := client.Do(req)
57 if err != nil {
58 return nil
59 }
60
61 defer resp.Body.Close()
62
63 var p LPPersonX
64 if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
65 return nil
66 }
67 *owner = LPPerson(p)
68 // Do not forget to update the cache.
69 personCache[ownerLink] = *owner
70 return nil
71}
72
73// LPBug describes a Launchpad bug.
74type LPBug struct {
75 Title string `json:"title"`
76 ID int `json:"id"`
77 Owner LPPerson `json:"owner_link"`
78 Description string `json:"description"`
79 CreatedAt string `json:"date_created"`
80 Messages []LPMessage
81}
82
83// LPMessage describes a comment on a bug report
84type LPMessage struct {
85 Content string `json:"content"`
86 CreatedAt string `json:"date_created"`
87 Owner LPPerson `json:"owner_link"`
88 ID string `json:"self_link"`
89}
90
91type launchpadBugEntry struct {
92 BugLink string `json:"bug_link"`
93 SelfLink string `json:"self_link"`
94}
95
96type launchpadAnswer struct {
97 Entries []launchpadBugEntry `json:"entries"`
98 Start int `json:"start"`
99 NextLink string `json:"next_collection_link"`
100}
101
102type launchpadMessageAnswer struct {
103 Entries []LPMessage `json:"entries"`
104 NextLink string `json:"next_collection_link"`
105}
106
107type launchpadAPI struct {
108 client *http.Client
109}
110
111func (lapi *launchpadAPI) Init() error {
112 lapi.client = &http.Client{}
113 return nil
114}
115
116func (lapi *launchpadAPI) SearchTasks(project string) ([]LPBug, error) {
117 var bugs []LPBug
118
119 // First, let us build the URL. Not all statuses are included by
120 // default, so we have to explicitely enumerate them.
121 validStatuses := [13]string{
122 "New", "Incomplete", "Opinion", "Invalid",
123 "Won't Fix", "Expired", "Confirmed", "Triaged",
124 "In Progress", "Fix Committed", "Fix Released",
125 "Incomplete (with response)", "Incomplete (without response)",
126 }
127 queryParams := url.Values{}
128 queryParams.Add("ws.op", "searchTasks")
129 queryParams.Add("order_by", "-date_last_updated")
130 for _, validStatus := range validStatuses {
131 queryParams.Add("status", validStatus)
132 }
133 lpURL := fmt.Sprintf("%s/%s?%s", apiRoot, project, queryParams.Encode())
134
135 for {
136 req, err := http.NewRequest("GET", lpURL, nil)
137 if err != nil {
138 return nil, err
139 }
140
141 resp, err := lapi.client.Do(req)
142 if err != nil {
143 return nil, err
144 }
145
146 var result launchpadAnswer
147
148 err = json.NewDecoder(resp.Body).Decode(&result)
149 _ = resp.Body.Close()
150
151 if err != nil {
152 return nil, err
153 }
154
155 for _, bugEntry := range result.Entries {
156 bug, err := lapi.queryBug(bugEntry.BugLink)
157 if err == nil {
158 bugs = append(bugs, bug)
159 }
160 }
161
162 // Launchpad only returns 75 results at a time. We get the next
163 // page and run another query, unless there is no other page.
164 lpURL = result.NextLink
165 if lpURL == "" {
166 break
167 }
168 }
169
170 return bugs, nil
171}
172
173func (lapi *launchpadAPI) queryBug(url string) (LPBug, error) {
174 var bug LPBug
175
176 req, err := http.NewRequest("GET", url, nil)
177 if err != nil {
178 return bug, err
179 }
180
181 resp, err := lapi.client.Do(req)
182 if err != nil {
183 return bug, err
184 }
185
186 defer resp.Body.Close()
187
188 if err := json.NewDecoder(resp.Body).Decode(&bug); err != nil {
189 return bug, err
190 }
191
192 /* Fetch messages */
193 messagesCollectionLink := fmt.Sprintf("%s/bugs/%d/messages", apiRoot, bug.ID)
194 messages, err := lapi.queryMessages(messagesCollectionLink)
195 if err != nil {
196 return bug, err
197 }
198 bug.Messages = messages
199
200 return bug, nil
201}
202
203func (lapi *launchpadAPI) queryMessages(messagesURL string) ([]LPMessage, error) {
204 var messages []LPMessage
205
206 for {
207 req, err := http.NewRequest("GET", messagesURL, nil)
208 if err != nil {
209 return nil, err
210 }
211
212 resp, err := lapi.client.Do(req)
213 if err != nil {
214 return nil, err
215 }
216
217 var result launchpadMessageAnswer
218
219 err = json.NewDecoder(resp.Body).Decode(&result)
220 _ = resp.Body.Close()
221
222 if err != nil {
223 return nil, err
224 }
225
226 messages = append(messages, result.Entries...)
227
228 // Launchpad only returns 75 results at a time. We get the next
229 // page and run another query, unless there is no other page.
230 messagesURL = result.NextLink
231 if messagesURL == "" {
232 break
233 }
234 }
235 return messages, nil
236}