ubuntu-bots/Bugtracker/plugin.py

626 lines
26 KiB
Python

# -*- Encoding: utf-8 -*-
###
# Copyright (c) 2005-2007 Dennis Kaarsemaker
# Copyright (c) 2008-2010 Terence Simpson
# Copyright (c) 2017- Krytarik Raido
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#
# 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.
#
###
from supybot.commands import *
import supybot.utils as utils
import supybot.ircutils as ircutils
import supybot.ircdb as ircdb
import supybot.callbacks as callbacks
import supybot.conf as conf
import supybot.registry as registry
import supybot.log as supylog
import re, time
from .config import registerBugtracker, default_bugtrackers
from .trackers import defined_bugtrackers
from . import trackers
def defaultIgnored(hostmask):
if not conf.supybot.defaultIgnore():
return False
try:
user = ircdb.users.getUser(hostmask)
except KeyError:
return True
return False
def checkIgnored(hostmask, channel):
try:
user = ircdb.users.getUser(hostmask)
try:
if user._checkCapability('trusted'):
return False
except KeyError:
pass
if user.ignore:
return True
except KeyError:
pass
if ircdb.ignores.checkIgnored(hostmask):
return True
if channel:
c = ircdb.channels.getChannel(channel)
if c.checkIgnored(hostmask):
return True
return False
def checkAddressed(text, channel):
if channel:
if text[0] in str(conf.supybot.reply.whenAddressedBy.chars.get(channel)):
return True
elif text[0] in conf.supybot.reply.whenAddressedBy.chars():
return True
return False
class Bugtracker(callbacks.PluginRegexp):
"""Show a link to a bug report with a brief description"""
threaded = True
callBefore = ('URL')
regexps = ('bugSnarfer', 'bugUrlSnarfer', 'commitSnarfer', 'commitUrlSnarfer', 'cveSnarfer', 'oopsSnarfer')
def __init__(self, irc):
self.__parent = super(Bugtracker, self)
self.__parent.__init__(irc)
self.set_trackers()
self.shown = {}
def set_trackers(self):
self.db = ircutils.IrcDict()
self.aliases = {}
trackers = self.registryValue('bugtrackers')
if not trackers:
for (name, (url, description, trackertype, aliases)) in list(default_bugtrackers.items()):
registerBugtracker(name, url, description, trackertype, aliases)
for name in trackers:
tracker = self.registryValue('bugtrackers', value=False).get(name)
if tracker.trackertype() in defined_bugtrackers:
self.db[name] = defined_bugtrackers[tracker.trackertype()](name, tracker.url(),
tracker.description(), tracker.trackertype(), tracker.aliases())
for a in tracker.aliases():
self.aliases[a] = name
else:
supylog.warning("Bugtracker: Unknown trackertype '%s' (%s)" % (trackers[name].trackertype(), name))
self.shorthand = utils.abbrev(list(self.db.keys()))
def is_ok(self, channel, network, tracker, bugid):
"""Flood/repeat protection"""
now = time.time()
for k in list(self.shown.keys()):
if self.shown[k] < now - self.registryValue('repeatdelay', channel, network):
self.shown.pop(k)
if (network, channel, tracker, bugid) not in self.shown:
self.shown[(network, channel, tracker, bugid)] = now
return True
return False
def add(self, irc, msg, args, name, trackertype, url, description):
"""<name> <type> <url> [<description>]
Add a bugtracker to the list of defined bugtrackers. Currently supported types are
Launchpad, Debbugs, Bugzilla, SourceForge, GitHub, GitLab, Gitea, CGit, Mantis, and Trac.
<name> will be used to reference the bugtracker in all commands.
Unambiguous abbreviations of it will also be accepted.
<description> will be used to reference the bugtracker in the
query result. If not given, it defaults to <name>.
"""
name = name.lower()
if not description:
description = name
if url[-1] == '/':
url = url[:-1]
trackertype = trackertype.lower()
if trackertype in defined_bugtrackers:
self.db[name] = defined_bugtrackers[trackertype](name, url, description, trackertype)
else:
irc.error("Bugtrackers of type '%s' are not understood" % trackertype)
return
registerBugtracker(name, url, description, trackertype)
self.shorthand = utils.abbrev(list(self.db.keys()))
irc.replySuccess()
add = wrap(add, [('checkCapability', 'admin'), 'something', 'something', 'url', additional('text')])
def remove(self, irc, msg, args, name):
"""<abbreviation>
Remove the bugtracker associated with <abbreviation> from the list of
defined bugtrackers.
"""
try:
name = self.shorthand[name.lower()]
for a in self.db[name].aliases:
del self.aliases[a]
del self.db[name]
trackers = self.registryValue('bugtrackers')
if name in trackers:
trackers.remove(name)
self.registryValue('bugtrackers', value=False).unregister(name)
self.shorthand = utils.abbrev(list(self.db.keys()))
irc.replySuccess()
except KeyError:
s = self.registryValue('replyNoBugtracker', msg.channel, irc.network)
irc.error(s % name)
remove = wrap(remove, [('checkCapability', 'admin'), 'text'])
def rename(self, irc, msg, args, oldname, newname, newdesc):
"""<oldname> <newname> [<newdescription>]
Rename the bugtracker associated with <oldname> to <newname>,
optionally with <newdescription>.
"""
try:
oldname = self.shorthand[oldname.lower()]
newname = newname.lower()
tracker = self.db[oldname]
d = tracker.description
if newdesc:
d = newdesc
self.db[newname] = defined_bugtrackers[tracker.trackertype](newname, tracker.url, d, tracker.aliases)
registerBugtracker(newname, tracker.url, d, tracker.trackertype, tracker.aliases)
del self.db[oldname]
trackers = self.registryValue('bugtrackers')
if oldname in trackers:
trackers.remove(oldname)
self.registryValue('bugtrackers', value=False).unregister(oldname)
self.shorthand = utils.abbrev(list(self.db.keys()))
for a in tracker.aliases:
self.aliases[a] = newname
irc.replySuccess()
except KeyError:
s = self.registryValue('replyNoBugtracker', msg.channel, irc.network)
irc.error(s % oldname)
rename = wrap(rename, [('checkCapability', 'admin'), 'something', 'something', additional('text')])
def alias(self, irc, msg, args, name, alias):
"""<name> [<alias>]
Add an alias to a defined bugtracker, or list any set ones.
"""
try:
name = self.shorthand[name.lower()]
if not alias:
a = self.db[name].aliases
irc.reply('%s: %s' % (name, utils.str.commaAndify(sorted(a)) if a else 'No aliases set'))
return
alias = alias.lower()
self.db[name].aliases.add(alias)
trackers = self.registryValue('bugtrackers')
if name in trackers:
self.registryValue('bugtrackers', value=False).get(name).aliases().add(alias)
else:
tracker = self.db[name]
registerBugtracker(tracker.name, tracker.url, tracker.description, tracker.trackertype, tracker.aliases)
self.aliases[alias] = name
irc.replySuccess()
except KeyError:
s = self.registryValue('replyNoBugtracker', msg.channel, irc.network)
irc.error(s % name)
alias = wrap(alias, [('checkCapability', 'admin'), 'something', additional('text')])
def unalias(self, irc, msg, args, name, alias):
"""<name> <alias>
Remove an alias from a defined bugtracker.
"""
try:
name = self.shorthand[name.lower()]
alias = alias.lower()
try:
self.db[name].aliases.remove(alias)
trackers = self.registryValue('bugtrackers')
if name in trackers:
self.registryValue('bugtrackers', value=False).get(name).aliases().remove(alias)
del self.aliases[alias]
irc.replySuccess()
except ValueError:
irc.error("Bugtracker '%s' has no alias '%s' set" % (name, alias))
return
except KeyError:
s = self.registryValue('replyNoBugtracker', msg.channel, irc.network)
irc.error(s % name)
unalias = wrap(unalias, [('checkCapability', 'admin'), 'something', 'something'])
def list(self, irc, msg, args, name):
"""[<abbreviation>]
List defined bugtrackers. If <abbreviation> is specified, list the
information for that bugtracker.
"""
if name:
try:
name = self.shorthand[name.lower()]
tracker = self.db[name]
irc.reply('%s%s: %s, %s [%s]' % (name, ' (%s)' % ', '.join(sorted(tracker.aliases)) if tracker.aliases else '',
tracker.description, tracker.url, tracker.__class__.__name__))
except KeyError:
s = self.registryValue('replyNoBugtracker', msg.channel, irc.network)
irc.error(s % name)
else:
if self.db:
irc.reply(utils.str.commaAndify(sorted(list(self.db.keys()))))
else:
irc.reply('I have no defined bugtrackers.')
list = wrap(list, [additional('text')])
def reset(self, irc, msg, args, name):
"""[<abbreviation>]
Reset defined bugtrackers to defaults. If <abbreviation> is specified,
reset only that bugtracker.
"""
if name:
try:
name = self.shorthand[name.lower()]
for a in self.db[name].aliases:
del self.aliases[a]
del self.db[name]
trackers = self.registryValue('bugtrackers')
if name in trackers:
trackers.remove(name)
self.registryValue('bugtrackers', value=False).unregister(name)
if name in default_bugtrackers:
(url, description, trackertype, aliases) = default_bugtrackers[name]
if trackertype in defined_bugtrackers:
self.db[name] = defined_bugtrackers[trackertype](name, url, description, trackertype, aliases)
for a in aliases:
self.aliases[a] = name
registerBugtracker(name, url, description, trackertype, aliases)
else:
supylog.warning("Bugtracker: Unknown trackertype '%s' (%s)" % (trackers[name].trackertype(), name))
self.shorthand = utils.abbrev(list(self.db.keys()))
except KeyError:
s = self.registryValue('replyNoBugtracker', msg.channel, irc.network)
irc.error(s % name)
return
else:
trackers = self.registryValue('bugtrackers')
for name in list(trackers)[:]:
trackers.remove(name)
self.registryValue('bugtrackers', value=False).unregister(name)
self.set_trackers()
irc.replySuccess()
reset = wrap(reset, [('checkCapability', 'admin'), additional('text')])
def inFilter(self, irc, msg):
if not (msg.prefix and msg.args):
return msg
if not ircutils.isUserHostmask(msg.prefix):
return msg
if not defaultIgnored(msg.prefix):
return msg
if checkIgnored(msg.prefix, msg.channel):
return msg
if msg.command == 'PRIVMSG':
self.doPrivmsg(irc, msg)
return msg
def bugSnarfer(self, irc, msg, match):
r"(?P<bt>[a-z][^\s:]*(\s+(bug|ticket|issue|pull|pr|merge|mr)('?s)?)?)(?P<prefix>:+\s*[#!]?|\s+[#!]?|\s*[#!])(?P<bug>\d+(?!\d*[-.]\d+)(\s*([,\s]+|[,\s]*(and|und|en|et|ir|[&+]+))\s*#?\d+(?!\d*[-.]\d+))*)"
self.termSnarfer(irc, msg, match, 'bug')
def commitSnarfer(self, irc, msg, match):
r"(?P<bt>[a-z][^\s:]*(\s+(commit)('?s)?)?)(?P<prefix>:+\s*#?|\s+#?|\s*#)(?P<bug>[a-f0-9]{7,}(?![a-f0-9]*[-.][a-f0-9]{7,})(\s*([,\s]+|[,\s]*(and|und|en|et|ir|[&+]+))\s*#?[a-f0-9]{7,}(?![a-f0-9]*[-.][a-f0-9]{7,}))*)"
self.termSnarfer(irc, msg, match, 'commit')
def termSnarfer(self, irc, msg, match, termtype):
channel = msg.channel
if checkAddressed(msg.args[1].strip(), channel):
return
if not self.registryValue('{}Snarfer'.format(termtype), channel, irc.network):
return
nbugs = msg.tagged('nbugs') or 0
if nbugs >= 5:
return
if termtype != 'commit':
bugids = re.findall(r'[0-9]+', match.group('bug'))[:5-nbugs]
else:
bugids = re.findall(r'[a-f0-9]{7,}', match.group('bug'))[:5-nbugs]
# Begin HACK
# Strings like "Ubuntu 1004" and "Ubuntu 1610" are false triggers for us
if match.group('bt').lower() == 'ubuntu' and termtype == 'bug':
bugids = [x for x in bugids if not re.match(r'^([4-9]|[12][0-9])(04|10)$', x)]
# End HACK
# Get tracker name
bt = [x.lower() for x in match.group('bt').split()]
if termtype != 'commit':
sure_bug = re.match(r"^(?P<type>bug|ticket|issue|pull|pr|merge|mr)('?s)?$", bt[-1])
else:
sure_bug = re.match(r"^(?P<type>commit)('?s)?$", bt[-1])
# Get bug type
if sure_bug:
bugtype = sure_bug.group('type')
elif '#' in match.group('prefix'):
bugtype = 'bug'
elif '!' in match.group('prefix'):
bugtype = 'merge'
else:
bugtype = ''
bugids = list(set(bugids)) # remove dupes
if termtype == 'bug' and not bugtype:
bugids = [x for x in bugids if int(x) >= 100]
msg.tag('nbugs', nbugs + len(bugids))
name = ''
showTracker = True
if len(bt) == 1 and not sure_bug:
try:
name = bt[0]
if name in list(self.aliases.keys()):
name = self.aliases[name]
tracker = self.db[name]
except:
return
elif len(bt) == 2:
try:
name = bt[0]
if name in list(self.aliases.keys()):
name = self.aliases[name]
tracker = self.db[name]
except:
name = ''
if not name:
showTracker = False
snarfTarget = self.registryValue('snarfTarget', channel, irc.network)
if not snarfTarget:
supylog.warning("Bugtracker: No snarfTarget set")
return
try:
name = self.shorthand[snarfTarget.lower()]
tracker = self.db[name]
except:
s = self.registryValue('replyNoBugtracker', channel, irc.network)
irc.error(s % (name or snarfTarget))
return
if bugtype in ('issue', 'pull', 'pr', 'merge', 'mr') and \
tracker.trackertype not in ('github', 'gitlab', 'gitea', 'sourceforge'):
return
for bugid in bugids:
try:
report = self.get_bug(channel or msg.nick, irc.network, tracker, bugtype, bugid,
self.registryValue('showassignee', channel, irc.network),
self.registryValue('extended', channel, irc.network),
do_tracker=showTracker)
except trackers.BugNotFoundError:
if self.registryValue('replyWhenNotFound'):
irc.error("Could not find %s %s %s" % (tracker.description, termtype, bugid))
except trackers.BugtrackerError as e:
if self.registryValue('replyWhenError') and sure_bug:
irc.error(str(e))
else:
if report:
if not self.registryValue('useNotices', channel, irc.network):
irc.reply(report)
else:
irc.reply(report, notice=True)
def bugUrlSnarfer(self, irc, msg, match):
r"(https?://)?((bugs\.debian\.org|pad\.lv)/|\S+/(show_bug\.cgi\?id=|bugreport\.cgi\?bug=|view\.php\?id=|bug=|bugs/|\+bug/|tickets?/|feature-requests/|patches/|todo/|issues/|pulls?/|merge_requests/))(?P<bug>\d+)"
self.urlSnarfer(irc, msg, match, 'bug')
def commitUrlSnarfer(self, irc, msg, match):
r"(https?://)?\S+/commits?/([^\s?]*\?([^\s?&]+&)?id=)?(?P<bug>[a-f0-9]{7,})"
self.urlSnarfer(irc, msg, match, 'commit')
def urlSnarfer(self, irc, msg, match, urltype):
channel = msg.channel
if checkAddressed(msg.args[1].strip(), channel):
return
if not self.registryValue('{}Snarfer'.format(urltype), channel, irc.network):
return
nbugs = msg.tagged('nbugs') or 0
if nbugs >= 5:
return
msg.tag('nbugs', nbugs+1)
url = match.group(0)
bugid = match.group('bug')
if '://' in url:
url = url[url.rfind('://')+3:]
try:
tracker = self.get_tracker(url, bugid)
if not tracker:
return
report = self.get_bug(channel or msg.nick, irc.network, tracker, 'url', bugid,
self.registryValue('showassignee', channel, irc.network),
self.registryValue('extended', channel, irc.network),
do_url=False)
except trackers.BugNotFoundError:
if self.registryValue('replyWhenNotFound'):
irc.error("Could not find %s %s %s" % (tracker.description, urltype, match.group('bug')))
except trackers.BugtrackerError as e:
if self.registryValue('replyWhenError'):
irc.error(str(e))
else:
if report:
if not self.registryValue('useNotices', channel, irc.network):
irc.reply(report)
else:
irc.reply(report, notice=True)
# Only useful to Launchpad developers
def oopsSnarfer(self, irc, msg, match):
r"(https?://\S+[=/])?OOPS-(?P<oopsid>[a-f0-9]{6,})"
channel = msg.channel
if checkAddressed(msg.args[1].strip(), channel):
return
if not (self.registryValue('bugSnarfer', channel, irc.network) \
and self.registryValue('oopsSnarfer', channel, irc.network)):
return
oopsid = match.group('oopsid')
if not self.is_ok(channel or msg.nick, irc.network, 'lpoops', oopsid):
return
if not match.group(1):
report = 'https://oops.canonical.com/?oopsid=OOPS-%s' % oopsid
if not self.registryValue('useNotices', channel, irc.network):
irc.reply(report)
else:
irc.reply(report, notice=True)
def cveSnarfer(self, irc, msg, match):
r"(https?://\S+=)?CVE[- ](?P<cveid>\d{4}[- ]\d{4,})"
channel = msg.channel
if checkAddressed(msg.args[1].strip(), channel):
return
if not (self.registryValue('bugSnarfer', channel, irc.network) \
and self.registryValue('cveSnarfer', channel, irc.network)):
return
cveid = match.group('cveid').replace(' ','-')
if not self.is_ok(channel or msg.nick, irc.network, 'cve', cveid):
return
if not match.group(1):
do_url = True
else:
do_url = False
try:
report = trackers.CVE().get_bug(channel or msg.nick, cveid, do_url)
except trackers.BugNotFoundError:
if self.registryValue('replyWhenNotFound'):
irc.error("Could not find CVE %s" % cveid)
except trackers.BugtrackerError as e:
if self.registryValue('replyWhenError'):
irc.error(str(e))
else:
if report:
if not self.registryValue('useNotices', channel, irc.network):
irc.reply(report)
else:
irc.reply(report, notice=True)
#TODO: As we will depend on launchpadlib, we should consider using lazr.uri.URI to do URL parsing
def get_tracker(self, snarfurl, bugid):
# SourceForge short domain
snarfurl = snarfurl.replace('sf.net', 'sourceforge.net', 1)
# Launchpad URL shortening
snarfurl = re.sub(r'pad\.lv/(bug=)?(?P<bug>[0-9]+)', r'launchpad.net/bugs/\g<bug>', snarfurl)
for t in list(self.db.keys()):
tracker = self.db[t]
url = tracker.url[tracker.url.rfind('://')+3:]
if url in snarfurl and not (url == 'launchpad.net' \
and snarfurl.startswith('git.launchpad.net')):
return tracker
# No tracker found, bummer. Let's try and get one
if 'show_bug.cgi' in snarfurl:
tracker = trackers.Bugzilla().get_tracker(snarfurl)
elif 'sourceforge.net' in snarfurl:
tracker = trackers.SourceForge().get_tracker(snarfurl)
elif 'github.com' in snarfurl:
tracker = trackers.GitHub().get_tracker(snarfurl)
elif re.match(r'\S+/commit/[^\s?]*\?([^\s?&]+&)?id=', snarfurl):
tracker = trackers.CGit().get_tracker(snarfurl, bugid)
elif re.match(r'[^\s/]+/[^\s/]+(/[^\s/]+)+/-/(issues|merge_requests|commits?)', snarfurl) \
or re.match(r'[^\s/]+/[^\s/]+(/[^\s/]+)+/merge_requests', snarfurl) \
or re.match(r'[^\s/]+/[^\s/]+(/[^\s/]+){2,}/(issues|commits?)', snarfurl):
tracker = trackers.GitLab().get_tracker(snarfurl, bugid)
elif re.match(r'[^\s/]+/[^\s/]+/[^\s/]+/(issues|commits?)', snarfurl):
tracker = trackers.GitLab().get_tracker(snarfurl, bugid)
if not tracker:
tracker = trackers.Gitea().get_tracker(snarfurl, bugid)
elif re.match(r'[^\s/]+/[^\s/]+/[^\s/]+/pulls', snarfurl):
tracker = trackers.Gitea().get_tracker(snarfurl, bugid)
elif 'view.php' in snarfurl:
tracker = trackers.Mantis().get_tracker(snarfurl)
elif '/ticket/' in snarfurl:
tracker = trackers.Trac().get_tracker(snarfurl)
else:
return
if tracker:
self.db[tracker.name] = tracker
if self.registryValue('saveDiscoveredTrackers'):
registerBugtracker(tracker.name, tracker.url, tracker.description, tracker.trackertype, tracker.aliases)
self.shorthand = utils.abbrev(list(self.db.keys()))
return tracker
def get_bug(self, channel, network, tracker, bugtype, bugid, do_assignee, do_extinfo, do_url=True, do_tracker=True):
if not self.is_ok(channel, network, tracker, bugid):
return
bugdata = tracker.get_bug(bugtype, bugid)
if not bugdata:
return
(bugid, product, title, severity, status, assignee, url, extinfo, duplicate) = bugdata
if duplicate and not self.is_ok(channel, network, tracker, bugid):
return
bugtype = re.match(r'\S+/(feature-)?(?P<type>request|patch|todo|issue|pull|merge|ticket|commit)(_requests)?(e?s)?/(\?id=)?[a-f0-9]+/?$', url)
if do_tracker and tracker.trackertype not in ('github', 'gitlab', 'gitea', 'cgit'):
if re.match(r'\S+/(bugs|feature-requests|patches|todo|issues|pulls?|merge_requests|tickets?|commits?)$', tracker.description):
report = '%s %s' % (tracker.description, bugid)
else:
if bugtype:
report = '%s %s %s' % (tracker.description, bugtype.group('type'), bugid)
else:
report = '%s bug %s' % (tracker.description, bugid)
else:
if bugtype:
report = '%s %s' % (bugtype.group('type').title(), bugid)
else:
report = 'Bug %s' % bugid
if product:
report += ' in %s' % product
report += ' "%s"' % title.replace('"', "'").strip()
if do_extinfo and extinfo:
report += ' (%s)' % ', '.join(extinfo)
if do_assignee and assignee:
report += ' (assigned: %s)' % assignee
severity_status = []
if severity:
severity_status.append(' '.join(word[0].upper() + word[1:].lower() for word in severity.split()))
if status:
severity_status.append(' '.join(word[0].upper() + word[1:].lower() for word in status.split()))
if severity_status:
report += ' [%s]' % ', '.join(severity_status)
if duplicate:
report += ' [duplicate: %s]' % duplicate[0]
if do_url:
report += ' %s' % url
message_max = 450 - len(channel)
if len(report) > message_max:
report_parts = report.split('"')
report_start = report_parts[0]
report_end = report_parts[-1]
report_title = '"'.join(report_parts[1:-1])
title_max = message_max - len(report_start) - len(report_end) - 5
report_title_cut = report_title[:title_max].rsplit(None, 1)[0] + '...'
report = '%s"%s"%s' % (report_start, report_title_cut, report_end)
return report
Class = Bugtracker