8
by Martin Pool
Rude text mode Launchpad client |
1 |
#! /usr/bin/python
|
2 |
||
3 |
# Copyright (C) 2010 Martin Pool
|
|
4 |
||
5 |
"""A text-mode interactive Launchpad client"""
|
|
6 |
||
7 |
||
8 |
import cmd |
|
10
by Martin Pool
httplib2 debug of launchpadlib calls |
9 |
import httplib2 |
11
by Martin Pool
Add --staging option |
10 |
import optparse |
31
by Martin Pool
Add pillar and official_tags commands |
11 |
import os |
9
by Martin Pool
Can now open bugs in a browser |
12 |
import subprocess |
8
by Martin Pool
Rude text mode Launchpad client |
13 |
import sys |
44
by Martin Pool
Use Python webbrowser module rather than shelling out |
14 |
import webbrowser |
8
by Martin Pool
Rude text mode Launchpad client |
15 |
|
16 |
import hydrazine |
|
11
by Martin Pool
Add --staging option |
17 |
import launchpadlib.launchpad |
45
by Martin Pool
Trap and print lazr.restful errors rather than aborting |
18 |
import lazr.restfulclient.errors |
8
by Martin Pool
Rude text mode Launchpad client |
19 |
|
20 |
||
21 |
class HydrazineCmd(cmd.Cmd): |
|
22 |
||
23 |
def __init__(self): |
|
24 |
cmd.Cmd.__init__(self) |
|
25 |
self.bug = None |
|
36
by Martin Pool
Add retarget command |
26 |
self.pillar = None |
38
by Martin Pool
Add next command |
27 |
self.task_list = None |
8
by Martin Pool
Rude text mode Launchpad client |
28 |
|
29 |
def _connect(self): |
|
30 |
self.session = hydrazine.create_session() |
|
31 |
||
32 |
def do_bug(self, bug_number): |
|
39
by Martin Pool
Add help for commands that miss it |
33 |
"""Open bug by number"""
|
8
by Martin Pool
Rude text mode Launchpad client |
34 |
try: |
35 |
bug_number = int(bug_number) |
|
36 |
except ValueError: |
|
37 |
print 'usage: bzr NUMBER' |
|
38 |
return
|
|
39 |
try: |
|
30
by Martin Pool
Add select_new command |
40 |
the_bug = self.session.bugs[bug_number] |
8
by Martin Pool
Rude text mode Launchpad client |
41 |
except KeyError: |
42 |
print 'no such bug?' |
|
43 |
return
|
|
30
by Martin Pool
Add select_new command |
44 |
self._select_bug(the_bug) |
8
by Martin Pool
Rude text mode Launchpad client |
45 |
|
37
by Martin Pool
Add comment command, and ability to work on multi-task bugs |
46 |
def do_comment(self, line): |
47 |
"""Post a comment to the current bug."""
|
|
48 |
if self._needs_bug(): return |
|
49 |
if not line: |
|
50 |
print "Please specify a comment" |
|
51 |
return
|
|
52 |
result = self.bug.newMessage(content=line) |
|
53 |
print "Posted message: %s" % result |
|
54 |
||
8
by Martin Pool
Rude text mode Launchpad client |
55 |
def do_description(self, nothing): |
39
by Martin Pool
Add help for commands that miss it |
56 |
"""Show bug description"""
|
9
by Martin Pool
Can now open bugs in a browser |
57 |
if self._needs_bug(): |
58 |
return
|
|
8
by Martin Pool
Rude text mode Launchpad client |
59 |
print self.bug.description |
60 |
||
15
by Martin Pool
Add duplicate command |
61 |
def do_duplicate(self, duplicate_id): |
39
by Martin Pool
Add help for commands that miss it |
62 |
"""Mark as a duplicate"""
|
15
by Martin Pool
Add duplicate command |
63 |
if self._needs_bug(): |
64 |
return
|
|
65 |
try: |
|
66 |
duplicate_id = int(duplicate_id) |
|
67 |
except ValueError: |
|
68 |
print 'usage: duplicate BUG_NUMBER' |
|
69 |
return
|
|
70 |
# XXX: could just synthesize a URL, which might be faster; probably
|
|
71 |
# need to make sure the root lines up correctly
|
|
72 |
try: |
|
73 |
duplicate_bug = self.session.bugs[duplicate_id] |
|
74 |
except KeyError: |
|
75 |
print 'no such bug?' |
|
76 |
return
|
|
77 |
print 'marking %d as a duplicate of %d' % (self.current_bug_number, |
|
78 |
duplicate_bug.id) |
|
79 |
print ' "%s"' % duplicate_bug.title |
|
80 |
self.bug.markAsDuplicate(duplicate_of=duplicate_bug) |
|
81 |
||
8
by Martin Pool
Rude text mode Launchpad client |
82 |
def do_EOF(self, what): |
83 |
return True |
|
84 |
||
25
by Martin Pool
Add importance command |
85 |
def do_importance(self, line): |
27
by Martin Pool
Add show command |
86 |
"""Set importance"""
|
25
by Martin Pool
Add importance command |
87 |
task = self._needs_single_task() |
88 |
if task is None: |
|
89 |
return
|
|
90 |
new_importance = canonical_importance(line) |
|
91 |
if new_importance is None: |
|
92 |
return
|
|
93 |
print 'changing importance %s => %s' % (task.importance, new_importance) |
|
94 |
task.importance = new_importance |
|
52
by Martin Pool
Show some debug information to help with bug 534066 |
95 |
print '**** before save' |
96 |
if opts.debug: |
|
97 |
print task._wadl_resource._definition.representation |
|
98 |
try: |
|
99 |
task.lp_save() |
|
100 |
except: |
|
101 |
print '**** got error' |
|
102 |
if opts.debug: |
|
103 |
print task._wadl_resource._definition.representation |
|
25
by Martin Pool
Add importance command |
104 |
|
38
by Martin Pool
Add next command |
105 |
def do_next(self, ignored): |
39
by Martin Pool
Add help for commands that miss it |
106 |
"""Go to the next bug in the list"""
|
38
by Martin Pool
Add next command |
107 |
if self.task_list is None: |
108 |
print 'no list loaded; use select_new etc' |
|
109 |
return
|
|
110 |
self.search_index += 1 |
|
111 |
bug_task = self.task_list[self.search_index] |
|
112 |
self._select_bug(bug_task.bug) |
|
113 |
||
31
by Martin Pool
Add pillar and official_tags commands |
114 |
def do_official_tags(self, ignored): |
115 |
"""Show the official tags for the current pillar."""
|
|
116 |
if self._needs_pillar(): return |
|
117 |
print 'Official bug tags for %s' % self.pillar.name |
|
118 |
tags = self.pillar.official_bug_tags |
|
119 |
_show_columnated(tags) |
|
120 |
||
9
by Martin Pool
Can now open bugs in a browser |
121 |
def do_open(self, ignored): |
27
by Martin Pool
Add show command |
122 |
"""Open the current bug in a web browser"""
|
9
by Martin Pool
Can now open bugs in a browser |
123 |
if self._needs_bug(): |
124 |
return
|
|
44
by Martin Pool
Use Python webbrowser module rather than shelling out |
125 |
webbrowser.open(web_url(self.bug)) |
22
by Martin Pool
Add 'title' command |
126 |
|
31
by Martin Pool
Add pillar and official_tags commands |
127 |
def do_pillar(self, pillar_name): |
128 |
"""Select a pillar (project, etc)"""
|
|
36
by Martin Pool
Add retarget command |
129 |
self._select_pillar(self._find_pillar(pillar_name)) |
31
by Martin Pool
Add pillar and official_tags commands |
130 |
|
34
by Martin Pool
Add refresh command |
131 |
def do_refresh(self, ignored): |
132 |
"""Reload current bug."""
|
|
133 |
if self._needs_bug(): return |
|
134 |
self.bug.lp_refresh() |
|
135 |
self._show_bug(self.bug) |
|
136 |
||
36
by Martin Pool
Add retarget command |
137 |
def do_retarget(self, to_pillar): |
138 |
"""Change a bug from this pillar to another."""
|
|
139 |
task = self._needs_single_task() |
|
140 |
if task is None: return |
|
141 |
if not to_pillar: |
|
142 |
print 'usage: retarget TO_PILLAR' |
|
143 |
return
|
|
144 |
new_target = self._find_pillar(to_pillar) |
|
145 |
if new_target is None: |
|
146 |
print 'no such product?' |
|
147 |
return
|
|
148 |
print 'change target of bug %s' % (task.bug.id,) |
|
149 |
print ' from: %s' % (task.target,) |
|
150 |
print ' to: %s' % (new_target,) |
|
151 |
task.target = new_target |
|
152 |
task.lp_save() |
|
153 |
||
32
by Martin Pool
select_new doesn't need a pillar name; small typo correction |
154 |
def do_select_new(self, ignored): |
155 |
"""Select the list of new bugs in the current pillar"""
|
|
156 |
if self._needs_pillar(): return |
|
35
by Martin Pool
Select newest new bug; tweak bugtask display |
157 |
self.task_list = self.pillar.searchTasks(status="New", |
158 |
order_by=['-datecreated']) |
|
30
by Martin Pool
Add select_new command |
159 |
self.task_list_index = 0 |
160 |
try: |
|
161 |
first_bug_task = self.task_list[0] |
|
38
by Martin Pool
Add next command |
162 |
self.search_index = 0 |
30
by Martin Pool
Add select_new command |
163 |
except IndexError: |
164 |
print "No bugtasks found" |
|
165 |
self._select_bug(first_bug_task.bug) |
|
166 |
||
27
by Martin Pool
Add show command |
167 |
def do_show(self, ignored): |
168 |
"""Show the header of the current bug"""
|
|
169 |
if self._needs_bug(): |
|
170 |
return
|
|
171 |
self._show_bug(self.bug) |
|
172 |
||
22
by Martin Pool
Add 'title' command |
173 |
def do_title(self, new_title): |
174 |
"""Change the title of the current bug.
|
|
175 |
||
176 |
example:
|
|
177 |
title bzr diff should warn if tree is out of date with branch
|
|
178 |
"""
|
|
179 |
if self._needs_bug(): |
|
180 |
return
|
|
181 |
print 'changing title of bug %d to "%s"' % (self.bug.id, new_title) |
|
182 |
print ' old title "%s"' % (self.bug.title) |
|
183 |
self.bug.title = new_title |
|
184 |
self.bug.lp_save() |
|
9
by Martin Pool
Can now open bugs in a browser |
185 |
|
24
by Martin Pool
Add quit command |
186 |
def do_quit(self, ignored): |
187 |
return True |
|
188 |
||
26
by Martin Pool
Add status command |
189 |
def do_status(self, line): |
39
by Martin Pool
Add help for commands that miss it |
190 |
"""Change status of the current bug"""
|
26
by Martin Pool
Add status command |
191 |
task = self._needs_single_task() |
192 |
if task is None: |
|
193 |
return
|
|
194 |
new_status = canonical_status(line) |
|
195 |
if new_status is None: |
|
196 |
return
|
|
197 |
print 'changing status %s => %s' % (task.status, new_status) |
|
198 |
task.status = new_status |
|
199 |
task.lp_save() |
|
200 |
||
29
by Martin Pool
Add tags command |
201 |
def do_tags(self, line): |
202 |
"""Show, add or remove bug tags.
|
|
203 |
||
204 |
example:
|
|
205 |
tags +easy -crash
|
|
206 |
||
207 |
If no arguments are given, show the current tags.
|
|
208 |
||
209 |
Otherwise, add or remove the given tags.
|
|
210 |
"""
|
|
211 |
if self._needs_bug(): return |
|
212 |
if not line.strip(): |
|
213 |
print 'bug %d tags: %s' % (self.bug.id, ' '.join(self.bug.tags)) |
|
214 |
return
|
|
215 |
to_add = [] |
|
216 |
to_remove = [] |
|
217 |
for word in line.split(): |
|
218 |
if word[0] == '+': |
|
219 |
to_add.append(word[1:]) |
|
220 |
elif word[0] == '-': |
|
221 |
to_remove.append(word[1:]) |
|
222 |
else: |
|
223 |
# XXX: not sure, should we just set it?
|
|
224 |
to_add.append(word) |
|
225 |
old_tags = list(self.bug.tags) |
|
226 |
new_tags = old_tags[:] |
|
227 |
for a in to_add: |
|
228 |
if a not in new_tags: |
|
229 |
new_tags.append(a) |
|
230 |
for a in to_remove: |
|
231 |
if a in new_tags: |
|
232 |
new_tags.remove(a) |
|
233 |
print 'changing bug %d tags' % self.bug.id |
|
234 |
print ' from: %s' % ' '.join(old_tags) |
|
235 |
print ' to: %s' % ' '.join(new_tags) |
|
236 |
self.bug.tags = new_tags |
|
237 |
self.bug.lp_save() |
|
238 |
||
40
by Martin Pool
Add triage command to do more operations in one go |
239 |
def do_triage(self, line): |
240 |
"""Set tags, status, and importance.
|
|
241 |
||
242 |
example:
|
|
243 |
triage confirmed wishlist +foo +bar
|
|
244 |
"""
|
|
245 |
if self._needs_bug(): return |
|
246 |
task = self._needs_single_task() |
|
43
by Martin Pool
Be a bit more verbose |
247 |
if not task: |
248 |
print 'no task selected' |
|
249 |
return
|
|
40
by Martin Pool
Add triage command to do more operations in one go |
250 |
for w in line.split(): |
251 |
if w[0] == '+': |
|
252 |
self.bug.tags.append(w[1:]) |
|
253 |
continue
|
|
254 |
importance = canonical_importance(w) |
|
255 |
if importance: |
|
256 |
task.importance = importance |
|
257 |
continue
|
|
258 |
status = canonical_status(w) |
|
259 |
if status: |
|
260 |
task.status = status |
|
261 |
continue
|
|
262 |
if self.bug._dirty_attributes: |
|
263 |
self.bug.lp_save() |
|
264 |
if task._dirty_attributes: |
|
265 |
task.lp_save() |
|
266 |
||
45
by Martin Pool
Trap and print lazr.restful errors rather than aborting |
267 |
def onecmd(self, cmdline): |
268 |
# run the command protected against stupid
|
|
269 |
# errors caused by eg <https://bugs.edge.launchpad.net/bugs/341950>
|
|
270 |
try: |
|
271 |
# can't use super because Cmd is an old-style
|
|
272 |
# class
|
|
273 |
cmd.Cmd.onecmd(self, cmdline) |
|
274 |
except lazr.restfulclient.errors.RestfulError, e: |
|
275 |
print e |
|
276 |
pass
|
|
277 |
||
9
by Martin Pool
Can now open bugs in a browser |
278 |
def _needs_bug(self): |
279 |
if self.bug is None: |
|
280 |
print 'no bug selected' |
|
281 |
return True |
|
282 |
||
31
by Martin Pool
Add pillar and official_tags commands |
283 |
def _needs_pillar(self): |
284 |
if self.pillar is None: |
|
285 |
print 'no pillar selected' |
|
286 |
return True |
|
287 |
||
25
by Martin Pool
Add importance command |
288 |
def _needs_single_task(self): |
37
by Martin Pool
Add comment command, and ability to work on multi-task bugs |
289 |
"""Return the single task for the current bug in the current pillar, or None"""
|
25
by Martin Pool
Add importance command |
290 |
if self.bug is None: |
291 |
print 'no bug selected' |
|
292 |
return None |
|
293 |
tasks = list(self.bug.bug_tasks) |
|
37
by Martin Pool
Add comment command, and ability to work on multi-task bugs |
294 |
if self.pillar is None: |
295 |
if len(tasks) == 1: |
|
296 |
# no pillar; assume this is ok
|
|
297 |
return tasks[0] |
|
298 |
else: |
|
299 |
print 'This bug has multiple tasks; please choose a pillar' |
|
300 |
return None |
|
301 |
else: |
|
302 |
for t in tasks: |
|
303 |
if t.target == self.pillar: |
|
304 |
return t |
|
305 |
else: |
|
306 |
print 'No task for %s in %s' % (self.pillar, self.bug) |
|
307 |
return None |
|
25
by Martin Pool
Add importance command |
308 |
|
12
by Martin Pool
Calculate prompt dynamically |
309 |
@property
|
310 |
def prompt(self): |
|
31
by Martin Pool
Add pillar and official_tags commands |
311 |
p = 'hydrazine(%s) ' % (self.short_service_root,) |
312 |
if self.bug is not None: |
|
32
by Martin Pool
select_new doesn't need a pillar name; small typo correction |
313 |
p += '#%d ' % (self.current_bug_number,) |
31
by Martin Pool
Add pillar and official_tags commands |
314 |
if self.pillar is not None: |
315 |
p += 'in %s ' % self.pillar.name |
|
23
by Martin Pool
Turn off prompt highlighting, which causes trouble with readline |
316 |
# would like to highlight the prompt, but Cmd doesn't seem to have a
|
317 |
# way to know some characters are not visible, therefore repainting is
|
|
24
by Martin Pool
Add quit command |
318 |
# messed
|
31
by Martin Pool
Add pillar and official_tags commands |
319 |
if p[-1] == ' ': |
320 |
p = p[:-1] |
|
321 |
return p + '> ' |
|
12
by Martin Pool
Calculate prompt dynamically |
322 |
|
30
by Martin Pool
Add select_new command |
323 |
def _select_bug(self, the_bug): |
324 |
self.bug = the_bug |
|
325 |
self.current_bug_number = the_bug.id |
|
326 |
self._show_bug(self.bug) |
|
327 |
||
36
by Martin Pool
Add retarget command |
328 |
def _find_pillar(self, pillar_name): |
30
by Martin Pool
Add select_new command |
329 |
pillar_collection = self.session.pillars.search(text=pillar_name) |
330 |
try: |
|
36
by Martin Pool
Add retarget command |
331 |
return pillar_collection[0] |
30
by Martin Pool
Add select_new command |
332 |
except IndexError: |
333 |
print "No such pillar?" |
|
334 |
return
|
|
36
by Martin Pool
Add retarget command |
335 |
|
336 |
def _select_pillar(self, pillar): |
|
337 |
self.pillar = pillar |
|
338 |
if pillar is None: |
|
339 |
print "no pillar selected" |
|
340 |
else: |
|
341 |
print " %s" % self.pillar |
|
30
by Martin Pool
Add select_new command |
342 |
|
8
by Martin Pool
Rude text mode Launchpad client |
343 |
def _show_bug(self, bug): |
344 |
print 'bug: %d: %s' % (bug.id, bug.title) |
|
21
by Martin Pool
Better display of bugs that are dupes |
345 |
if bug.duplicate_of: |
346 |
print ' duplicate of bug %d' % (bug.duplicate_of.id,) |
|
347 |
else: |
|
348 |
for task in bug.bug_tasks: |
|
35
by Martin Pool
Select newest new bug; tweak bugtask display |
349 |
print ' affects %-40s %14s %s' % ( |
350 |
task.bug_target_name, task.status, task.importance,) |
|
29
by Martin Pool
Add tags command |
351 |
print ' tags: %s' % ' '.join(bug.tags) |
8
by Martin Pool
Rude text mode Launchpad client |
352 |
|
353 |
||
25
by Martin Pool
Add importance command |
354 |
def canonical_importance(from_importance): |
355 |
real_importances = ['Critical', 'High', 'Medium', 'Low', 'Wishlist', 'Undecided'] |
|
26
by Martin Pool
Add status command |
356 |
return canonical_enum(from_importance, real_importances) |
357 |
||
358 |
||
359 |
def canonical_status(entered): |
|
360 |
return canonical_enum(entered, |
|
361 |
['Confirmed', 'Triaged', 'Fix Committed', 'Fix Released', 'In Progress', |
|
362 |
"Won't Fix", "Incomplete", "Invalid", "New"]) |
|
363 |
||
364 |
||
365 |
def canonical_enum(entered, options): |
|
366 |
def squish(a): |
|
367 |
return a.lower().replace(' ', '') |
|
368 |
for i in options: |
|
369 |
if squish(i) == squish(entered): |
|
25
by Martin Pool
Add importance command |
370 |
return i |
371 |
return None |
|
372 |
||
373 |
||
31
by Martin Pool
Add pillar and official_tags commands |
374 |
def _show_columnated(tags): |
375 |
tags = tags[:] |
|
376 |
longest = max(map(len, tags)) |
|
377 |
cols = int(os.environ.get('COLUMNS', '80')) |
|
378 |
per_row = max(int((cols-1)/(longest + 1)), 1) |
|
379 |
i = 0 |
|
380 |
while tags: |
|
381 |
t = tags.pop(0) |
|
382 |
print '%-*s' % (longest, t), |
|
383 |
i += 1 |
|
384 |
if i == per_row: |
|
385 |
print
|
|
386 |
i = 0 |
|
387 |
if i != 0: |
|
388 |
print
|
|
389 |
||
390 |
||
44
by Martin Pool
Use Python webbrowser module rather than shelling out |
391 |
def web_url(launchpad_object): |
392 |
"""Translate from an object's api url to the web page url"""
|
|
393 |
# very dodgy; see https://bugs.launchpad.net/launchpadlib/+bug/316694
|
|
394 |
return launchpad_object.self_link.replace('api.', '', 1).replace('/beta/', '/', 1) |
|
395 |
||
396 |
||
8
by Martin Pool
Rude text mode Launchpad client |
397 |
def main(argv): |
11
by Martin Pool
Add --staging option |
398 |
parser = optparse.OptionParser() |
399 |
parser.add_option('--staging', action='store_const', |
|
14
by Martin Pool
Show service root in prompt |
400 |
const='staging', |
401 |
dest='short_service_root') |
|
18
by Martin Pool
Add --debug option |
402 |
parser.add_option('--debug', action='store_true', |
403 |
dest='debug', |
|
404 |
help='Show trace of API calls') |
|
19
by Martin Pool
Add -c option to run commands |
405 |
parser.add_option('-c', '--command', |
406 |
action='append', |
|
407 |
dest='commands', |
|
408 |
help='Run this command before starting interactive mode (may be repeated)', |
|
20
by Martin Pool
tweak help |
409 |
metavar='COMMAND', |
19
by Martin Pool
Add -c option to run commands |
410 |
)
|
14
by Martin Pool
Show service root in prompt |
411 |
parser.set_defaults(short_service_root='edge') |
11
by Martin Pool
Add --staging option |
412 |
|
52
by Martin Pool
Show some debug information to help with bug 534066 |
413 |
global opts, args |
11
by Martin Pool
Add --staging option |
414 |
opts, args = parser.parse_args(argv) |
14
by Martin Pool
Show service root in prompt |
415 |
hydrazine.service_root = dict( |
416 |
edge=launchpadlib.launchpad.EDGE_SERVICE_ROOT, |
|
417 |
staging=launchpadlib.launchpad.STAGING_SERVICE_ROOT, |
|
418 |
)[opts.short_service_root] |
|
18
by Martin Pool
Add --debug option |
419 |
if opts.debug: |
420 |
# debuglevel only takes effect when the connection is opened, so we can't
|
|
421 |
# trivially change it while the program is running
|
|
422 |
# see <https://bugs.edge.launchpad.net/launchpadlib/+bug/520219>
|
|
423 |
httplib2.debuglevel = int(not httplib2.debuglevel) |
|
424 |
||
8
by Martin Pool
Rude text mode Launchpad client |
425 |
cmd = HydrazineCmd() |
14
by Martin Pool
Show service root in prompt |
426 |
cmd.short_service_root = opts.short_service_root |
8
by Martin Pool
Rude text mode Launchpad client |
427 |
cmd._connect() |
19
by Martin Pool
Add -c option to run commands |
428 |
|
42
by Martin Pool
Cope if no -c options are given |
429 |
for c in opts.commands or []: |
43
by Martin Pool
Be a bit more verbose |
430 |
print '> ' + c |
28
by Martin Pool
make '-cquit' before going into interactive mode, as you'd expect |
431 |
if cmd.onecmd(c): |
432 |
break
|
|
433 |
else: |
|
434 |
# run cmdloop unless eg '-c quit' caused us to exit already
|
|
435 |
cmd.cmdloop() |
|
8
by Martin Pool
Rude text mode Launchpad client |
436 |
|
437 |
||
438 |
if __name__ == '__main__': |
|
439 |
main(sys.argv) |