Backport change from r115807 in Emacs trunk
[org-mode/org-kjn.git] / mk / pw
blob13df6ea5b050e2730eff2f56bb2259aa6c2e5069
1 #!/usr/bin/env python
3 # Patchwork command line client
4 # Copyright (C) 2008, 2013 Nate Case <ncase@xes-inc.com>
6 # This file is part of the Patchwork package.
8 # Patchwork is free software; you can redistribute it and/or modify
9 # it under the terms of the GNU General Public License as published by
10 # the Free Software Foundation; either version 2 of the License, or
11 # (at your option) any later version.
13 # Patchwork is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU General Public License for more details.
18 # You should have received a copy of the GNU General Public License
19 # along with Patchwork; if not, write to the Free Software
20 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
22 import os
23 import sys
24 import xmlrpclib
25 import getopt
26 import string
27 import tempfile
28 import subprocess
29 import base64
30 import ConfigParser
31 import datetime
32 import smtplib
33 import urllib
34 import re
36 from email.mime.text import MIMEText
38 notify_on_state_change = {
39 'accepted': 'emacs-orgmode@gnu.org',
40 'rejected': 'emacs-orgmode@gnu.org',
41 'changes requested': 'emacs-orgmode@gnu.org',
42 'rfc': 'emacs-orgmode@gnu.org'
45 # Default Patchwork remote XML-RPC server URL
46 # This script will check the PW_XMLRPC_URL environment variable
47 # for the URL to access. If that is unspecified, it will fallback to
48 # the hardcoded default value specified here.
49 DEFAULT_URL = "http://patchwork.newartisans.com/xmlrpc/"
50 CONFIG_FILES = [os.path.expanduser('~/.pwclientrc')]
52 class Filter:
53 """Filter for selecting patches."""
54 def __init__(self):
55 # These fields refer to specific objects, so they are special
56 # because we have to resolve them to IDs before passing the
57 # filter to the server
58 self.state = ""
59 self.project = ""
61 # The dictionary that gets passed to via XML-RPC
62 self.d = {}
64 def add(self, field, value):
65 if field == 'state':
66 self.state = value
67 elif field == 'project':
68 self.project = value
69 else:
70 # OK to add directly
71 self.d[field] = value
73 def resolve_ids(self, rpc):
74 """Resolve State, Project, and Person IDs based on filter strings."""
75 if self.state != "":
76 id = state_id_by_name(rpc, self.state)
77 if id == 0:
78 sys.stderr.write("Note: No State found matching %s*, " \
79 "ignoring filter\n" % self.state)
80 else:
81 self.d['state_id'] = id
83 if self.project != "":
84 id = project_id_by_name(rpc, self.project)
85 if id == 0:
86 sys.stderr.write("Note: No Project found matching %s, " \
87 "ignoring filter\n" % self.project)
88 else:
89 self.d['project_id'] = id
91 def __str__(self):
92 """Return human-readable description of the filter."""
93 return str(self.d)
95 class BasicHTTPAuthTransport(xmlrpclib.SafeTransport):
97 def __init__(self, username = None, password = None, use_https = False):
98 self.username = username
99 self.password = password
100 self.use_https = use_https
101 xmlrpclib.SafeTransport.__init__(self)
103 def authenticated(self):
104 return self.username != None and self.password != None
106 def send_host(self, connection, host):
107 xmlrpclib.Transport.send_host(self, connection, host)
108 if not self.authenticated():
109 return
110 credentials = '%s:%s' % (self.username, self.password)
111 auth = 'Basic ' + base64.encodestring(credentials).strip()
112 connection.putheader('Authorization', auth)
114 def make_connection(self, host):
115 if self.use_https:
116 fn = xmlrpclib.SafeTransport.make_connection
117 else:
118 fn = xmlrpclib.Transport.make_connection
119 return fn(self, host)
121 def usage():
122 sys.stderr.write("Usage: %s <action> [options]\n\n" % \
123 (os.path.basename(sys.argv[0])))
124 sys.stderr.write("Where <action> is one of:\n")
125 sys.stderr.write(
126 """ apply <ID> : Apply a patch (in the current dir, using -p1)
127 get <ID> : Download a patch and save it locally
128 projects : List all projects
129 states : Show list of potential patch states
130 list [str] : List patches, using the optional filters specified
131 below and an optional substring to search for patches
132 by name
133 search [str] : Same as 'list'
134 view <ID> : View a patch
135 show <ID> : Same as view
136 update [-s state] [-c commit-ref] [-m email-comment] <ID>
137 : Update patch, send mail to mailing list if appropriate
138 branch <ID> : Create a topic branch t/patchNNN for this patch
139 : and update it to the current master
140 merge [-m email-comment] <ID>
141 : Merge the t/patchNNN topic branch into master
142 \n""")
143 sys.stderr.write("""\nFilter options for 'list' and 'search':
144 -s <state> : Filter by patch state (e.g., 'New', 'Accepted', etc.)
145 -p <project> : Filter by project name (see 'projects' for list)
146 -w <who> : Filter by submitter (name, e-mail substring search)
147 -d <who> : Filter by delegate (name, e-mail substring search)
148 -n <max #> : Restrict number of results\n""")
149 sys.stderr.write("""\nActions that take an ID argument can also be \
150 invoked with:
151 -h <hash> : Lookup by patch hash\n""")
152 sys.exit(1)
154 def project_id_by_name(rpc, linkname):
155 """Given a project short name, look up the Project ID."""
156 if len(linkname) == 0:
157 return 0
158 projects = rpc.project_list(linkname, 0)
159 for project in projects:
160 if project['linkname'] == linkname:
161 return project['id']
162 return 0
164 def state_id_by_name(rpc, name):
165 """Given a partial state name, look up the state ID."""
166 if len(name) == 0:
167 return 0
168 states = rpc.state_list(name, 0)
169 for state in states:
170 if state['name'].lower().startswith(name.lower()):
171 return state['id']
172 return 0
174 def person_ids_by_name(rpc, name):
175 """Given a partial name or email address, return a list of the
176 person IDs that match."""
177 if len(name) == 0:
178 return []
179 people = rpc.person_list(name, 0)
180 return map(lambda x: x['id'], people)
182 def list_patches(patches):
183 """Dump a list of patches to stdout."""
184 print("%-5s %-12s %s" % ("ID", "State", "Name"))
185 print("%-5s %-12s %s" % ("--", "-----", "----"))
186 for patch in patches:
187 print("%-5d %-12s %s" % (patch['id'], patch['state'], patch['name']))
189 def action_list(rpc, filter, submitter_str, delegate_str):
190 filter.resolve_ids(rpc)
192 if submitter_str != "":
193 ids = person_ids_by_name(rpc, submitter_str)
194 if len(ids) == 0:
195 sys.stderr.write("Note: Nobody found matching *%s*\n", \
196 submitter_str)
197 else:
198 for id in ids:
199 person = rpc.person_get(id)
200 print "Patches submitted by %s <%s>:" % \
201 (person['name'], person['email'])
202 f = filter
203 f.add("submitter_id", id)
204 patches = rpc.patch_list(f.d)
205 list_patches(patches)
206 return
208 if delegate_str != "":
209 ids = person_ids_by_name(rpc, delegate_str)
210 if len(ids) == 0:
211 sys.stderr.write("Note: Nobody found matching *%s*\n", \
212 delegate_str)
213 else:
214 for id in ids:
215 person = rpc.person_get(id)
216 print "Patches delegated to %s <%s>:" % \
217 (person['name'], person['email'])
218 f = filter
219 f.add("delegate_id", id)
220 patches = rpc.patch_list(f.d)
221 list_patches(patches)
222 return
224 patches = rpc.patch_list(filter.d)
225 list_patches(patches)
227 def action_projects(rpc):
228 projects = rpc.project_list("", 0)
229 print("%-5s %-24s %s" % ("ID", "Name", "Description"))
230 print("%-5s %-24s %s" % ("--", "----", "-----------"))
231 for project in projects:
232 print("%-5d %-24s %s" % (project['id'], \
233 project['linkname'], \
234 project['name']))
236 def action_states(rpc):
237 states = rpc.state_list("", 0)
238 print("%-5s %s" % ("ID", "Name"))
239 print("%-5s %s" % ("--", "----"))
240 for state in states:
241 print("%-5d %s" % (state['id'], state['name']))
243 def action_get(rpc, patch_id):
244 patch = rpc.patch_get(patch_id)
245 s = rpc.patch_get_mbox(patch_id)
247 if patch == {} or len(s) == 0:
248 sys.stderr.write("Unable to get patch %d\n" % patch_id)
249 sys.exit(1)
251 base_fname = fname = os.path.basename(patch['filename'])
252 i = 0
253 while os.path.exists(fname):
254 fname = "%s.%d" % (base_fname, i)
255 i += 1
257 try:
258 f = open(fname, "w")
259 except:
260 sys.stderr.write("Unable to open %s for writing\n" % fname)
261 sys.exit(1)
263 try:
264 f.write(unicode(s).encode("utf-8"))
265 f.close()
266 print "Saved patch to %s" % fname
267 except:
268 sys.stderr.write("Failed to write to %s\n" % fname)
269 sys.exit(1)
271 def action_apply(rpc, patch_id):
272 patch = rpc.patch_get(patch_id)
273 if patch == {}:
274 sys.stderr.write("Error getting information on patch ID %d\n" % \
275 patch_id)
276 sys.exit(1)
277 print "Applying patch #%d to current directory" % patch_id
278 print "Description: %s" % patch['name']
279 s = rpc.patch_get_mbox(patch_id)
280 if len(s) > 0:
281 proc = subprocess.Popen(['patch', '-p1'], stdin = subprocess.PIPE)
282 proc.communicate(s)
283 else:
284 sys.stderr.write("Error: No patch content found\n")
285 sys.exit(1)
287 def action_update_patch(rpc, patch_id, state = None, commit = None,
288 delegate_str = "", comment_str = "None",
289 archived = False):
290 patch = rpc.patch_get(patch_id)
291 if patch == {}:
292 sys.stderr.write("Error getting information on patch ID %d\n" % \
293 patch_id)
294 sys.exit(1)
296 params = {}
298 delegate_id = None
299 if delegate_str != "":
300 params['delegate'] = delegate_str
301 ids = person_ids_by_name(rpc, delegate_str)
302 if len(ids) == 0:
303 sys.stderr.write("Note: Nobody found matching *%s*\n"% \
304 delegate_str)
305 else:
306 delegate_id = ids[0]
308 if state:
309 state_id = state_id_by_name(rpc, state)
310 if state_id == 0:
311 sys.stderr.write("Error: No State found matching %s*\n" % state)
312 sys.exit(1)
313 params['state'] = state_id
315 if state.lower() in notify_on_state_change:
316 if not delegate_id:
317 sys.stderr.write("Error: Delegate (-d) required for this update\n")
318 sys.exit(1)
320 person = rpc.person_get(delegate_id)
321 submitter = rpc.person_get(patch['submitter_id'])
323 from_addr = '%s <%s>' % (person['name'], person['email'])
324 cc_addr = '%s <%s>' % (submitter['name'], submitter['email'])
325 to_addr = notify_on_state_change[state.lower()]
327 orig_mail = rpc.patch_get_mbox(patch_id)
328 orig_mail = '> ' + orig_mail.replace('\n','\n> ')
330 longdesc = '''Patch %s (http://patchwork.newartisans.com/patch/%s/) is now "%s".
332 Maintainer comment: %s
334 This relates to the following submission:
336 http://mid.gmane.org/%s
338 Here is the original message containing the patch:
340 %s''' % \
341 (patch['id'], patch['id'], state, comment_str, urllib.quote(patch['msgid']), orig_mail)
342 shortdesc = '[%s] %s' % (state, patch['name'])
344 msg = MIMEText(longdesc)
346 msg['Subject'] = shortdesc
347 msg['From'] = from_addr
348 msg['To'] = to_addr
349 #msg['Cc'] = cc_addr
350 msg['References'] = patch['msgid']
352 # Send the message via our own SMTP server, but don't include
353 # the envelope header.
354 try:
355 s = smtplib.SMTP('localhost')
356 print "Sending e-mail to: %s, %s" % (to_addr, cc_addr)
357 s.sendmail(from_addr, [to_addr, cc_addr], msg.as_string())
358 s.quit()
359 except:
360 sys.stderr.write("Warning: Failed to send e-mail " +
361 "(no SMTP server listening at localhost?)\n")
363 if commit:
364 params['commit_ref'] = commit
366 if archived:
367 params['archived'] = archived
369 success = False
370 try:
371 print "Updating patch %d to state '%s', delegate %s" % \
372 (patch_id, state, delegate_str)
373 success = rpc.patch_set(patch_id, params)
374 except xmlrpclib.Fault, f:
375 sys.stderr.write("Error updating patch: %s\n" % f.faultString)
377 if not success:
378 sys.stderr.write("Patch not updated\n")
380 def patch_id_from_hash(rpc, project, hash):
381 try:
382 patch = rpc.patch_get_by_project_hash(project, hash)
383 except xmlrpclib.Fault:
384 # the server may not have the newer patch_get_by_project_hash function,
385 # so fall back to hash-only.
386 patch = rpc.patch_get_by_hash(hash)
388 if patch == {}:
389 return None
391 return patch['id']
393 def branch_with(patch_id, rpc, delegate_str):
394 s = rpc.patch_get_mbox(patch_id)
395 if len(s) > 0:
396 print unicode(s).encode("utf-8")
398 patch = rpc.patch_get(patch_id)
400 # Find the latest commit from the day before the patch
401 proc = subprocess.Popen(['git', 'log', '--until=' + patch['date'],
402 '-1', '--format=%H', 'master'],
403 stdout = subprocess.PIPE)
404 sha = proc.stdout.read()[:-1]
406 # Create a topic branch named after this commit
407 proc = subprocess.Popen(['git', 'checkout', '-b', 't/patch%s' %
408 patch_id, sha])
409 sts = os.waitpid(proc.pid, 0)
410 if sts[1] != 0:
411 sys.stderr.write("Could not create branch for patch\n")
412 return
414 # Apply the patch to the branch
415 fname = '/tmp/patch'
416 try:
417 f = open(fname, "w")
418 except:
419 sys.stderr.write("Unable to open %s for writing\n" % fname)
420 sys.exit(1)
422 try:
423 f.write(unicode(s).encode("utf-8"))
424 f.close()
425 print "Saved patch to %s" % fname
426 except:
427 sys.stderr.write("Failed to write to %s\n" % fname)
428 sys.exit(1)
430 proc = subprocess.Popen(['git', 'am', '/tmp/patch'])
431 sts = os.waitpid(proc.pid, 0)
432 if sts[1] != 0:
433 sys.stderr.write("Failed to apply patch to branch\n")
434 proc = subprocess.Popen(['git', 'checkout', 'master'])
435 os.waitpid(proc.pid, 0)
436 proc = subprocess.Popen(['git', 'branch', '-D', 't/patch%s' %
437 patch_id, sha])
438 os.waitpid(proc.pid, 0)
439 return
441 # If it succeeded this far, mark the patch as "Under Review" by the
442 # invoking user.
443 action_update_patch(rpc, patch_id, state = 'Under Review',
444 delegate_str = delegate_str)
446 proc = subprocess.Popen(['git', 'rebase', 'master'])
447 sts = os.waitpid(proc.pid, 0)
449 print sha
451 def merge_with(patch_id, rpc, delegate_str, comment_str):
452 s = rpc.patch_get_mbox(patch_id)
453 if len(s) > 0:
454 print unicode(s).encode("utf-8")
456 proc = subprocess.Popen(['git', 'checkout', 'master'])
457 sts = os.waitpid(proc.pid, 0)
458 if sts[1] != 0:
459 sys.stderr.write("Failed to checkout master branch\n")
460 return
462 proc = subprocess.Popen(['git', 'merge', '--ff', 't/patch%s' % patch_id])
463 sts = os.waitpid(proc.pid, 0)
464 if sts[1] != 0:
465 sys.stderr.write("Failed to merge t/patch%s into master\n" % patch_id)
466 return
468 proc = subprocess.Popen(['git', 'rev-parse', 'master'],
469 stdout = subprocess.PIPE)
470 sha = proc.stdout.read()[:-1]
472 # If it succeeded this far, mark the patch as "Accepted" by the invoking
473 # user.
474 action_update_patch(rpc, patch_id, state = 'Accepted', commit = sha,
475 delegate_str = delegate_str, comment_str = comment_str,
476 archived = True)
478 print sha
480 auth_actions = ['update', 'branch', 'merge']
482 def main():
483 try:
484 opts, args = getopt.getopt(sys.argv[2:], 's:p:w:d:n:c:h:m:')
485 except getopt.GetoptError, err:
486 print str(err)
487 usage()
489 if len(sys.argv) < 2:
490 usage()
492 action = sys.argv[1].lower()
494 # set defaults
495 filt = Filter()
496 submitter_str = ""
497 delegate_str = ""
498 project_str = ""
499 commit_str = ""
500 state_str = "New"
501 hash_str = ""
502 url = DEFAULT_URL
504 config = ConfigParser.ConfigParser()
505 config.read(CONFIG_FILES)
507 # grab settings from config files
508 if config.has_option('base', 'url'):
509 url = config.get('base', 'url')
511 if config.has_option('base', 'project'):
512 project_str = config.get('base', 'project')
514 comment_str = "none"
515 for name, value in opts:
516 if name == '-s':
517 state_str = value
518 elif name == '-p':
519 project_str = value
520 elif name == '-w':
521 submitter_str = value
522 elif name == '-d':
523 delegate_str = value
524 elif name == '-c':
525 commit_str = value
526 elif name == '-h':
527 hash_str = value
528 elif name == '-m':
529 comment_str = value
530 elif name == '-n':
531 try:
532 filt.add("max_count", int(value))
533 except:
534 sys.stderr.write("Invalid maximum count '%s'\n" % value)
535 usage()
536 else:
537 sys.stderr.write("Unknown option '%s'\n" % name)
538 usage()
540 if len(args) > 1:
541 sys.stderr.write("Too many arguments specified\n")
542 usage()
544 (username, password) = (None, None)
545 transport = None
546 if action in auth_actions:
547 if config.has_option('auth', 'username') and \
548 config.has_option('auth', 'password'):
550 use_https = url.startswith('https')
552 transport = BasicHTTPAuthTransport( \
553 config.get('auth', 'username'),
554 config.get('auth', 'password'),
555 use_https)
557 else:
558 sys.stderr.write(("The %s action requires authentication, "
559 "but no username or password\nis configured\n") % action)
560 sys.exit(1)
562 if project_str:
563 filt.add("project", project_str)
565 if state_str:
566 filt.add("state", state_str)
568 try:
569 rpc = xmlrpclib.Server(url, transport = transport)
570 except:
571 sys.stderr.write("Unable to connect to %s\n" % url)
572 sys.exit(1)
574 patch_id = None
575 if hash_str:
576 patch_id = patch_id_from_hash(rpc, project_str, hash_str)
577 if patch_id is None:
578 sys.stderr.write("No patch has the hash provided\n")
579 sys.exit(1)
582 if action == 'list' or action == 'search':
583 if len(args) > 0:
584 filt.add("name__icontains", args[0])
585 action_list(rpc, filt, submitter_str, delegate_str)
587 elif action.startswith('project'):
588 action_projects(rpc)
590 elif action.startswith('state'):
591 action_states(rpc)
593 elif action == 'branch':
594 try:
595 patch_id = patch_id or int(args[0])
596 except:
597 sys.stderr.write("Invalid patch ID given\n")
598 sys.exit(1)
600 branch_with(patch_id, rpc, config.get('auth', 'username'))
602 elif action == 'merge':
603 try:
604 patch_id = patch_id or int(args[0])
605 except:
606 sys.stderr.write("Invalid patch ID given\n")
607 sys.exit(1)
609 merge_with(patch_id, rpc, config.get('auth', 'username'), comment_str)
611 elif action == 'test':
613 patch_id = patch_id or int(args[0])
614 patch = rpc.patch_get(patch_id)
615 s = rpc.patch_get_mbox(patch_id)
616 print "xxx %s xxx"
617 print "xxx %s xxx" % patch['name']
618 print "xxx %s xxx" % rpc.patch_get_mbox(patch_id)
620 elif action == 'view' or action == 'show':
621 try:
622 patch_id = patch_id or int(args[0])
623 except:
624 sys.stderr.write("Invalid patch ID given\n")
625 sys.exit(1)
627 s = rpc.patch_get_mbox(patch_id)
628 if len(s) > 0:
629 print unicode(s).encode("utf-8")
631 elif action == 'get' or action == 'save':
632 try:
633 patch_id = patch_id or int(args[0])
634 except:
635 sys.stderr.write("Invalid patch ID given\n")
636 sys.exit(1)
638 action_get(rpc, patch_id)
640 elif action == 'apply':
641 try:
642 patch_id = patch_id or int(args[0])
643 except:
644 sys.stderr.write("Invalid patch ID given\n")
645 sys.exit(1)
647 action_apply(rpc, patch_id)
649 elif action == 'update':
650 try:
651 patch_id = patch_id or int(args[0])
652 except:
653 sys.stderr.write("Invalid patch ID given\n")
654 sys.exit(1)
656 action_update_patch(rpc, patch_id, state = state_str,
657 commit = commit_str, delegate_str = delegate_str, comment_str = comment_str)
659 else:
660 sys.stderr.write("Unknown action '%s'\n" % action)
661 usage()
663 if __name__ == "__main__":
664 main()