~statik/hydrazine/packaging

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)