Index: moap/bug/trac.py
===================================================================
--- moap/bug/trac.py	(revision 375)
+++ moap/bug/trac.py	(working copy)
@@ -1,6 +1,7 @@
 # -*- Mode: Python -*-
 # vi:si:et:sw=4:sts=4:ts=4
 
+import urlparse
 import xmlrpclib
 
 from moap.bug import bug
@@ -16,15 +17,25 @@
     return URL
 
 class Trac(bug.BugTracker):
-    def __init__(self, URL):
+    def __init__(self, URL, username=None, password=None):
         bug.BugTracker.__init__(self, URL)
         self.debug('Contacting server at %s' % self.URL)
-        self._server = xmlrpclib.ServerProxy(self.URL + '/xmlrpc')
 
-    def getBug(self, id):
-        """
-        @type id: str
-        """
+        rpcURL = URL
+        if username and password:
+            parts = urlparse.urlparse(URL)
+            # nasty hack: if we want to log in, change protocol to https
+            # FIXME: follow redirects instead
+            protocol = parts[0]
+            protocol = 'https'
+            rpcURL = "%s://%s:%s@%s%s/login/xmlrpc" % (
+                protocol, username, password, parts[1], parts[2])
+        else:
+            rpcURL += '/xmlrpc'
+        self._server = xmlrpclib.ServerProxy(rpcURL)
+        self._rpcURL = rpcURL
+
+    def _getTicket(self, id):
         try:
             ticket = self._server.ticket.get(int(id))
         except xmlrpclib.Fault, e:
@@ -36,18 +47,37 @@
 
             # something else is wrong, reraise
             raise e
+        except xmlrpclib.ProtocolError, e:
+            if e.errcode == 404:
+                raise bug.TrackerNotFoundError, self._rpcURL
 
+        return ticket
+
+
+    def getBug(self, id):
+        """
+        @type id: str
+        """
+        ticket = self._getTicket(id)
+
         if not ticket:
             return
+
         return self._getBugFromTicket(ticket)
 
     def query(self, queryString):
+        # queryString is specific to trac; for example
+        # summary=~exception
+
         # multicall.ticket is dynamically added
         __pychecker__ = 'no-objattrs'
+
+        assert '=' in queryString, 'a Trac query should contain field=value'
         multicall = xmlrpclib.MultiCall(self._server)
         try:
             result = self._server.ticket.query(queryString)
         except xmlrpclib.Fault, e:
+            self.warning('Query %s failed' % queryString)
             raise e
         for r in result:
             multicall.ticket.get(r)
@@ -56,6 +86,43 @@
         self.debug('Found %d bugs' % len(ret))
         return ret
 
+    def getAttachment(self, bugg, filename):
+        try:
+            result = self._server.ticket.getAttachment(bugg.id, filename)
+        except xmlrpclib.Fault, e:
+            # FIXME: a Fault has a faultCode and faultString
+            # faultCode is 2 both for "not exist" and "id is not integer"
+            # so we use a fragile string search, boo
+            if e.faultString.find(' does not exist') > -1:
+                raise bug.NotFoundError(id)
+
+            # something else is wrong, reraise
+            raise e
+
+        # this call returns xmlrpclib.Binary
+        return result.data
+
+    def putAttachment(self, bugg, filename, description, data):
+        try:
+            self._server.ticket.putAttachment(
+                bugg.id, filename, description, xmlrpclib.Binary(data))
+        except xmlrpclib.Fault, e:
+            raise e
+
+    def listAttachments(self, bugg):
+        try:
+            result = self._server.ticket.listAttachments(bugg.id)
+        except xmlrpclib.Fault, e:
+            raise e
+
+        ret = []
+        for (filename, description, size, time, author) in result:
+            ret.append(
+                bug.Attachment(filename, description, size, time, author))
+
+        return ret
+
+
     def _getBugFromTicket(self, ticket):
         """
         @type ticket: a list as returned by Trac's xml-rpc interface
Index: moap/bug/bugzilla.py
===================================================================
--- moap/bug/bugzilla.py	(revision 375)
+++ moap/bug/bugzilla.py	(working copy)
@@ -18,7 +18,7 @@
     Queries bugzilla using the CSV format.
     CSV is supported by both Red Hat's and GNOME's bugzilla.
     """
-    def __init__(self, URL):
+    def __init__(self, URL, username=None, password=None):
         # when we get the URL from a DOAP file, scrub the submission part
         # from it
         if URL and 'enter_bug.cgi' in URL:
Index: moap/bug/bug.py
===================================================================
--- moap/bug/bug.py	(revision 375)
+++ moap/bug/bug.py	(working copy)
@@ -9,7 +9,7 @@
 
 from moap.util import log, util
 
-def detect(URL):
+def detect(URL, username=None, password=None):
     """
     Detect which bug tracker is being used at the given URL.
 
@@ -29,7 +29,7 @@
          
         if ret:
             try:
-                o = m.BugClass(ret)
+                o = m.BugClass(ret, username, password)
             except AttributeError:
                 sys.stderr.write('moap.bug.%s is missing BugClass()\n' % s)
                 continue
@@ -40,7 +40,7 @@
     return None
 
 class BugTracker(log.Loggable):
-    def __init__(self, URL):
+    def __init__(self, URL, username=None, password=None):
         self.URL = URL
 
     def __repr__(self):
@@ -58,8 +58,47 @@
 
         @rtype: list of L{Bug}
         """
+        raise NotImplementedError
 
+    def listAttachments(self, bug):
+        """
+        List attachments of the given bug.
 
+        @param bug: the bug to list attachments of
+        @type  bug: L{Bug}
+
+        @rtype: list of str
+        """
+        raise NotImplementedError
+
+    def getAttachment(self, bug, filename):
+        """
+        Get attachment of the given bug.
+
+        @param bug:      the bug to get attachment of
+        @type  bug:      L{Bug}
+        @param filename: the attachment to get
+        @type  filename: str
+
+        @rtype: str
+        """
+        raise NotImplementedError
+
+    def putAttachment(self, bug, filename, description, data):
+        """
+        Put an attachment to the given bug.
+
+        @param bug:         the bug to put attachment to
+        @type  bug:         L{Bug}
+        @param filename:    the filename to put
+        @type  filename:    str
+        @param description: the description of the attachment
+        @type  description: str
+        @param data:        the data of the attachment
+        @type  data:        str
+        """
+        raise NotImplementedError
+
 class Bug(log.Loggable):
     """
     I am a bug in a bug tracker.
@@ -74,6 +113,38 @@
         self.id = id
         self.summary = summary
 
+class Attachment(log.Loggable):
+    """
+    I am an attachment to a bug in a bug tracker.
+
+    @type filename:    str
+    @type description: str
+    @type size:        int
+    @type time:        int
+    @type author:      str
+    """
+    filename = None
+    description = None
+    size = None
+    time = None
+    author = None
+
+    def __init__(self, filename, description, size, time, author):
+        self.filename = filename
+        self.description = description
+        self.size = size
+        self.time = time
+        self.author = author
+
+
+class TrackerNotFoundError(Exception):
+    """
+    The bug tracker was not found.
+    """
+    def __init__(self, URL):
+        Exception.__init__(self, URL)
+        self.URL = URL
+
 class NotFoundError(Exception):
     """
     The bug with the given id was not found.
Index: moap/command/bug.py
===================================================================
--- moap/command/bug.py	(revision 375)
+++ moap/command/bug.py	(working copy)
@@ -2,11 +2,116 @@
 # vi:si:et:sw=4:sts=4:ts=4
 
 import sys
+import os
 
 from moap.util import util, mail
 from moap.bug import bug
 from moap.doap import doap
 
+  
+class Get(util.LogCommand):
+    usage = "get [bug id] [filename]"
+    summary = "get attachment of the given bug"
+
+    def do(self, args):
+        try:
+            bugId = args[0]
+            filename = args[1]
+        except:
+            self.stderr.write('Please give a bug id and filename to show.\n')
+            return 3
+
+        self.debug('Looking up bug %r' % bugId)
+        tracker = self.parentCommand.parentCommand.tracker
+        try:
+            b = tracker.getBug(bugId)
+        except bug.NotFoundError, e:
+            self.stderr.write('Bug %s not found in bug tracker.\n' % e.id)
+            return 3
+
+        try:
+            contents = tracker.getAttachment(b, filename)
+        except bug.NotFoundError, e:
+            self.stderr.write('Attachment %s not found in bug %s.\n' % (
+                filename, b.id))
+            return 3
+
+        handle = open(filename, 'w')
+        handle.write(contents)
+        handle.close()
+        self.stdout.write("Got attachment '%s'\n" % filename)
+
+class List(util.LogCommand):
+    usage = "list [bug id]"
+    summary = "list attachments of the given bug"
+
+    def do(self, args):
+        if not args:
+            self.stderr.write('Please give a bug id to show.\n')
+            return 3
+
+        bugId = args[0]
+        self.debug('Looking up bug %r' % bugId)
+        tracker = self.parentCommand.parentCommand.tracker
+        try:
+            b = tracker.getBug(bugId)
+        except bug.NotFoundError, e:
+            self.stderr.write('Bug %s not found in bug tracker.\n' % e.id)
+            return 3
+
+        attachments = tracker.listAttachments(b)
+        if not attachments:
+            self.stdout.write('Bug %s (%s) has no attachments.\n' % (
+                b.id, b.summary))
+            return 0
+
+        self.stdout.write('Bug %s (%s) has the following attachments:\n' % (
+            b.id, b.summary))
+        for a in attachments:
+            self.stdout.write('%s: %s\n' % (
+                a.filename, a.description or '[no description]'))
+
+class Put(util.LogCommand):
+    usage = "put [bug id] [filename] [description]"
+    summary = "put an attachment to the given bug"
+
+    def do(self, args):
+        try:
+            bugId = args[0]
+            path = args[1]
+        except:
+            self.stderr.write('Please give a bug id and filename to show.\n')
+            return 3
+
+        description = None
+        if len(args) > 2:
+            description = " ".join(args[2:])
+
+        if not os.path.exists(path):
+            self.stderr.write("File %s does not exist.\n" % path)
+            return 3
+
+        self.debug('Looking up bug %r' % bugId)
+        tracker = self.parentCommand.parentCommand.tracker
+        try:
+            b = tracker.getBug(bugId)
+        except bug.NotFoundError, e:
+            self.stderr.write('Bug %s not found in bug tracker.\n' % e.id)
+            return 3
+
+        filename = os.path.basename(path)
+        handle = open(path)
+        contents = handle.read()
+        handle.close()
+        contents = tracker.putAttachment(b, filename, description, contents)
+
+        self.stdout.write("Put attachment '%s'\n" % filename)
+
+class Attachment(util.LogCommand):
+    summary = "interact with attachments"
+    subCommandClasses = [Get, List, Put]
+
+
 class Show(util.LogCommand):
     usage = "[bug id]"
     summary = "show a bug"
@@ -22,8 +127,11 @@
         try:
             b = tracker.getBug(bugId)
         except bug.NotFoundError, e:
-            sys.stderr.write('Bug %s not found in bug tracker.\n' % e.id)
+            self.stderr.write('Bug %s not found in bug tracker.\n' % e.id)
             return 3
+        except bug.TrackerNotFoundError, e:
+            self.stderr.write('Bug tracker not found at %s.\n' % e.URL)
+            return 3
 
         print "%s: %s" % (b.id, b.summary)
 
@@ -55,7 +163,7 @@
 
     def do(self, args):
         if not args:
-            sys.stderr.write('Please give a bug search query.\n')
+            self.stderr.write('Please give a bug search query.\n')
             return 3
 
         query = args[0]
@@ -65,7 +173,7 @@
         self.debug('Looking up bug using query %r' % query)
         bugs = tracker.query(query)
         if not bugs:
-            sys.stderr.write('Zaroo bugs found.\n')
+            self.stderr.write('Zaroo bugs found.\n')
             return 3
 
 
@@ -87,13 +195,21 @@
 Currently supported bug trackers:
   trac, bugzilla
 """
-    subCommandClasses = [Show, Query]
+    subCommandClasses = [Attachment, Show, Query]
 
     def addOptions(self):
         self.parser.add_option('-U', '--URL',
             action="store", dest="URL",
             help="URL of bug tracker")
 
+        self.parser.add_option('-u', '--username',
+            action="store", dest="username",
+            help="username to authenticate as")
+        # FIXME: ask for it instead
+        self.parser.add_option('-p', '--password',
+            action="store", dest="password",
+            help="password to authenticate with")
+
     def handleOptions(self, options):
         # prefer the URL passed
         # if not passed, try a doap file
@@ -110,7 +226,7 @@
                 try:
                     d = doap.findDoapFile(None)
                 except doap.DoapException, e:
-                    sys.stderr.write(e.args[0])
+                    self.stderr.write(e.args[0])
 
             if not d:
                 return 3
@@ -121,9 +237,9 @@
             self.debug('Using specified bug tracker URL %s' % self.URL)
 
         self.debug('Looking up bug tracker at %s' % self.URL)
-        tracker = bug.detect(self.URL)
+        tracker = bug.detect(self.URL, options.username, options.password)
         if not tracker:
-            sys.stderr.write('No known bug tracker found at %s\n' % self.URL)
+            self.stderr.write('No known bug tracker found at %s\n' % self.URL)
             return 3
 
         self.debug('Found bug tracker %r' % tracker)
