Merge lp:~russell/ibid/jira-plugin into lp:~ibid-core/ibid/old-trunk-1.6

Proposed by Russell Cloran
Status: Work in progress
Proposed branch: lp:~russell/ibid/jira-plugin
Merge into: lp:~ibid-core/ibid/old-trunk-1.6
Diff against target: 253 lines (+249/-0)
1 file modified
ibid/plugins/jira.py (+249/-0)
To merge this branch: bzr merge lp:~russell/ibid/jira-plugin
Reviewer Review Type Date Requested Status
Max Rabkin Needs Fixing
Stefano Rivera Needs Fixing
Jonathan Hitchcock Needs Fixing
Michael Gorven Approve
Review via email: mp+16752@code.launchpad.net

This proposal supersedes a proposal from 2010-01-03.

To post a comment you must log in.
Revision history for this message
Russell Cloran (russell) wrote : Posted in a previous version of this proposal

I suspect that there will be quite a lot of feedback here ;)

Revision history for this message
Michael Gorven (mgorven) wrote :

Good enough for now (despite the scary caching).
 review approve

review: Approve
Revision history for this message
Stefano Rivera (stefanor) wrote :

I can't comment too much on it, as I don't know jira. But besides from being scary, this is an OK plugin.

It is a bit messy though:
* Your editor leaves empty lines with whitespace in them
* Commented out bits of code
* Unneeded imports
* regex substitution that should probably be re.escaped()ed

So here's a grudging approve

review: Approve
Revision history for this message
Jonathan Hitchcock (vhata) wrote :

This needs fixing to work with generic Jira instances - I will run it against Yola's Jira and debug it for a bit (it doesn't work right now, ootb).

review: Needs Fixing
Revision history for this message
Stefano Rivera (stefanor) wrote :

Can you add a copyright header to the top (see any other file in current trunk)

Also, the @cached issue is fixed now.

review: Needs Fixing
Revision history for this message
Max Rabkin (max-rabkin) wrote :

Needs to migrate to new-style help.

review: Needs Fixing

Unmerged revisions

821. By Russell Cloran

Fix usage information

820. By Russell Cloran

Use the new-ish conflate stuff

819. By Russell Cloran

First pass at a Jira plugin

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'ibid/plugins/jira.py'
2--- ibid/plugins/jira.py 1970-01-01 00:00:00 +0000
3+++ ibid/plugins/jira.py 2010-01-03 15:50:26 +0000
4@@ -0,0 +1,249 @@
5+import logging
6+import re
7+from collections import defaultdict
8+from datetime import datetime
9+
10+import suds.client
11+
12+import ibid
13+from ibid.plugins import Processor, handler, match, RPC
14+from ibid.config import Option, BoolOption
15+from ibid.utils import ago
16+
17+help = {'jira': u'Retrieves tickets from a Jira instance.'}
18+
19+class LoginError(Exception):
20+ pass
21+
22+class cached(object):
23+ def __init__(self, property=None):
24+ self._property = property
25+
26+ def __call__(self, method):
27+ self._method = method
28+ if self._property is None:
29+ self._property = '_' + method.__name__
30+ return self
31+
32+ def __get__(self, instance, owner):
33+ if getattr(instance, self._property, None):
34+ return getattr(instance, self._property)
35+
36+ setattr(instance, self._property, self._method(instance))
37+ return getattr(instance, self._property)
38+
39+ def __set__(self, instance, value):
40+ setattr(instance, self._property, value)
41+
42+def exceptional_response(m):
43+ def inner(self, event, *args, **kwargs):
44+ try:
45+ return m(self, event, *args, **kwargs)
46+ except LoginError:
47+ event.addresponse(u"I couldn't log in to Jira")
48+ except Exception:
49+ raise
50+
51+ return inner
52+
53+class JiraProxy(object):
54+ """
55+ This proxy object looks like a bit of overkill that could just live in
56+ the Processor, but... A Processor's setup() should be as minimal as
57+ possible, so we want to make cached information (the list of projects,
58+ statuses, versions, etc) lazily loaded.
59+
60+ Unfortunately, the default Processor looks at all attributes to search
61+ for handlers (and clock event handlers), and so even if the lazily
62+ loaded information lived on the processor, it would be loaded the first
63+ time anybody queries the bot (no matter which plugin), so we instead
64+ move it to a separate object.
65+ """
66+ query_template = r"^(?:(my|\S+?(?:'s))\s+)?(?:(%(statuses)s)\s+)?tickets(?:\s+for\s+(.+?))?(?:(?:\s+ver\w*)?\s+(.+?))?$"
67+ def __init__(self, processor, url, username, password):
68+ logging.getLogger('suds').setLevel(logging.WARN)
69+ self.processor = processor
70+ self.client = suds.client.Client(url + 'rpc/soap/jirasoapservice-v2?wsdl')
71+ self.username = username
72+ self.password = password
73+ self.tok = ''
74+
75+ # Ticket information
76+ self.priorities = None
77+ self.statuses = None
78+ self.types = None
79+
80+ # Projects components and versions
81+ self.projects = None
82+ self.components = None
83+ self.versions = None
84+
85+ def call(self, method, *args, **kwargs):
86+ if not self.tok:
87+ try:
88+ self.tok = self.client.service.login(self.username, self.password)
89+ except Exception, e:
90+ raise LoginError
91+
92+ try:
93+ return getattr(self.client.service, method)(self.tok, *args, **kwargs)
94+ except suds.client.WebFault, e:
95+ if e.fault.faultstring.startswith('com.atlassian.jira.rpc.exception.RemoteAuthenticationException'):
96+ try:
97+ self.tok = self.client.service.login(self.username, self.password)
98+ except Exception:
99+ raise LoginError
100+ else:
101+ # Don't recurse, silly
102+ return getattr(self.client.service, method)(self.tok, *args, **kwargs)
103+
104+ @cached()
105+ def client(self):
106+ return suds.client.Client(self.url + '/rpc/soap/jirasoapservice-v2?wsdl')
107+
108+ @cached()
109+ def priorities(self):
110+ return dict([(p.id, p.name) for p in self.call('getPriorities')])
111+
112+ @cached()
113+ def statuses(self):
114+ statuses = dict([(s.id, s.name) for s in self.call('getStatuses')])
115+ status_alts = "|".join(statuses.values())
116+ self.processor.handle_list.im_func.pattern = re.compile(self.query_template % {'statuses': status_alts}, re.IGNORECASE)
117+ return statuses
118+
119+ @cached()
120+ def types(self):
121+ return dict([(t.id, t.name) for t in self.call('getIssueTypes')])
122+
123+ @cached()
124+ def projects(self):
125+ projects = dict([(p.key, p) for p in self.call('getProjectsNoSchemes')])
126+
127+ proj_alts = "|".join(projects.keys())
128+ self.processor.get.im_func.pattern = re.compile(r'^(?:ticket\s+)?((?:%s)-\d+)$' % proj_alts)
129+
130+ return projects
131+
132+ @cached()
133+ def components(self):
134+ tmp = defaultdict(list)
135+ for p in self.projects.values():
136+ p.components = self.call('getComponents', p.key)
137+ for component in p.components:
138+ tmp[component.name].append(p.key)
139+
140+ return tmp
141+
142+ @cached()
143+ def versions(self):
144+ tmp = defaultdict(list)
145+ for p in self.projects.values():
146+ p.versions = self.call('getVersions', p.key)
147+ for version in p.versions:
148+ tmp[version.name].append(p)
149+
150+ return tmp
151+
152+ def get_ticket(self, tag):
153+ try:
154+ return self.call('getIssue', tag)
155+ except suds.client.WebFault:
156+ return None
157+
158+ def query(self, query, n=10):
159+ return self.call('getIssuesFromJqlSearch', query, n)
160+
161+class Tickets(Processor): #, RPC):
162+ u"""ticket <ticket key>
163+ <ticket key>
164+ <my|<who>'s> <status> tickets <for (project|component)> <ver version>"""
165+ feature = 'jira'
166+ priority = 250
167+ url = Option('url', "URL of Jira instance")
168+ username = Option('username', "Jira account username")
169+ password = Option('password', "Jira account password")
170+ default_project = Option('default_project', "Default project for searches which don't specify a component or project")
171+
172+ # source = Option('source', 'Source to send commit notifications to')
173+ # channel = Option('channel', 'Channel to send commit notifications to')
174+ # announce_changes = BoolOption('announce_changes', u'Announce changes to tickets', True)
175+
176+ def __init__(self, name):
177+ # RPC.__init__(self)
178+ self.log = logging.getLogger('plugins.jira')
179+ Processor.__init__(self, name)
180+ self.j = JiraProxy(self, self.url, self.username, self.password)
181+
182+ def setup(self):
183+ self.get.im_func.pattern = re.compile('^(%s-\d+)$' % (self.default_project, ))
184+ # TODO: Should be able to update the Jira proxy if any config changed
185+
186+ @handler
187+ @exceptional_response
188+ def get(self, event, tag):
189+ ticket = self.j.get_ticket(tag)
190+
191+ if ticket:
192+ try:
193+ fixVersion = 'for %s ' % ticket.fixVersions[0].name
194+ except:
195+ fixVersion = ''
196+ event.addresponse(u'Ticket %(key)s (%(status)s %(priority)s %(type)s in %(component)s %(fixVersion)s'
197+ u'reported %(ago)s ago assigned to %(owner)s: "%(summary)s" %(url)s', {
198+ 'key': ticket.key,
199+ 'status': self.j.statuses.get(ticket.status, 'Unknown'),
200+ 'priority': self.j.priorities.get(ticket.priority, 'Unknown'),
201+ 'type': self.j.types.get(ticket.type, 'Unknown'),
202+ 'component': ", ".join([c.name for c in ticket.components]),
203+ 'fixVersion': fixVersion,
204+ 'ago': ago(datetime.now() - ticket.created, 2),
205+ 'owner': ticket.assignee,
206+ 'summary': ticket.summary,
207+ 'url': '%sbrowse/%s' % (self.url, ticket.key),
208+ })
209+ else:
210+ event.addresponse(u"No such ticket")
211+
212+ @match(r"^(?:(my|\S+?(?:'s))\s+)?(?:(open|closed|review)\s+)?tickets(?:\s+for\s+(.+?))?(?:(?:\s+ver\w*)?\s+(.+?))?$")
213+ @exceptional_response
214+ def handle_list(self, event, owner, status, component, version):
215+ status = status or 'open'
216+ if status.lower() == 'open':
217+ statuses = ("Accepted", "Assigned", "In Progress", "New", "Open", "Reopened")
218+ else:
219+ statuses = (status.lower(),)
220+
221+ query = "status in (%s)" % (", ".join(['"%s"'%s for s in statuses]))
222+
223+ if owner:
224+ if owner.lower() == 'my':
225+ owner = event.sender['nick'].lower()
226+ else:
227+ owner = owner.lower().replace("'s", '')
228+ query += " and assignee='%s'" % (owner, )
229+
230+ if not component:
231+ component = self.default_project
232+
233+ # This should have some way of specifying project and component
234+ # so that two projects with the same name components can be distinguished.
235+ if component in self.j.projects:
236+ query += " and project='%s'" % (component, )
237+ elif component in self.j.components:
238+ query += " and component='%s'" % (component, )
239+
240+ if version:
241+ query += " and (affectedVersion='%s' or fixVersion='%s')" % (version, version)
242+
243+ tickets = self.j.query(query, 10)
244+
245+ if len(tickets) > 0:
246+ if owner:
247+ event.addresponse(u'\n'.join(['%s: "%s"' % (ticket.key, ticket.summary) for ticket in tickets]), conflate='; ')
248+ else:
249+ event.addresponse(u'\n'.join(['%s (%s): "%s"' % (ticket.key, ticket.assignee, ticket.summary) for ticket in tickets]), conflate='; ')
250+ else:
251+ event.addresponse(u"No tickets found")
252+
253+# vi: set et sta sw=4 ts=4:

Subscribers

People subscribed via source and target branches