diff options
50 files changed, 1843 insertions, 100 deletions
diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/303986f2-0b17-4589-bf76-ed1461699c3e/body b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/303986f2-0b17-4589-bf76-ed1461699c3e/body new file mode 100644 index 0000000..df90918 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/303986f2-0b17-4589-bf76-ed1461699c3e/body @@ -0,0 +1,16 @@ +Perhaps something like + be-handle-mail --notify-since <revision-id> +to tell subscribers about changes since the specified revision. + +This would duplicate mail to P in our first example above, but that's +not too annoying, and P might _want_ to know what R had merged from Q. + +On the other hand it would be annoying if 10 other repos merged Q and +ran the notification. + +We could make the subscription something like + subscribe BUG-ID HOST-LIST +e.g. + subscribe 1234 bugseverywhere.org,fancy_branch.com + subscribe abcd * +To allow users to whitelist hosts they want updates from. diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/303986f2-0b17-4589-bf76-ed1461699c3e/values b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/303986f2-0b17-4589-bf76-ed1461699c3e/values new file mode 100644 index 0000000..ddb7442 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/303986f2-0b17-4589-bf76-ed1461699c3e/values @@ -0,0 +1,11 @@ +Content-type: text/plain + + +Date: Tue, 21 Jul 2009 19:52:25 +0000 + + +From: W. Trevor King <wking@drexel.edu> + + +In-reply-to: 950ac308-f3e1-4956-885a-e79ce3025fd5 + diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/478443b3-dd69-4719-b79a-b1279f75b8e4/body b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/478443b3-dd69-4719-b79a-b1279f75b8e4/body new file mode 100644 index 0000000..8842587 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/478443b3-dd69-4719-b79a-b1279f75b8e4/body @@ -0,0 +1,5 @@ +"all" and "new" might be valid shortnames? + +Nope, UUID string representations are restricted to hex (0-9a-f) and +"-" as per RFC 4122 section 3. + diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/478443b3-dd69-4719-b79a-b1279f75b8e4/values b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/478443b3-dd69-4719-b79a-b1279f75b8e4/values new file mode 100644 index 0000000..64258d9 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/478443b3-dd69-4719-b79a-b1279f75b8e4/values @@ -0,0 +1,11 @@ +Content-type: text/plain + + +Date: Tue, 21 Jul 2009 19:53:02 +0000 + + +From: W. Trevor King <wking@drexel.edu> + + +In-reply-to: 85a2d1ac-200a-4ae7-841f-9f4e87795dbf + diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/85a2d1ac-200a-4ae7-841f-9f4e87795dbf/body b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/85a2d1ac-200a-4ae7-841f-9f4e87795dbf/body new file mode 100644 index 0000000..99d9cc3 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/85a2d1ac-200a-4ae7-841f-9f4e87795dbf/body @@ -0,0 +1,8 @@ +Obviously via the control interface: + subscribe #BUG-ID + subscribe new + subscribe all + unsubscribe #BUG-ID + ... +Implemented via .extra_strings, although we'll need +BugDir.extra_strings for the repo-wide new/all. diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/85a2d1ac-200a-4ae7-841f-9f4e87795dbf/values b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/85a2d1ac-200a-4ae7-841f-9f4e87795dbf/values new file mode 100644 index 0000000..eaad59f --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/85a2d1ac-200a-4ae7-841f-9f4e87795dbf/values @@ -0,0 +1,8 @@ +Content-type: text/plain + + +Date: Tue, 21 Jul 2009 19:34:20 +0000 + + +From: W. Trevor King <wking@drexel.edu> + diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/950ac308-f3e1-4956-885a-e79ce3025fd5/body b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/950ac308-f3e1-4956-885a-e79ce3025fd5/body new file mode 100644 index 0000000..890a4b6 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/950ac308-f3e1-4956-885a-e79ce3025fd5/body @@ -0,0 +1,10 @@ +This creates an interesting situation: + Person P subscribes to bug B in repo R. + Repo S merges repo R. + Person Q comments on B in S. + S notifies P :). +which is nice. However + Person P subscribes to bug B in repo R. + Person Q comments on B in repo S. + R merges S. + P never notified about Q's comment. diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/950ac308-f3e1-4956-885a-e79ce3025fd5/values b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/950ac308-f3e1-4956-885a-e79ce3025fd5/values new file mode 100644 index 0000000..710ad30 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/comments/950ac308-f3e1-4956-885a-e79ce3025fd5/values @@ -0,0 +1,11 @@ +Content-type: text/plain + + +Date: Tue, 21 Jul 2009 19:34:32 +0000 + + +From: W. Trevor King <wking@drexel.edu> + + +In-reply-to: 85a2d1ac-200a-4ae7-841f-9f4e87795dbf + diff --git a/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/values b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/values new file mode 100644 index 0000000..6dc3423 --- /dev/null +++ b/.be/bugs/3e331b72-51fd-4408-bc0d-b6c5ac3b9f3e/values @@ -0,0 +1,20 @@ +assigned: W. Trevor King <wking@drexel.edu> + + +creator: W. Trevor King <wking@drexel.edu> + + +reporter: W. Trevor King <wking@drexel.edu> + + +severity: minor + + +status: open + + +summary: 'subscribe/unsubscribe (bug #..., "new bugs", "all", etc.)' + + +time: Tue, 21 Jul 2009 19:27:04 +0000 + @@ -38,9 +38,9 @@ MODULES += ${DOC_DIR} RM = rm -#PREFIX = /usr/local -PREFIX = ${HOME} -INSTALL_OPTIONS = "--prefix=${PREFIX}" +PREFIX = /usr/local +#PREFIX = ${HOME} +#INSTALL_OPTIONS = "--prefix=${PREFIX}" .PHONY: all @@ -57,8 +57,8 @@ build: libbe/_version.py .PHONY: install install: doc build python setup.py install ${INSTALL_OPTIONS} - cp -v interfaces/xml/* ${PREFIX}/bin - cp -v interfaces/email/catmutt ${PREFIX}/bin +#cp -v interfaces/xml/* ${PREFIX}/bin +#cp -v interfaces/email/catmutt ${PREFIX}/bin .PHONY: clean diff --git a/becommands/assign.py b/becommands/assign.py index 536bca6..ba79aac 100644 --- a/becommands/assign.py +++ b/becommands/assign.py @@ -20,7 +20,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() @@ -28,17 +28,17 @@ def execute(args, test=False): >>> bd.bug_from_shortname("a").assigned is None True - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) >>> bd._clear_bugs() >>> bd.bug_from_shortname("a").assigned == bd.user_id True - >>> execute(["a", "someone"], test=True) + >>> execute(["a", "someone"], manipulate_encodings=False) >>> bd._clear_bugs() >>> print bd.bug_from_shortname("a").assigned someone - >>> execute(["a","none"], test=True) + >>> execute(["a","none"], manipulate_encodings=False) >>> bd._clear_bugs() >>> bd.bug_from_shortname("a").assigned is None True @@ -53,7 +53,8 @@ def execute(args, test=False): if len(args) > 2: help() raise cmdutil.UsageError("Too many arguments.") - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bug = bd.bug_from_shortname(args[0]) if len(args) == 1: bug.assigned = bd.user_id diff --git a/becommands/close.py b/becommands/close.py index 0ba8f50..05bdc10 100644 --- a/becommands/close.py +++ b/becommands/close.py @@ -20,7 +20,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> from libbe import bugdir >>> import os @@ -28,7 +28,7 @@ def execute(args, test=False): >>> os.chdir(bd.root) >>> print bd.bug_from_shortname("a").status open - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) >>> bd._clear_bugs() >>> print bd.bug_from_shortname("a").status closed @@ -41,7 +41,8 @@ def execute(args, test=False): raise cmdutil.UsageError("Please specify a bug id.") if len(args) > 1: raise cmdutil.UsageError("Too many arguments.") - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bug = bd.bug_from_shortname(args[0]) bug.status = "closed" bd.save() diff --git a/becommands/comment.py b/becommands/comment.py index 5392286..9408b09 100644 --- a/becommands/comment.py +++ b/becommands/comment.py @@ -25,12 +25,12 @@ except ImportError: # look for non-core module from elementtree import ElementTree __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import time >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) - >>> execute(["a", "This is a comment about a"], test=True) + >>> execute(["a", "This is a comment about a"], manipulate_encodings=False) >>> bd._clear_bugs() >>> bug = bd.bug_from_shortname("a") >>> bug.load_comments(load_full=False) @@ -47,12 +47,12 @@ def execute(args, test=False): >>> if 'EDITOR' in os.environ: ... del os.environ["EDITOR"] - >>> execute(["b"], test=True) + >>> execute(["b"], manipulate_encodings=False) Traceback (most recent call last): UserError: No comment supplied, and EDITOR not specified. >>> os.environ["EDITOR"] = "echo 'I like cheese' > " - >>> execute(["b"], test=True) + >>> execute(["b"], manipulate_encodings=False) >>> bd._clear_bugs() >>> bug = bd.bug_from_shortname("b") >>> bug.load_comments(load_full=False) @@ -80,7 +80,8 @@ def execute(args, test=False): bugname = shortname is_reply = False - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bug = bd.bug_from_shortname(bugname) bug.load_comments(load_full=False) if is_reply: @@ -113,6 +114,10 @@ def execute(args, test=False): if options.XML == False: new = parent.new_reply(body=body) + if options.author != None: + new.From = options.author + if options.alt_id != None: + new.alt_id = options.alt_id if options.content_type != None: new.content_type = options.content_type else: # import XML comment [list] @@ -157,6 +162,10 @@ def execute(args, test=False): def get_parser(): parser = cmdutil.CmdOptionParser("be comment ID [COMMENT]") + parser.add_option("-a", "--author", metavar="AUTHOR", dest="author", + help="Set the comment author", default=None) + parser.add_option("--alt-id", metavar="ID", dest="alt_id", + help="Set an alternate comment ID", default=None) parser.add_option("-c", "--content-type", metavar="MIME", dest="content_type", help="Set comment content-type (e.g. text/plain)", default=None) parser.add_option("-x", "--xml", action="store_true", default=False, diff --git a/becommands/depend.py b/becommands/depend.py index 4a23b0f..201c2ea 100644 --- a/becommands/depend.py +++ b/becommands/depend.py @@ -18,22 +18,22 @@ from libbe import cmdutil, bugdir import os, copy __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> from libbe import utility >>> bd = bugdir.simple_bug_dir() >>> bd.save() >>> os.chdir(bd.root) - >>> execute(["a", "b"], test=True) + >>> execute(["a", "b"], manipulate_encodings=False) Blocks on a: b - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) Blocks on a: b - >>> execute(["--show-status", "a"], test=True) # doctest: +NORMALIZE_WHITESPACE + >>> execute(["--show-status", "a"], manipulate_encodings=False) # doctest: +NORMALIZE_WHITESPACE Blocks on a: b closed - >>> execute(["-r", "a", "b"], test=True) + >>> execute(["-r", "a", "b"], manipulate_encodings=False) """ parser = get_parser() options, args = parser.parse_args(args) @@ -47,7 +47,8 @@ def execute(args, test=False): help() raise cmdutil.UsageError("Too many arguments.") - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bugA = bd.bug_from_shortname(args[0]) if len(args) == 2: bugB = bd.bug_from_shortname(args[1]) diff --git a/becommands/diff.py b/becommands/diff.py index 491261e..50dea7c 100644 --- a/becommands/diff.py +++ b/becommands/diff.py @@ -20,7 +20,7 @@ from libbe import cmdutil, bugdir, diff import os __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() @@ -31,7 +31,7 @@ def execute(args, test=False): >>> changed = bd.rcs.commit("Closed bug a") >>> os.chdir(bd.root) >>> if bd.rcs.versioned == True: - ... execute([original], test=True) + ... execute([original], manipulate_encodings=False) ... else: ... print "a:cm: Bug A\\nstatus: open -> closed\\n" Modified bug reports: @@ -48,7 +48,8 @@ def execute(args, test=False): revision = args[0] if len(args) > 1: raise cmdutil.UsageError("Too many arguments.") - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) if bd.rcs.versioned == False: print "This directory is not revision-controlled." else: diff --git a/becommands/help.py b/becommands/help.py index a8ae338..a8f346a 100644 --- a/becommands/help.py +++ b/becommands/help.py @@ -19,9 +19,11 @@ from libbe import cmdutil, utility __desc__ = __doc__ -def execute(args): +def execute(args, manipulate_encodings=False): """ - Print help of specified command. + Print help of specified command (the manipulate_encodings argument + is ignored). + >>> execute(["help"]) Usage: be help [COMMAND] <BLANKLINE> diff --git a/becommands/init.py b/becommands/init.py index 5b2a416..4156a26 100644 --- a/becommands/init.py +++ b/becommands/init.py @@ -19,7 +19,7 @@ import os.path from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> from libbe import utility, rcs >>> import os @@ -29,7 +29,7 @@ def execute(args, test=False): ... except bugdir.NoBugDir, e: ... True True - >>> execute(['--root', dir.path], test=True) + >>> execute(['--root', dir.path], manipulate_encodings=False) No revision control detected. Directory initialized. >>> del(dir) @@ -40,17 +40,17 @@ def execute(args, test=False): >>> rcs.init('.') >>> print rcs.name Arch - >>> execute([], test=True) + >>> execute([], manipulate_encodings=False) Using Arch for revision control. Directory initialized. >>> rcs.cleanup() >>> try: - ... execute(['--root', '.'], test=True) + ... execute(['--root', '.'], manipulate_encodings=False) ... except cmdutil.UserError, e: ... str(e).startswith("Directory already initialized: ") True - >>> execute(['--root', '/highly-unlikely-to-exist'], test=True) + >>> execute(['--root', '/highly-unlikely-to-exist'], manipulate_encodings=False) Traceback (most recent call last): UserError: No such directory: /highly-unlikely-to-exist >>> os.chdir('/') @@ -64,7 +64,7 @@ def execute(args, test=False): bd = bugdir.BugDir(options.root_dir, from_disk=False, sink_to_existing_root=False, assert_new_BugDir=True, - manipulate_encodings=not test) + manipulate_encodings=manipulate_encodings) except bugdir.NoRootEntry: raise cmdutil.UserError("No such directory: %s" % options.root_dir) except bugdir.AlreadyInitialized: diff --git a/becommands/list.py b/becommands/list.py index 5ba1821..50038e6 100644 --- a/becommands/list.py +++ b/becommands/list.py @@ -26,14 +26,14 @@ __desc__ = __doc__ AVAILABLE_CMPS = [fn[4:] for fn in dir(bug) if fn[:4] == 'cmp_'] AVAILABLE_CMPS.remove("attr") # a cmp_* template. -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) - >>> execute([], test=True) + >>> execute([], manipulate_encodings=False) a:om: Bug A - >>> execute(["--status", "all"], test=True) + >>> execute(["--status", "all"], manipulate_encodings=False) a:om: Bug A b:cm: Bug B """ @@ -46,11 +46,13 @@ def execute(args, test=False): if options.sort_by != None: for cmp in options.sort_by.split(','): if cmp not in AVAILABLE_CMPS: - raise cmdutil.UserError("Invalid sort on '%s'.\nValid sorts:\n %s" - % (cmp, '\n '.join(AVAILABLE_CMPS))) + raise cmdutil.UserError( + "Invalid sort on '%s'.\nValid sorts:\n %s" + % (cmp, '\n '.join(AVAILABLE_CMPS))) cmp_list.append(eval('bug.cmp_%s' % cmp)) - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bd.load_all_bugs() # select status if options.status != None: diff --git a/becommands/merge.py b/becommands/merge.py index 4aaefa8..8e1fd50 100644 --- a/becommands/merge.py +++ b/becommands/merge.py @@ -18,7 +18,7 @@ from libbe import cmdutil, bugdir import os, copy __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> from libbe import utility >>> bd = bugdir.simple_bug_dir() @@ -37,7 +37,7 @@ def execute(args, test=False): >>> dummy = dummy.new_reply("1 2 3 4") >>> dummy.time = 2 >>> os.chdir(bd.root) - >>> execute(["a", "b"], test=True) + >>> execute(["a", "b"], manipulate_encodings=False) Merging bugs a and b >>> bd._clear_bugs() >>> a = bd.bug_from_shortname("a") @@ -133,7 +133,8 @@ def execute(args, test=False): help() raise cmdutil.UsageError("Too many arguments.") - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bugA = bd.bug_from_shortname(args[0]) bugA.load_comments() bugB = bd.bug_from_shortname(args[1]) diff --git a/becommands/new.py b/becommands/new.py index af599d7..2487bac 100644 --- a/becommands/new.py +++ b/becommands/new.py @@ -19,14 +19,14 @@ from libbe import cmdutil, bugdir import sys __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os, time >>> from libbe import bug >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) >>> bug.uuid_gen = lambda: "X" - >>> execute (["this is a test",], test=True) + >>> execute (["this is a test",], manipulate_encodings=False) Created bug with ID X >>> bd.load() >>> bug = bd.bug_from_uuid("X") @@ -44,7 +44,8 @@ def execute(args, test=False): cmdutil.default_complete(options, args, parser) if len(args) != 1: raise cmdutil.UsageError("Please supply a summary message") - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) if args[0] == '-': # read summary from stdin summary = sys.stdin.readline() else: diff --git a/becommands/open.py b/becommands/open.py index 2ef5f43..b98463d 100644 --- a/becommands/open.py +++ b/becommands/open.py @@ -20,14 +20,14 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) >>> print bd.bug_from_shortname("b").status closed - >>> execute(["b"], test=True) + >>> execute(["b"], manipulate_encodings=False) >>> bd._clear_bugs() >>> print bd.bug_from_shortname("b").status open @@ -40,7 +40,8 @@ def execute(args, test=False): raise cmdutil.UsageError, "Please specify a bug id." if len(args) > 1: raise cmdutil.UsageError, "Too many arguments." - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bug = bd.bug_from_shortname(args[0]) bug.status = "open" diff --git a/becommands/remove.py b/becommands/remove.py index d79a7be..884b792 100644 --- a/becommands/remove.py +++ b/becommands/remove.py @@ -17,7 +17,7 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> from libbe import mapfile >>> import os @@ -25,7 +25,7 @@ def execute(args, test=False): >>> os.chdir(bd.root) >>> print bd.bug_from_shortname("b").status closed - >>> execute (["b"], test=True) + >>> execute (["b"], manipulate_encodings=False) Removed bug b >>> bd._clear_bugs() >>> try: @@ -40,7 +40,8 @@ def execute(args, test=False): bugid_args={0: lambda bug : bug.active==True}) if len(args) != 1: raise cmdutil.UsageError, "Please specify a bug id." - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bug = bd.bug_from_shortname(args[0]) bd.remove_bug(bug) print "Removed bug %s" % bug.uuid diff --git a/becommands/set.py b/becommands/set.py index 0c0862f..f7fca54 100644 --- a/becommands/set.py +++ b/becommands/set.py @@ -32,18 +32,18 @@ def _value_string(bd, setting): val = None return str(val) -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) - >>> execute(["target"], test=True) + >>> execute(["target"], manipulate_encodings=False) None - >>> execute(["target", "tomorrow"], test=True) - >>> execute(["target"], test=True) + >>> execute(["target", "tomorrow"], manipulate_encodings=False) + >>> execute(["target"], manipulate_encodings=False) tomorrow - >>> execute(["target", "none"], test=True) - >>> execute(["target"], test=True) + >>> execute(["target", "none"], manipulate_encodings=False) + >>> execute(["target"], manipulate_encodings=False) None """ parser = get_parser() @@ -51,7 +51,8 @@ def execute(args, test=False): complete(options, args, parser) if len(args) > 2: raise cmdutil.UsageError, "Too many arguments" - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) if len(args) == 0: keys = bd.settings_properties keys.sort() diff --git a/becommands/severity.py b/becommands/severity.py index 65467e3..d125789 100644 --- a/becommands/severity.py +++ b/becommands/severity.py @@ -20,17 +20,17 @@ from libbe import cmdutil, bugdir, bug __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) minor - >>> execute(["a", "wishlist"], test=True) - >>> execute(["a"], test=True) + >>> execute(["a", "wishlist"], manipulate_encodings=False) + >>> execute(["a"], manipulate_encodings=False) wishlist - >>> execute(["a", "none"], test=True) + >>> execute(["a", "none"], manipulate_encodings=False) Traceback (most recent call last): UserError: Invalid severity level: none """ @@ -39,7 +39,8 @@ def execute(args, test=False): complete(options, args, parser) if len(args) not in (1,2): raise cmdutil.UsageError - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bug = bd.bug_from_shortname(args[0]) if len(args) == 1: print bug.severity diff --git a/becommands/show.py b/becommands/show.py index e43cfb9..b7bfe78 100644 --- a/becommands/show.py +++ b/becommands/show.py @@ -22,12 +22,12 @@ import sys from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) - >>> execute (["a",], test=True) # doctest: +ELLIPSIS + >>> execute (["a",], manipulate_encodings=False) # doctest: +ELLIPSIS ID : a Short name : a Severity : minor @@ -39,7 +39,7 @@ def execute(args, test=False): Created : ... Bug A <BLANKLINE> - >>> execute (["--xml", "a"], test=True) # doctest: +ELLIPSIS + >>> execute (["--xml", "a"], manipulate_encodings=False) # doctest: +ELLIPSIS <?xml version="1.0" encoding="..." ?> <bug> <uuid>a</uuid> @@ -57,7 +57,8 @@ def execute(args, test=False): bugid_args={-1: lambda bug : bug.active==True}) if len(args) == 0: raise cmdutil.UsageError - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) if options.XML: print '<?xml version="1.0" encoding="%s" ?>' % bd.encoding for shortname in args: diff --git a/becommands/status.py b/becommands/status.py index edc948d..56cb505 100644 --- a/becommands/status.py +++ b/becommands/status.py @@ -17,17 +17,17 @@ from libbe import cmdutil, bugdir, bug __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) open - >>> execute(["a", "closed"], test=True) - >>> execute(["a"], test=True) + >>> execute(["a", "closed"], manipulate_encodings=False) + >>> execute(["a"], manipulate_encodings=False) closed - >>> execute(["a", "none"], test=True) + >>> execute(["a", "none"], manipulate_encodings=False) Traceback (most recent call last): UserError: Invalid status: none """ @@ -36,7 +36,8 @@ def execute(args, test=False): complete(options, args, parser) if len(args) not in (1,2): raise cmdutil.UsageError - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) bug = bd.bug_from_shortname(args[0]) if len(args) == 1: print bug.status @@ -55,7 +56,8 @@ def get_parser(): def help(): try: # See if there are any per-tree status configurations - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=False) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=False) except bugdir.NoBugDir, e: pass # No tree, just show the defaults longest_status_len = max([len(s) for s in bug.status_values]) diff --git a/becommands/tag.py b/becommands/tag.py index 216ffbc..5c4b7a6 100644 --- a/becommands/tag.py +++ b/becommands/tag.py @@ -18,7 +18,7 @@ from libbe import cmdutil, bugdir import os, copy __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> from libbe import utility >>> bd = bugdir.simple_bug_dir() @@ -27,25 +27,25 @@ def execute(args, test=False): >>> a = bd.bug_from_shortname("a") >>> print a.extra_strings [] - >>> execute(["a", "GUI"], test=True) + >>> execute(["a", "GUI"], manipulate_encodings=False) Tags for a: GUI >>> bd._clear_bugs() # resync our copy of bug >>> a = bd.bug_from_shortname("a") >>> print a.extra_strings ['TAG:GUI'] - >>> execute(["a", "later"], test=True) + >>> execute(["a", "later"], manipulate_encodings=False) Tags for a: GUI later - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) Tags for a: GUI later - >>> execute(["--list"], test=True) + >>> execute(["--list"], manipulate_encodings=False) GUI later - >>> execute(["a", "Alphabetically first"], test=True) + >>> execute(["a", "Alphabetically first"], manipulate_encodings=False) Tags for a: Alphabetically first GUI @@ -57,15 +57,15 @@ def execute(args, test=False): >>> a.extra_strings = [] >>> print a.extra_strings [] - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) >>> bd._clear_bugs() # resync our copy of bug >>> a = bd.bug_from_shortname("a") >>> print a.extra_strings [] - >>> execute(["a", "Alphabetically first"], test=True) + >>> execute(["a", "Alphabetically first"], manipulate_encodings=False) Tags for a: Alphabetically first - >>> execute(["--remove", "a", "Alphabetically first"], test=True) + >>> execute(["--remove", "a", "Alphabetically first"], manipulate_encodings=False) """ parser = get_parser() options, args = parser.parse_args(args) @@ -78,7 +78,8 @@ def execute(args, test=False): help() raise cmdutil.UsageError("Too many arguments.") - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) if options.list: bd.load_all_bugs() tags = [] diff --git a/becommands/target.py b/becommands/target.py index 527b16a..541918c 100644 --- a/becommands/target.py +++ b/becommands/target.py @@ -22,20 +22,20 @@ from libbe import cmdutil, bugdir __desc__ = __doc__ -def execute(args, test=False): +def execute(args, manipulate_encodings=True): """ >>> import os >>> bd = bugdir.simple_bug_dir() >>> os.chdir(bd.root) - >>> execute(["a"], test=True) + >>> execute(["a"], manipulate_encodings=False) No target assigned. - >>> execute(["a", "tomorrow"], test=True) - >>> execute(["a"], test=True) + >>> execute(["a", "tomorrow"], manipulate_encodings=False) + >>> execute(["a"], manipulate_encodings=False) tomorrow - >>> execute(["--list"], test=True) + >>> execute(["--list"], manipulate_encodings=False) tomorrow - >>> execute(["a", "none"], test=True) - >>> execute(["a"], test=True) + >>> execute(["a", "none"], manipulate_encodings=False) + >>> execute(["a"], manipulate_encodings=False) No target assigned. """ parser = get_parser() @@ -46,7 +46,8 @@ def execute(args, test=False): if len(args) not in (1, 2): if not (options.list == True and len(args) == 0): raise cmdutil.UsageError - bd = bugdir.BugDir(from_disk=True, manipulate_encodings=not test) + bd = bugdir.BugDir(from_disk=True, + manipulate_encodings=manipulate_encodings) if options.list: ts = set([bd.bug_from_uuid(bug).target for bug in bd.list_uuids()]) for target in sorted(ts): diff --git a/interfaces/README b/interfaces/README new file mode 100644 index 0000000..4d74580 --- /dev/null +++ b/interfaces/README @@ -0,0 +1,34 @@ +Removing spam commits from the history +====================================== + +arch bzr darcs git hg none + +In the case that some spam or inappropriate comment makes its way +through you interface, you can remove the offending commit XYZ with: + + If the offending commit is the last commit: + + arch: + bzr: bzr uncommit && bzr revert + darcs: darcs obliterate --last=1 + git: git reset --hard HEAD^ + hg: hg rollback && hg revert + + If the offending commit is not the last commit: + + arch: + bzr: bzr rebase -r <XYZ+1>..-1 --onto before:XYZ . + (requires bzr-rebase plugin, note, you have to increment XYZ by + hand for <XYZ+1>, because bzr does not support "after:XYZ".) + darcs: darcs obliterate --matches 'name XYZ' + git: git rebase --onto XYZ~1 XYZ + hg: -not-supported- + (From http://hgbook.red-bean.com/read/finding-and-fixing-mistakes.html#id394667 + "Mercurial also does not provide a way to make a file or + changeset completely disappear from history, because there is no + way to enforce its disappearance") + +Note that all of these _change_the_repo_history_, so only do this on +your interface-specific repo before it interacts with any other repo. +Otherwise, you'll have to survive by cherry-picking only the good +commits. diff --git a/interfaces/email/interactive/README b/interfaces/email/interactive/README new file mode 100644 index 0000000..8954383 --- /dev/null +++ b/interfaces/email/interactive/README @@ -0,0 +1,145 @@ +Overview +======== + +The interactive email interface to Bugs Everywhere (BE) attempts to +provide a Debian-bug-tracking-system-style interface to a BE +repository. Users can mail in bug reports, comments, or control +requests, which will be committed to the served repository. +Developers can then pull the changes they approve of from the served +repository into their other repositories and push updates back onto +the served repository. + +For details about the Debian bug tracking system that inspired this +interface, see http://www.debian.org/Bugs . + +Architecture +============ + +In order to reduce setup costs, the entire interface can piggyback on +an existing email address, although from a security standpoint it's +probably best to create a dedicated user. Incoming email is filtered +by procmail, with matching emails being piped into be-handle-mail for +execution. + +Once be-handle-mail receives the email, the parsing method is selected +according to the subject tag that procmail used grab the email in the +first place. There are three parsing styles: + Style Subject + creating bugs [be-bug:submit] new bug summary + commenting on bugs [be-bug:<bug-id>] human-specific subject + control [be-bug] human-specific subject +These are analogous to submit@bugs.debian.org, nnn@bugs.debian.org, +and control@bugs.debian.org respectively. + +Creating bugs +============= + +This interface creates a bug whose summary is given by the email's +post-tag subject. The body of the email must begin with a +pseudo-header containing at least the "Version" field. Anything after +the pseudo-header and before a line starting with '--' is, if present, +attached as the bug's first comment. + + From jdoe@example.com Fri Apr 18 12:00:00 2008 + From: John Doe <jdoe@example.com> + Date: Fri, 18 Apr 2008 12:00:00 +0000 + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 8bit + Subject: [be-bug:submit] Need tests for the email interface. + + Version: XYZ + Severity: minor + + Someone should write up a series of test emails to send into + be-handle mail so we can test changes quickly without having to + use procmail. + + -- + Goofy tagline not included. + +Available pseudo-headers are Version, Reporter, Assign, Depend, +Severity, Status, Tag, and Target. + +Commenting on bugs +================== + +This interface appends a comment to the bug specified in the subject +tag. The the first non-multipart body is attached with the +appropriate content-type. In the case of "text/plain" contents, +anything following a line starting with '--' is stripped. + + From jdoe@example.com Fri Apr 18 12:00:00 2008 + From: John Doe <jdoe@example.com> + Date: Fri, 18 Apr 2008 12:00:00 +0000 + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 8bit + Subject: [be-bug:XYZ] Isolated problem in baz() + + Finally tracked it down to the bar() call. Some sort of + string<->unicode conversion problem. Solution ideas? + + -- + Goofy tagline not included. + +Controlling bugs +================ + +This interface consists of a list of allowed be commands, with one +command per line. Blank lines and lines beginning with '#' are +ignored, as well anything following a line starting with '--'. All +the listed commands are executed in order and their output returned. +The commands are split into arguments with the POSIX-compliant +shlex.split(). + + From jdoe@example.com Fri Apr 18 12:00:00 2008 + From: John Doe <jdoe@example.com> + Date: Fri, 18 Apr 2008 12:00:00 +0000 + Content-Type: text/plain; charset=UTF-8 + Content-Transfer-Encoding: 8bit + Subject: [be-bug] I'll handle XYZ by release 1.2.3 + + assign XYZ "John Doe <jdoe@example.com>" + status XYZ assigned + severity XYZ critical + target XYZ 1.2.3 + + -- + Goofy tagline ignored. + +Example emails +============== + +Take a look at my interfaces/email/interactive/examples for some +more examples. + +Procmail rules +============== + +The file _procmailrc as it stands is fairly appropriate for as a +dedicated user's ~/.procmailrc. It forwards matching mail to +be-handle-mail, which should be installed somewhere in the user's +path. All non-matching mail is dumped into /dev/null. Everything +procmail does will be logged to ~/be-mail/procmail.log. + +If you're piggybacking the interface on top of an existing account, +you probably only need to add the be-handle-mail stanza to your +existing ~/.procmailrc, since you will still want to receive non-bug +emails. + +Note that you will probably have to add a + --be-dir /path/to/served/repository +option to the be-handle-mail invocation so it knows what repository to +serve. + +Multiple repositories may be served by the same email address by adding +multiple be-handle-mail stanzas, each matching a different tag, for +example the "[be-bug" portion of the stanza could be "[projectX-bug", +"[projectY-bug", etc. If you change the base tag, be sure to add a + --tag-base "projectX-bug" +or equivalent to your be-handle-mail invocation. + +Testing +======= + +Send test emails in to be-handle-mail with something like + cat examples/blank | ./be-handle-mail -o -l - -a diff --git a/interfaces/email/interactive/_procmailrc b/interfaces/email/interactive/_procmailrc new file mode 100644 index 0000000..c7daa1e --- /dev/null +++ b/interfaces/email/interactive/_procmailrc @@ -0,0 +1,22 @@ +# .procmailrc +# +# see man procmail, procmailrc, and procmailex +# +# If you already have a ~/.procmailrc file, you probably only need to +# insert the bug-email grabbing stanza in your ~/.procmailrc. +# +# This file is released to the Public Domain. + +MAILDIR=$HOME/be-mail +LOGFILE=$MAILDIR/procmail.log + +# Grab all incoming bug emails (but not replies). This rule eats +# matching emails (i.e. no further procmail processing). +:0 +* ^Subject: [be-bug +* !^Subject:.*[be-bug].*Re: +| be-handle-mail + +# Drop everything else +:0 +/dev/null diff --git a/interfaces/email/interactive/be-handle-mail b/interfaces/email/interactive/be-handle-mail new file mode 100755 index 0000000..f457b6a --- /dev/null +++ b/interfaces/email/interactive/be-handle-mail @@ -0,0 +1,673 @@ +#!/usr/bin/env python +# +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Provide and email interface to the distributed bugtracker Bugs +Everywhere. Recieves incoming email via procmail. Provides an +interface similar to the Debian Bug Tracker. There are currently +three distinct email types: submits, comments, and controls. The +email types are differentiated by tags in the email subject. See +SUBJECT_TAG* for the current values. + +Submit emails create a bug (and optionally add some intitial +comments). The post-tag subject is used as the bug summary, and the +email body is parsed for a pseudo-header. Any text after the +psuedo-header but before a possible line starting with BREAK is added +as the initial bug comment. + +Comment emails... + +Control emails... + +Any changes made to the repository are commited after the email is +executed, with the email's post-tag subject as the commit message. +""" + +import codecs +import cStringIO as StringIO +import email +from email.mime.multipart import MIMEMultipart +import email.utils +import os +import os.path +import re +import shlex +import sys +import time +import traceback +import doctest +import unittest + +import libbe.cmdutil, libbe.encoding, libbe.utility +import send_pgp_mime + +HANDLER_ADDRESS = u"BE Bugs <wking@thor.physics.drexel.edu>" +_THIS_DIR = os.path.abspath(os.path.dirname(__file__)) +BE_DIR = _THIS_DIR +LOGPATH = os.path.join(_THIS_DIR, u"be-handle-mail.log") +LOGFILE = None + +# Tag strings generated by generate_global_tags() +SUBJECT_TAG_BASE = u"be-bug" +SUBJECT_TAG_RESPONSE = None +SUBJECT_TAG_START = None +SUBJECT_TAG_NEW = None +SUBJECT_TAG_COMMENT = None +SUBJECT_TAG_CONTROL = None + +BREAK = u"--" +NEW_REQUIRED_PSEUDOHEADERS = [u"Version"] +NEW_OPTIONAL_PSEUDOHEADERS = [u"Reporter", u"Assign", u"Depend", u"Severity", + u"Status", u"Tag", u"Target"] +CONTROL_COMMENT = u"#" +ALLOWED_COMMANDS = [u"assign", u"comment", u"commit", u"depend", u"help", + u"list", u"merge", u"new", u"open", u"severity", u"show", + u"status", u"tag", u"target"] + +AUTOCOMMIT = True + +libbe.encoding.ENCODING = u"utf-8" # force default encoding +ENCODING = libbe.encoding.get_encoding() + +class InvalidEmail (ValueError): + def __init__(self, msg, message): + ValueError.__init__(self, message) + self.msg = msg + def response(self): + header = self.msg.response_header + body = [u"Error processing email:\n", + self.response_body(), u""] + response_generator = \ + send_pgp_mime.PGPMimeMessageFactory(u"\n".join(body)) + response = MIMEMultipart() + response.attach(response_generator.plain()) + response.attach(self.msg.msg) + ret = send_pgp_mime.attach_root(header, response) + return ret + def response_body(self): + err_text = [unicode(self)] + return u"\n".join(err_text) + +class InvalidSubject (InvalidEmail): + def __init__(self, msg, message=None): + if message == None: + message = u"Invalid subject" + InvalidEmail.__init__(self, msg, message) + def response_body(self): + err_text = u"\n".join([unicode(self), u"", + u"full subject was:", + self.msg.subject()]) + return err_text + +class InvalidPseudoHeader (InvalidEmail): + def response_body(self): + err_text = [u"Invalid pseudo-header:\n", + unicode(self)] + return u"\n".join(err_text) + +class InvalidCommand (InvalidEmail): + def __init__(self, msg, command, message=None): + bigmessage = u"Invalid execution command '%s'" % command + if message != None: + bigmessage += u"\n%s" % message + InvalidEmail.__init__(self, msg, bigmessage) + self.command = command + +class InvalidOption (InvalidCommand): + def __init__(self, msg, option, message=None): + bigmessage = u"Invalid option '%s'" % (option) + if message != None: + bigmessage += u"\n%s" % message + InvalidCommand.__init__(self, msg, info, command, bigmessage) + self.option = option + +class ID (object): + """ + Sometimes you want to reference the output of a command that + hasn't been executed yet. ID is there for situations like + > a = Command(msg, "new", ["create a bug"]) + > b = Command(msg, "comment", [ID(a), "and comment on it"]) + """ + def __init__(self, command): + self.command = command + def extract_id(self): + if hasattr(self, "cached_id"): + return self.cached_id + assert self.command.ret == 0, self.command.ret + if self.command.command == u"new": + regexp = re.compile(u"Created bug with ID (.*)") + else: + raise NotImplementedError, self.command.command + match = regexp.match(self.command.stdout) + assert len(match.groups()) == 1, str(match.groups()) + self.cached_id = match.group(1) + return self.cached_id + def __str__(self): + if self.command.ret != 0: + return "<id for %s>" % repr(self.command) + return "<id %s>" % self.extract_id() + +class Command (object): + """ + A becommands command wrapper. + Doesn't validate input, so do that before initializing. + + Initialize with + Command(msg, command, args=None, stdin=None) + where + msg: the Message instance prompting this command + command: name of becommand to execute, e.g. "new" + args: list of arguments to pass to the command + stdin: if non-null, a string to pipe into the command's stdin + """ + def __init__(self, msg, command, args=None, stdin=None): + self.msg = msg + self.command = command + if args == None: + self.args = [] + else: + self.args = args + self.stdin = stdin + self.ret = None + self.stdout = None + self.stderr = None + self.err = None + def __str__(self): + return "<command: %s %s>" % (self.command, " ".join([str(s) for s in self.args])) + def normalize_args(self): + """ + Expand any ID placeholders in self.args. + """ + for i,arg in enumerate(self.args): + if isinstance(arg, ID): + self.args[i] = arg.extract_id() + def run(self): + """ + Attempt to execute the command whose info is given in the dictionary + info. Returns the exit code, stdout, and stderr produced by the + command. + """ + if self.command in [None, u""]: # don't accept blank commands + raise InvalidCommand(self.msg, self, "Blank") + elif self.command not in ALLOWED_COMMANDS: + raise InvalidCommand(self.msg, self, "Not allowed") + assert self.ret == None, u"running %s twice!" % unicode(self) + self.normalize_args() + # set stdin and catch stdout and stderr + if self.stdin != None: + new_stdin = StringIO.StringIO(self.stdin) + orig___stdin = sys.__stdin__ + sys.__stdin__ = new_stdin + orig_stdin = sys.stdin + sys.stdin = new_stdin + new_stdout = codecs.getwriter(ENCODING)(StringIO.StringIO()) + new_stderr = codecs.getwriter(ENCODING)(StringIO.StringIO()) + orig_stdout = sys.stdout + orig_stderr = sys.stderr + sys.stdout = new_stdout + sys.stderr = new_stderr + # run the command + os.chdir(BE_DIR) + try: + self.ret = libbe.cmdutil.execute(self.command, self.args, + manipulate_encodings=False) + except libbe.cmdutil.GetHelp: + print libbe.cmdutil.help(command) + except libbe.cmdutil.GetCompletions: + self.err = InvalidOption(self.msg, self.command, u"--complete") + except libbe.cmdutil.UsageError, e: + self.err = InvalidCommand(self.msg, self, + "%s\n%s" % (type(e), unicode(e))) + except libbe.cmdutil.UserError, e: + self.err = InvalidCommand(self.msg, self, + "%s\n%s" % (type(e), unicode(e))) + # restore stdin, stdout, and stderr + if self.stdin != None: + sys.__stdin__ = new_stdin + sys.__stdin__ = orig___stdin + sys.stdin = orig_stdin + sys.stdout.flush() + sys.stderr.flush() + sys.stdout = orig_stdout + sys.stderr = orig_stderr + self.stdout = codecs.decode(new_stdout.getvalue(), ENCODING) + self.stderr = codecs.decode(new_stderr.getvalue(), ENCODING) + if self.err != None: + raise self.err + return (self.ret, self.stdout, self.stderr) + def response_msg(self): + response_body = [u"Results of running: (exit code %d)" % self.ret, + u" %s %s" % (self.command, u" ".join(self.args))] + if self.stdout != None and len(self.stdout) > 0: + response_body.extend([u"", u"stdout:", u"", self.stdout]) + if self.stderr != None and len(self.stderr) > 0: + response_body.extend([u"", u"stderr:", u"", self.stderr]) + response_body.append(u"") # trailing endline + response_generator = \ + send_pgp_mime.PGPMimeMessageFactory(u"\n".join(response_body)) + return response_generator.plain() + +class Message (object): + def __init__(self, email_text): + self.text = email_text + p=email.Parser.Parser() + self.msg=p.parsestr(self.text) + if LOGFILE != None: + LOGFILE.write(u"handling %s\n" % self.author_addr()) + LOGFILE.write(u"\n%s\n\n" % self.text) + def author_tuple(self): + """ + Extract and normalize the sender's email address. Returns a + (name, email) tuple. + """ + if not hasattr(self, "author_tuple_cache"): + self.author_tuple_cache = \ + send_pgp_mime.source_email(self.msg, return_realname=True) + return self.author_tuple_cache + def author_addr(self): + return email.utils.formataddr(self.author_tuple()) + def author_name(self): + return self.author_tuple()[0] + def author_email(self): + return self.author_tuple()[1] + def default_msg_attribute_access(self, attr_name, default=None): + if attr_name in self.msg: + return self.msg[attr_name] + return default + def message_id(self, default=None): + return self.default_msg_attribute_access("message-id", default=default) + def subject(self): + if "subject" not in self.msg: + raise InvalidSubject(self, u"Email must contain a subject") + return self.msg["subject"] + def _split_subject(self): + """ + Returns (tag, subject), with missing values replaced by None. + """ + if hasattr(self, "_split_subject_cache"): + return self._split_subject_cache + args = self.subject().split(u"]",1) + if len(args) < 1: + self._split_subject_cache = (None, None) + elif len(args) < 2: + self._split_subject_cache = (args[0]+u"]", None) + else: + self._split_subject_cache = (args[0]+u"]", args[1].strip()) + return self._split_subject_cache + def _subject_tag_type(self): + """ + Parse subject tag, return (type, value), where type is one of + None, "new", "comment", or "control"; and value is None except + in the case of "comment", in which case it's the bug + ID/shortname. + """ + tag,subject = self._split_subject() + type = None + value = None + if tag == SUBJECT_TAG_NEW: + type = u"new" + elif tag == SUBJECT_TAG_CONTROL: + type = u"control" + else: + match = SUBJECT_TAG_COMMENT.match(tag) + if len(match.groups()) == 1: + type = u"comment" + value = match.group(1) + return (type, value) + def validate_subject(self): + """ + Validate the subject line. + """ + tag,subject = self._split_subject() + if not tag.startswith(SUBJECT_TAG_START): + raise InvalidSubject( + self, u"Subject must start with '%s'" % SUBJECT_TAG_START) + tag_type,value = self._subject_tag_type() + if tag_type == None: + raise InvalidSubject(self, u"Invalid tag '%s'" % tag) + elif tag_type == u"new" and len(subject) == 0: + raise InvalidSubject(self, u"Cannot create a bug with blank title") + elif tag_type == u"comment" and len(value) == 0: + raise InvalidSubject(self, u"Must specify a bug ID to comment") + def _get_bodies_and_mime_types(self): + """ + Traverse the email message returning (body, mime_type) for + each non-mulitpart portion of the message. + """ + for part in self.msg.walk(): + if part.is_multipart(): + continue + body,mime_type=(part.get_payload(decode=1),part.get_content_type()) + yield (body, mime_type) + def _parse_body_pseudoheaders(self, body, required, optional, + dictionary=None): + """ + Grab any pseudo-headers from the beginning of body. Raise + InvalidPseudoHeader on errors. Returns the body text after + the pseudo-header and a dictionary of set options. If you + like, you can initialize the dictionary with some defaults + and pass your initialized dict in as dictionary. + """ + if dictionary == None: + dictionary = {} + body_lines = body.splitlines() + all = required+optional + for i,line in enumerate(body_lines): + line = line.strip() + if len(line) == 0: + break + key,value = line.split(":", 1) + value = value.strip() + if key not in all: + raise InvalidPseudoHeader(self, key) + if len(value) == 0: + raise InvalidEmail( + self, u"Blank value for: %s" % key) + dictionary[key] = value + missing = [] + for key in required: + if key not in dictionary: + missing.append(key) + if len(missing) > 0: + raise InvalidPseudoHeader(self, + u"Missing required pseudo-headers:\n%s" + % u", ".join(missing)) + remaining_body = u"\n".join(body_lines[i:]).strip() + return (remaining_body, dictionary) + def _strip_footer(self, body): + body_lines = body.splitlines() + for i,line in enumerate(body_lines): + if line.startswith(BREAK): + break + return u"\n".join(body_lines[:i]).strip() + def parse(self): + """ + Parse the commands given in the email. Raises assorted + subclasses of InvalidEmail in the case of invalid messages, + otherwise returns a list of suggested commands to run. + """ + self.validate_subject() + tag_type,value = self._subject_tag_type() + commands = [] + if tag_type == u"new": + command = u"new" + tag,subject = self._split_subject() + summary = subject + options = {u"Reporter": self.author_addr()} + body,mime_type = list(self._get_bodies_and_mime_types())[0] + comment_body,options = \ + self._parse_body_pseudoheaders(body, + NEW_REQUIRED_PSEUDOHEADERS, + NEW_OPTIONAL_PSEUDOHEADERS, + options) + args = [u"--reporter", options[u"Reporter"]] + args.append(summary) + commands.append(Command(self, command, args)) + comment_body = self._strip_footer(comment_body) + id = ID(commands[0]) + if len(comment_body) > 0: + command = u"comment" + comment = u"Version: %s\n\n"%options[u"Version"] + comment_body + args = [u"--author", self.author_addr(), + u"--alt-id", self.message_id(), + u"--content-type", mime_type] + args.append(id) + args.append(u"-") + commands.append(Command(self, u"comment", args, stdin=comment)) + for key,value in options.items(): + if key in [u"Version", u"Reporter"]: + continue # we've already handled this option + command = key.lower() + args = [id, value] + commands.append(Command(self, command, args)) + elif tag_type == u"comment": + command = u"comment" + bug_id = value + author = self.author_addr() + alt_id = self.message_id() + body,mime_type = list(self._get_bodies_and_mime_types())[0] + if mime_type == "text/plain": + body = self._strip_footer(body) + content_type = mime_type + args = [u"--author", author, u"--alt-id", alt_id, + u"--content-type", content_type, bug_id, u"-"] + commands.append(Command(self, command, args, stdin=body)) + elif tag_type == u"control": + body,mime_type = list(self._get_bodies_and_mime_types())[0] + for line in body.splitlines(): + line = line.strip() + if line.startswith(CONTROL_COMMENT) or len(line) == 0: + continue + if line.startswith(BREAK): + break + fields = shlex.split(line) + command,args = (fields[0], fields[1:]) + commands.append(Command(self, command, args)) + if len(commands) == 0: + raise InvalidEmail(self, u"No commands in control email.") + else: + raise Exception, u"Unrecognized tag type '%s'" % tag_type + return commands + def run(self): + self._begin_response() + commands = self.parse() + try: + for command in commands: + command.run() + self._add_response(command.response_msg()) + finally: + if AUTOCOMMIT == True: + tag,subject = self._split_subject() + command = Command(self, "commit", [subject]) + command.run() + if LOGFILE != None: + LOGFILE.write("Autocommit:\n%s\n\n" % + send_pgp_mime.flatten(command.response_msg(), + to_unicode=True)) + def _begin_response(self): + tag,subject = self._split_subject() + response_header = [u"From: %s" % HANDLER_ADDRESS, + u"To: %s" % self.author_addr(), + u"Date: %s" % libbe.utility.time_to_str(time.time()), + u"Subject: %s Re: %s"%(SUBJECT_TAG_RESPONSE,subject) + ] + if self.message_id() != None: + response_header.append(u"In-reply-to: %s" % self.message_id()) + self.response_header = \ + send_pgp_mime.header_from_text(text=u"\n".join(response_header)) + self._response_messages = [] + def _add_response(self, response_message): + self._response_messages.append(response_message) + def response_email(self): + assert len(self._response_messages) > 0 + if len(self._response_messages) == 1: + response_body = self._response_messages[0] + else: + response_body = MIMEMultipart() + for message in self._response_messages: + response_body.attach(message) + return send_pgp_mime.attach_root(self.response_header, response_body) + +def generate_global_tags(tag_base=u"be-bug"): + """ + Generate a series of tags from a base tag string. + """ + global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \ + SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL + SUBJECT_TAG_BASE = tag_base + SUBJECT_TAG_START = u"[%s" % tag_base + SUBJECT_TAG_RESPONSE = u"[%s]" % tag_base + SUBJECT_TAG_NEW = u"[%s:submit]" % tag_base + SUBJECT_TAG_COMMENT = re.compile(u"\[%s:([\-0-9a-z]*)]" % tag_base) + SUBJECT_TAG_CONTROL = SUBJECT_TAG_RESPONSE + +def open_logfile(logpath=None): + """ + If logpath=None, default to global LOGPATH. + Special logpath strings: + "-" set LOGFILE to sys.stderr + "none" disable logging + Relative logpaths are expanded relative to _THIS_DIR + """ + global LOGPATH, LOGFILE + if logpath != None: + if logpath == u"-": + LOGPATH = u"stderr" + LOGFILE = sys.stderr + elif logpath == u"none": + LOGPATH = u"none" + LOGFILE = None + elif os.path.isabs(logpath): + LOGPATH = logpath + else: + LOGPATH = os.path.join(_THIS_DIR, logpath) + if LOGFILE == None and LOGPATH != u"none": + LOGFILE = codecs.open(LOGPATH, u"a+", ENCODING) + LOGFILE.write(u"Default encoding: %s\n" % ENCODING) + +def close_logfile(): + if LOGFILE != None and LOGPATH not in [u"stderr", u"none"]: + LOGFILE.close() + +def test(): + unitsuite = unittest.TestLoader().loadTestsFromModule(sys.modules[__name__]) + suite = unittest.TestSuite([unitsuite, doctest.DocTestSuite()]) + result = unittest.TextTestRunner(verbosity=2).run(suite) + num_errors = len(result.errors) + num_failures = len(result.failures) + num_bad = num_errors + num_failures + return num_bad + +def main(): + from optparse import OptionParser + global AUTOCOMMIT, BE_DIR + + usage="be-handle-mail [options]\n\n%s" % (__doc__) + parser = OptionParser(usage=usage) + parser.add_option('-b', '--be-dir', dest='be_dir', default=BE_DIR, + metavar="DIR", + help='Select the BE directory to serve (%default).') + parser.add_option('-t', '--tag-base', dest='tag_base', + default=SUBJECT_TAG_BASE, metavar="TAG", + help='Set the subject tag base (%default).') + parser.add_option('-o', '--output', dest='output', action='store_true', + help="Don't mail the generated message, print it to stdout instead. Useful for testing be-handle-mail functionality without the whole mail transfer agent and procmail setup.") + parser.add_option('-l', '--logfile', dest='logfile', metavar='LOGFILE', + help='Set the logfile to LOGFILE. Relative paths are relative to the location of this be-handle-mail file (%s). The special value of "-" directs the log output to stderr, and "none" disables logging.' % _THIS_DIR) + parser.add_option('-a', '--disable-autocommit', dest='autocommit', + default=True, action='store_false', + help='Disable the autocommit after parsing the email.') + parser.add_option('--test', dest='test', action='store_true', + help='Run internal unit-tests and exit.') + + options,args = parser.parse_args() + + if options.test == True: + num_bad = test() + if num_bad > 126: + num_bad = 1 + sys.exit(num_bad) + + BE_DIR = options.be_dir + AUTOCOMMIT = options.autocommit + generate_global_tags(options.tag_base) + + msg_text = sys.stdin.read() + libbe.encoding.set_IO_stream_encodings(ENCODING) # _after_ reading message + open_logfile(options.logfile) + if len(msg_text.strip()) == 0: # blank email!? + if LOGFILE != None: + LOGFILE.write(u"Blank email!\n") + close_logfile() + sys.exit(1) + try: + m = Message(msg_text) + m.run() + except InvalidEmail, e: + response = e.response() + except Exception, e: + if LOGFILE != None: + LOGFILE.write(u"Uncaught exception:\n%s\n" % (e,)) + traceback.print_tb(sys.exc_traceback, file=LOGFILE) + close_logfile() + sys.exit(1) + else: + response = m.response_email() + if options.output == True: + print send_pgp_mime.flatten(response, to_unicode=True) + else: + if LOGFILE != None: + LOGFILE.write(u"sending response to %s\n" % m.author_addr()) + LOGFILE.write(u"\n%s\n\n" % send_pgp_mime.flatten(response, + to_unicode=True)) + send_pgp_mime.mail(response, send_pgp_mime.sendmail) + close_logfile() + +class GenerateGlobalTagsTestCase (unittest.TestCase): + def setUp(self): + super(GenerateGlobalTagsTestCase, self).setUp() + self.save_global_tags() + def tearDown(self): + self.restore_global_tags() + super(GenerateGlobalTagsTestCase, self).tearDown() + def save_global_tags(self): + self.saved_globals = [SUBJECT_TAG_BASE, SUBJECT_TAG_START, + SUBJECT_TAG_RESPONSE, SUBJECT_TAG_NEW, + SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL] + def restore_global_tags(self): + global SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \ + SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL + SUBJECT_TAG_BASE, SUBJECT_TAG_START, SUBJECT_TAG_RESPONSE, \ + SUBJECT_TAG_NEW, SUBJECT_TAG_COMMENT, SUBJECT_TAG_CONTROL = \ + self.saved_globals + def test_restore_global_tags(self): + "Test global tag restoration by teardown function." + global SUBJECT_TAG_BASE + self.failUnlessEqual(SUBJECT_TAG_BASE, u"be-bug") + SUBJECT_TAG_BASE = "projectX-bug" + self.failUnlessEqual(SUBJECT_TAG_BASE, u"projectX-bug") + self.restore_global_tags() + self.failUnlessEqual(SUBJECT_TAG_BASE, u"be-bug") + def test_subject_tag_base(self): + "Should set SUBJECT_TAG_BASE global correctly" + generate_global_tags(u"projectX-bug") + self.failUnlessEqual(SUBJECT_TAG_BASE, u"projectX-bug") + def test_subject_tag_start(self): + "Should set SUBJECT_TAG_START global correctly" + generate_global_tags(u"projectX-bug") + self.failUnlessEqual(SUBJECT_TAG_START, u"[projectX-bug") + def test_subject_tag_response(self): + "Should set SUBJECT_TAG_RESPONSE global correctly" + generate_global_tags(u"projectX-bug") + self.failUnlessEqual(SUBJECT_TAG_RESPONSE, u"[projectX-bug]") + def test_subject_tag_new(self): + "Should set SUBJECT_TAG_NEW global correctly" + generate_global_tags(u"projectX-bug") + self.failUnlessEqual(SUBJECT_TAG_NEW, u"[projectX-bug:submit]") + def test_subject_tag_control(self): + "Should set SUBJECT_TAG_CONTROL global correctly" + generate_global_tags(u"projectX-bug") + self.failUnlessEqual(SUBJECT_TAG_CONTROL, u"[projectX-bug]") + def test_subject_tag_comment(self): + "Should set SUBJECT_TAG_COMMENT global correctly" + generate_global_tags(u"projectX-bug") + m = SUBJECT_TAG_COMMENT.match("[projectX-bug:xyz-123]") + self.failUnlessEqual(len(m.groups()), 1) + self.failUnlessEqual(m.group(1), u"xyz-123") + +if __name__ == "__main__": + main() diff --git a/interfaces/email/interactive/becommands b/interfaces/email/interactive/becommands new file mode 120000 index 0000000..8af773c --- /dev/null +++ b/interfaces/email/interactive/becommands @@ -0,0 +1 @@ +../../../becommands
\ No newline at end of file diff --git a/interfaces/email/interactive/examples/blank b/interfaces/email/interactive/examples/blank new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/interfaces/email/interactive/examples/blank diff --git a/interfaces/email/interactive/examples/comment b/interfaces/email/interactive/examples/comment new file mode 100644 index 0000000..f22e4b2 --- /dev/null +++ b/interfaces/email/interactive/examples/comment @@ -0,0 +1,11 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <xyz@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug:a1d] Subject ignored + +We sure do. +-- +Goofy tagline ignored diff --git a/interfaces/email/interactive/examples/failing_multiples b/interfaces/email/interactive/examples/failing_multiples new file mode 100644 index 0000000..cf50211 --- /dev/null +++ b/interfaces/email/interactive/examples/failing_multiples @@ -0,0 +1,16 @@ +From jdoe@example.com Fri Apr 18 12:00:00 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] Commit message... + +new "test bug" +new "test bug 2" +failing-command +new "test bug 3" + +-- +This message fails partway through, but the partial changes should be +recorded in a commit... diff --git a/interfaces/email/interactive/examples/invalid_command b/interfaces/email/interactive/examples/invalid_command new file mode 100644 index 0000000..f2963c7 --- /dev/null +++ b/interfaces/email/interactive/examples/invalid_command @@ -0,0 +1,11 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] + +close +-- +Close is currently disabled for the email interface. diff --git a/interfaces/email/interactive/examples/invalid_subject b/interfaces/email/interactive/examples/invalid_subject new file mode 100644 index 0000000..1e2eb88 --- /dev/null +++ b/interfaces/email/interactive/examples/invalid_subject @@ -0,0 +1,9 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: Spam! + +This should elicit an "invalid subject" response email. diff --git a/interfaces/email/interactive/examples/list b/interfaces/email/interactive/examples/list new file mode 100644 index 0000000..acba424 --- /dev/null +++ b/interfaces/email/interactive/examples/list @@ -0,0 +1,11 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] Subject ignored + +list --status all +-- +Dummy content diff --git a/interfaces/email/interactive/examples/missing_command b/interfaces/email/interactive/examples/missing_command new file mode 100644 index 0000000..bb390fc --- /dev/null +++ b/interfaces/email/interactive/examples/missing_command @@ -0,0 +1,11 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] Subject ignored + +abcde +-- +This should elicit a "invalid command 'abcde'" response email. diff --git a/interfaces/email/interactive/examples/multiple_commands b/interfaces/email/interactive/examples/multiple_commands new file mode 100644 index 0000000..41ef730 --- /dev/null +++ b/interfaces/email/interactive/examples/multiple_commands @@ -0,0 +1,14 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] Subject ignored + +help +list --status=all +list --status=fixed +show --xml 361 +-- +Goofy tagline ignored. diff --git a/interfaces/email/interactive/examples/new b/interfaces/email/interactive/examples/new new file mode 100644 index 0000000..c64db93 --- /dev/null +++ b/interfaces/email/interactive/examples/new @@ -0,0 +1,19 @@ +From jdoe@example.com Fri Apr 18 12:00:00 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug:submit] Need tests for the email interface. + +Version: XYZ +Reporter: Jane Doe +Assign: Dick Tracy +Depend: 00f +Severity: critical +Status: assigned +Tag: topsecret +Target: Law&Order + +-- +Goofy tagline not included, and no comment added. diff --git a/interfaces/email/interactive/examples/new_with_comment b/interfaces/email/interactive/examples/new_with_comment new file mode 100644 index 0000000..1077f0f --- /dev/null +++ b/interfaces/email/interactive/examples/new_with_comment @@ -0,0 +1,13 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug:submit] Need tests for the email interface. + +Version: XYZ + +I think so anyway. +-- +Goofy tagline not included. diff --git a/interfaces/email/interactive/examples/show b/interfaces/email/interactive/examples/show new file mode 100644 index 0000000..c5f8a4d --- /dev/null +++ b/interfaces/email/interactive/examples/show @@ -0,0 +1,11 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] Subject ignored + +show --xml 361 +-- +Can we show a bug? diff --git a/interfaces/email/interactive/examples/unicode b/interfaces/email/interactive/examples/unicode new file mode 100644 index 0000000..f0e8001 --- /dev/null +++ b/interfaces/email/interactive/examples/unicode @@ -0,0 +1,11 @@ +From jdoe@example.com Fri Apr 18 11:18:58 2008 +Message-ID: <abcd@example.com> +Date: Fri, 18 Apr 2008 12:00:00 +0000 +From: John Doe <jdoe@example.com> +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit +Subject: [be-bug] Subject ignored + +show --xml f7ccd916-b5c7-4890-a2e3-8c8ace17ae3a +-- +Can we handle unicode output? diff --git a/interfaces/email/interactive/libbe b/interfaces/email/interactive/libbe new file mode 120000 index 0000000..7d18612 --- /dev/null +++ b/interfaces/email/interactive/libbe @@ -0,0 +1 @@ +../../../libbe
\ No newline at end of file diff --git a/interfaces/email/interactive/send_pgp_mime.py b/interfaces/email/interactive/send_pgp_mime.py new file mode 100644 index 0000000..babd720 --- /dev/null +++ b/interfaces/email/interactive/send_pgp_mime.py @@ -0,0 +1,600 @@ +#!/usr/bin/python +# +# Copyright (C) 2009 W. Trevor King <wking@drexel.edu> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. +""" +Python module and command line tool for sending pgp/mime email. + +Mostly uses subprocess to call gpg and a sendmail-compatible mailer. +If you lack gpg, either don't use the encryption functions or adjust +the pgp_* commands. You may need to adjust the sendmail command to +point to whichever sendmail-compatible mailer you have on your system. +""" + +from cStringIO import StringIO +import os +import re +#import GnuPGInterface # Maybe should use this instead of subprocess +import smtplib +import subprocess +import sys +import tempfile +import types + +try: + from email import Message + from email.mime.text import MIMEText + from email.mime.multipart import MIMEMultipart + from email.mime.application import MIMEApplication + from email.encoders import encode_7or8bit + from email.generator import Generator + from email.parser import Parser + from email.utils import getaddress +except ImportError: + # adjust to old python 2.4 + from email import Message + from email.MIMEText import MIMEText + from email.MIMEMultipart import MIMEMultipart + from email.MIMENonMultipart import MIMENonMultipart + from email.Encoders import encode_7or8bit + from email.Generator import Generator + from email.parser import Parser + from email.Utils import getaddresses + + getaddress = getaddresses + class MIMEApplication (MIMENonMultipart): + def __init__(self, _data, _subtype, _encoder, **params): + MIMENonMultipart.__init__(self, 'application', _subtype, **params) + self.set_payload(_data) + _encoder(self) + +usage="""usage: %prog [options] + +Scriptable PGP MIME email using gpg. + +You can use gpg-agent for passphrase caching if your key requires a +passphrase (it better!). Example usage would be to install gpg-agent, +and then run + export GPG_TTY=`tty` + eval $(gpg-agent --daemon) +in your shell before invoking this script. See gpg-agent(1) for more +details. Alternatively, you can send your passphrase in on stdin + echo 'passphrase' | %prog [options] +or use the --passphrase-file option + %prog [options] --passphrase-file FILE [more options] +Both of these alternatives are much less secure than gpg-agent. You +have been warned. +""" + +verboseInvoke = False +PGP_SIGN_AS = None +PASSPHRASE = None + +# The following commands are adapted from my .mutt/pgp configuration +# +# Printf-like sequences: +# %a The value of PGP_SIGN_AS. +# %f Expands to the name of a file with text to be signed/encrypted. +# %p Expands to the passphrase argument. +# %R A string with some number (0 on up) of pgp_reciepient_arg +# strings. +# %r One key ID (e.g. recipient email address) to build a +# pgp_reciepient_arg string. +# +# The above sequences can be used to optionally print a string if +# their length is nonzero. For example, you may only want to pass the +# -u/--local-user argument to gpg if PGP_SIGN_AS is defined. To +# optionally print a string based upon one of the above sequences, the +# following construct is used +# %?<sequence_char>?<optional_string>? +# where sequence_char is a character from the table above, and +# optional_string is the string you would like printed if status_char +# is nonzero. optional_string may contain other sequence as well as +# normal text, but it may not contain any question marks. +# +# see http://codesorcery.net/old/mutt/mutt-gnupg-howto +# http://www.mutt.org/doc/manual/manual-6.html#pgp_autosign +# http://tldp.org/HOWTO/Mutt-GnuPG-PGP-HOWTO-8.html +# for more details + +pgp_recipient_arg='-r "%r"' +pgp_stdin_passphrase_arg='--passphrase-fd 0' +pgp_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --detach-sign --armor --textmode %?a?-u "%a"? %f' +pgp_encrypt_only_command='/usr/bin/gpg --no-verbose --quiet --batch --output - --encrypt --armor --textmode --always-trust --encrypt-to "%a" %R -- %f' +pgp_encrypt_sign_command='/usr/bin/gpg --no-verbose --quiet --batch %p --output - --encrypt --sign %?a?-u "%a"? --armor --textmode --always-trust --encrypt-to "%a" %R -- %f' +sendmail='/usr/sbin/sendmail -t' + +def mail(msg, sendmail=None): + """ + Send an email Message instance on its merry way. + + We can shell out to the user specified sendmail in case + the local host doesn't have an SMTP server set up + for easy smtplib usage. + """ + if sendmail != None: + execute(sendmail, stdin=flatten(msg)) + return None + s = smtplib.SMTP() + s.connect() + s.sendmail(from_addr=source_email(msg), + to_addrs=target_emails(msg), + msg=flatten(msg)) + s.close() + +def header_from_text(text, encoding="us-ascii"): + """ + Simple wrapper for instantiating an email.Message from text. + >>> header = header_from_text('\\n'.join(['From: me@big.edu','To: you@big.edu','Subject: testing'])) + >>> print flatten(header) + From: me@big.edu + To: you@big.edu + Subject: testing + <BLANKLINE> + <BLANKLINE> + """ + text = text.strip() + if type(text) == types.UnicodeType: + text = text.encode(encoding) + # assume StringType arguments are already encoded + p = Parser() + return p.parsestr(text, headersonly=True) + +def attach_root(header, root_part): + """ + Attach the email.Message root_part to the email.Message header + without generating a multi-part message. + """ + for k,v in header.items(): + root_part[k] = v + return root_part + +def execute(args, stdin=None, expect=(0,)): + """ + Execute a command (allows us to drive gpg). + """ + if verboseInvoke == True: + print >> sys.stderr, '$ '+args + try: + p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True, close_fds=True) + except OSError, e: + strerror = '%s\nwhile executing %s' % (e.args[1], args) + raise Exception, strerror + output, error = p.communicate(input=stdin) + status = p.wait() + if verboseInvoke == True: + print >> sys.stderr, '(status: %d)\n%s%s' % (status, output, error) + if status not in expect: + strerror = '%s\nwhile executing %s\n%s\n%d' % (args[1], args, error, status) + raise Exception, strerror + return status, output, error + +def replace(template, format_char, replacement_text): + """ + >>> replace('--textmode %?a?-u %a? %f', 'f', 'file.in') + '--textmode %?a?-u %a? file.in' + >>> replace('--textmode %?a?-u %a? %f', 'a', '0xHEXKEY') + '--textmode -u 0xHEXKEY %f' + >>> replace('--textmode %?a?-u %a? %f', 'a', '') + '--textmode %f' + """ + if replacement_text == None: + replacement_text = "" + regexp = re.compile('%[?]'+format_char+'[?]([^?]*)[?]') + if len(replacement_text) > 0: + str = regexp.sub('\g<1>', template) + else: + str = regexp.sub('', template) + regexp = re.compile('%'+format_char) + str = regexp.sub(replacement_text, str) + return str + +def flatten(msg, to_unicode=False): + """ + Produce flat text output from an email Message instance. + """ + assert msg != None + fp = StringIO() + g = Generator(fp, mangle_from_=False) + g.flatten(msg) + text = fp.getvalue() + if to_unicode == True: + encoding = msg.get_content_charset() or "utf-8" + text = unicode(text, encoding=encoding) + return text + +def source_email(msg, return_realname=False): + """ + Search the header of an email Message instance to find the + sender's email address. + """ + froms = msg.get_all('from', []) + from_tuples = getaddresses(froms) # [(realname, email_address), ...] + assert len(from_tuples) == 1 + if return_realname == True: + return from_tuples[0] # (realname, email_address) + return from_tuples[0][1] # email_address + +def target_emails(msg): + """ + Search the header of an email Message instance to find a + list of recipient's email addresses. + """ + tos = msg.get_all('to', []) + ccs = msg.get_all('cc', []) + bccs = msg.get_all('bcc', []) + resent_tos = msg.get_all('resent-to', []) + resent_ccs = msg.get_all('resent-cc', []) + resent_bccs = msg.get_all('resent-bcc', []) + all_recipients = getaddresses(tos + ccs + bccs + resent_tos + + resent_ccs + resent_bccs) + return [addr[1] for addr in all_recipients] + +class PGPMimeMessageFactory (object): + """ + See http://www.ietf.org/rfc/rfc3156.txt for specification details. + >>> from_addr = "me@big.edu" + >>> to_addr = "you@you.edu" + >>> header = header_from_text('\\n'.join(['From: %s'%from_addr,'To: %s'%to_addr,'Subject: testing'])) + >>> source_email(header) == from_addr + True + >>> target_emails(header) == [to_addr] + True + >>> m = EncryptedMessageFactory('check 1 2\\ncheck 1 2\\n') + >>> print flatten(m.clearBodyPart()) + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + <BLANKLINE> + check 1 2 + check 1 2 + <BLANKLINE> + >>> print flatten(m.plain()) + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + <BLANKLINE> + check 1 2 + check 1 2 + <BLANKLINE> + >>> signed = m.sign(header) + >>> signed.set_boundary('boundsep') + >>> print flatten(signed).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + Content-Type: multipart/signed; protocol="application/pgp-signature"; + micalg="pgp-sha1"; boundary="boundsep" + MIME-Version: 1.0 + Content-Disposition: inline + <BLANKLINE> + --boundsep + Content-Type: text/plain; charset="us-ascii" + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Disposition: inline + <BLANKLINE> + check 1 2 + check 1 2 + <BLANKLINE> + --boundsep + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Description: signature + Content-Type: application/pgp-signature; name="signature.asc"; + charset="us-ascii" + <BLANKLINE> + -----BEGIN PGP SIGNATURE----- + ... + -----END PGP SIGNATURE----- + <BLANKLINE> + --boundsep-- + >>> encrypted = m.encrypt(header) + >>> encrypted.set_boundary('boundsep') + >>> print flatten(encrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + Content-Type: multipart/encrypted; + protocol="application/pgp-encrypted"; + micalg="pgp-sha1"; boundary="boundsep" + MIME-Version: 1.0 + Content-Disposition: inline + <BLANKLINE> + --boundsep + Content-Type: application/pgp-encrypted + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + <BLANKLINE> + Version: 1 + <BLANKLINE> + --boundsep + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Type: application/octet-stream; charset="us-ascii" + <BLANKLINE> + -----BEGIN PGP MESSAGE----- + ... + -----END PGP MESSAGE----- + <BLANKLINE> + --boundsep-- + >>> signedAndEncrypted = m.signAndEncrypt(header) + >>> signedAndEncrypted.set_boundary('boundsep') + >>> print flatten(signedAndEncrypted).replace('\\t', ' '*4) # doctest: +ELLIPSIS, +NORMALIZE_WHITESPACE + Content-Type: multipart/encrypted; + protocol="application/pgp-encrypted"; + micalg="pgp-sha1"; boundary="boundsep" + MIME-Version: 1.0 + Content-Disposition: inline + <BLANKLINE> + --boundsep + Content-Type: application/pgp-encrypted + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + <BLANKLINE> + Version: 1 + <BLANKLINE> + --boundsep + MIME-Version: 1.0 + Content-Transfer-Encoding: 7bit + Content-Type: application/octet-stream; charset="us-ascii" + <BLANKLINE> + -----BEGIN PGP MESSAGE----- + ... + -----END PGP MESSAGE----- + <BLANKLINE> + --boundsep-- + """ + def __init__(self, body): + self.body = body + def encodedMIMEText(self, body, encoding=None): + if encoding == None: + if type(body) == types.StringType: + encoding = "us-ascii" + elif type(body) == types.UnicodeType: + for encoding in ["us-ascii", "iso-8859-1", "utf-8"]: + try: + body.encode(encoding) + except UnicodeError: + pass + else: + break + assert encoding != None + # Create the message ('plain' stands for Content-Type: text/plain) + if encoding == "us-ascii": + return MIMEText(body) + else: + return MIMEText(body.encode(encoding), 'plain', encoding) + def clearBodyPart(self): + body = self.encodedMIMEText(self.body) + body.add_header('Content-Disposition', 'inline') + return body + def passphrase_arg(self, passphrase=None): + if passphrase == None and PASSPHRASE != None: + passphrase = PASSPHRASE + if passphrase == None: + return (None,'') + return (passphrase, pgp_stdin_passphrase_arg) + def plain(self): + """ + text/plain + """ + return self.encodedMIMEText(self.body) + def sign(self, header, passphrase=None): + """ + multipart/signed + +-> text/plain (body) + +-> application/pgp-signature (signature) + """ + passphrase,pass_arg = self.passphrase_arg(passphrase) + body = self.clearBodyPart() + bfile = tempfile.NamedTemporaryFile() + bfile.write(flatten(body)) + bfile.flush() + + args = replace(pgp_sign_command, 'f', bfile.name) + if PGP_SIGN_AS == None: + pgp_sign_as = '<%s>' % source_email(header) + else: + pgp_sign_as = PGP_SIGN_AS + args = replace(args, 'a', pgp_sign_as) + args = replace(args, 'p', pass_arg) + status,output,error = execute(args, stdin=passphrase) + signature = output + + sig = MIMEApplication(_data=signature, + _subtype='pgp-signature; name="signature.asc"', + _encoder=encode_7or8bit) + sig['Content-Description'] = 'signature' + sig.set_charset('us-ascii') + + msg = MIMEMultipart('signed', micalg='pgp-sha1', + protocol='application/pgp-signature') + msg.attach(body) + msg.attach(sig) + + msg['Content-Disposition'] = 'inline' + return msg + def encrypt(self, header, passphrase=None): + """ + multipart/encrypted + +-> application/pgp-encrypted (control information) + +-> application/octet-stream (body) + """ + body = self.clearBodyPart() + bfile = tempfile.NamedTemporaryFile() + bfile.write(flatten(body)) + bfile.flush() + + recipients = [replace(pgp_recipient_arg, 'r', recipient) + for recipient in target_emails(header)] + recipient_string = ' '.join(recipients) + args = replace(pgp_encrypt_only_command, 'R', recipient_string) + args = replace(args, 'f', bfile.name) + if PGP_SIGN_AS == None: + pgp_sign_as = '<%s>' % source_email(header) + else: + pgp_sign_as = PGP_SIGN_AS + args = replace(args, 'a', pgp_sign_as) + status,output,error = execute(args) + encrypted = output + + enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', + _encoder=encode_7or8bit) + enc.set_charset('us-ascii') + + control = MIMEApplication(_data='Version: 1\n', _subtype='pgp-encrypted', + _encoder=encode_7or8bit) + + msg = MIMEMultipart('encrypted', micalg='pgp-sha1', + protocol='application/pgp-encrypted') + msg.attach(control) + msg.attach(enc) + + msg['Content-Disposition'] = 'inline' + return msg + def signAndEncrypt(self, header, passphrase=None): + """ + multipart/encrypted + +-> application/pgp-encrypted (control information) + +-> application/octet-stream (body) + """ + passphrase,pass_arg = self.passphrase_arg(passphrase) + body = self.sign(header, passphrase) + body.__delitem__('Bcc') + bfile = tempfile.NamedTemporaryFile() + bfile.write(flatten(body)) + bfile.flush() + + recipients = [replace(pgp_recipient_arg, 'r', recipient) + for recipient in target_emails(header)] + recipient_string = ' '.join(recipients) + args = replace(pgp_encrypt_only_command, 'R', recipient_string) + args = replace(args, 'f', bfile.name) + if PGP_SIGN_AS == None: + pgp_sign_as = '<%s>' % source_email(header) + else: + pgp_sign_as = PGP_SIGN_AS + args = replace(args, 'a', pgp_sign_as) + args = replace(args, 'p', pass_arg) + status,output,error = execute(args, stdin=passphrase) + encrypted = output + + enc = MIMEApplication(_data=encrypted, _subtype='octet-stream', + _encoder=encode_7or8bit) + enc.set_charset('us-ascii') + + control = MIMEApplication(_data='Version: 1\n', + _subtype='pgp-encrypted', + _encoder=encode_7or8bit) + + msg = MIMEMultipart('encrypted', micalg='pgp-sha1', + protocol='application/pgp-encrypted') + msg.attach(control) + msg.attach(enc) + + msg['Content-Disposition'] = 'inline' + return msg + +def test(): + import doctest + doctest.testmod() + + +if __name__ == '__main__': + from optparse import OptionParser + + parser = OptionParser(usage=usage) + parser.add_option('-t', '--test', dest='test', action='store_true', + help='Run doctests and exit') + + parser.add_option('-H', '--header-file', dest='header_filename', + help='file containing email header', metavar='FILE') + parser.add_option('-B', '--body-file', dest='body_filename', + help='file containing email body', metavar='FILE') + + parser.add_option('-P', '--passphrase-file', dest='passphrase_file', + help='file containing gpg passphrase', metavar='FILE') + parser.add_option('-p', '--passphrase-fd', dest='passphrase_fd', + help='file descriptor from which to read gpg passphrase (0 for stdin)', + type="int", metavar='DESCRIPTOR') + + parser.add_option('--mode', dest='mode', default='sign', + help="One of 'sign', 'encrypt', 'sign-encrypt', or 'plain'. Defaults to %default.", + metavar='MODE') + + parser.add_option('-a', '--sign-as', dest='sign_as', + help="The gpg key to sign with (gpg's -u/--local-user)", + metavar='KEY') + + parser.add_option('--output', dest='output', action='store_true', + help="Don't mail the generated message, print it to stdout instead.") + + (options, args) = parser.parse_args() + + stdin_used = False + + if options.passphrase_file != None: + PASSPHRASE = file(options.passphrase_file, 'r').read() + elif options.passphrase_fd != None: + if options.passphrase_fd == 0: + stdin_used = True + PASSPHRASE = sys.stdin.read() + else: + PASSPHRASE = os.read(options.passphrase_fd) + + if options.sign_as: + PGP_SIGN_AS = options.sign_as + + if options.test == True: + test() + sys.exit(0) + + header = None + if options.header_filename != None: + if options.header_filename == '-': + assert stdin_used == False + stdin_used = True + header = sys.stdin.read() + else: + header = file(options.header_filename, 'r').read() + if header == None: + raise Exception, "missing header" + headermsg = header_from_text(header) + body = None + if options.body_filename != None: + if options.body_filename == '-': + assert stdin_used == False + stdin_used = True + body = sys.stdin.read() + else: + body = file(options.body_filename, 'r').read() + if body == None: + raise Exception, "missing body" + + m = EncryptedMessageFactory(body) + if options.mode == "sign": + bodymsg = m.sign(header) + elif options.mode == "encrypt": + bodymsg = m.encrypt(header) + elif options.mode == "sign-encrypt": + bodymsg = m.signAndEncrypt(header) + elif options.mode == "plain": + bodymsg = m.plain() + else: + print "Unrecognized mode '%s'" % options.mode + + message = attach_root(headermsg, bodymsg) + if options.output == True: + message = flatten(message) + print message + else: + mail(message, sendmail) diff --git a/interfaces/xml/be-mbox-to-xml b/interfaces/xml/be-mbox-to-xml index 335f92f..57de719 100755 --- a/interfaces/xml/be-mbox-to-xml +++ b/interfaces/xml/be-mbox-to-xml @@ -28,9 +28,6 @@ from libbe.encoding import get_encoding, set_IO_stream_encodings from mailbox import mbox, Message # the mailbox people really want an on-disk copy from time import asctime, gmtime import types -from xml.sax import make_parser -from xml.sax.handler import ContentHandler -from xml.sax.saxutils import escape DEFAULT_ENCODING = get_encoding() set_IO_stream_encodings(DEFAULT_ENCODING) diff --git a/libbe/cmdutil.py b/libbe/cmdutil.py index 853a75a..548c255 100644 --- a/libbe/cmdutil.py +++ b/libbe/cmdutil.py @@ -70,10 +70,11 @@ def get_command(command_name): return cmd -def execute(cmd, args): +def execute(cmd, args, manipulate_encodings=True): enc = encoding.get_encoding() cmd = get_command(cmd) - ret = cmd.execute([a.decode(enc) for a in args]) + ret = cmd.execute([a.decode(enc) for a in args], + manipulate_encodings=manipulate_encodings) if ret == None: ret = 0 return ret diff --git a/libbe/encoding.py b/libbe/encoding.py index d603602..4af864e 100644 --- a/libbe/encoding.py +++ b/libbe/encoding.py @@ -19,11 +19,15 @@ import locale import sys import doctest +ENCODING = None # override get_encoding() output by setting this + def get_encoding(): """ Guess a useful input/output/filesystem encoding... Maybe we need seperate encodings for input/output and filesystem? Hmm... """ + if ENCODING != None: + return ENCODING encoding = locale.getpreferredencoding() or sys.getdefaultencoding() if sys.platform != 'win32' or sys.version_info[:2] > (2, 3): encoding = locale.getlocale(locale.LC_TIME)[1] or encoding |