# -*- 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): """ [] Add a bugtracker to the list of defined bugtrackers. Currently supported types are Launchpad, Debbugs, Bugzilla, SourceForge, GitHub, GitLab, Gitea, CGit, Mantis, and Trac. will be used to reference the bugtracker in all commands. Unambiguous abbreviations of it will also be accepted. will be used to reference the bugtracker in the query result. If not given, it defaults to . """ 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): """ Remove the bugtracker associated with 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): """ [] Rename the bugtracker associated with to , optionally with . """ 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): """ [] 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): """ 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): """[] List defined bugtrackers. If 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): """[] Reset defined bugtrackers to defaults. If 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[a-z][^\s:]*(\s+(bug|ticket|issue|pull|pr|merge|mr)('?s)?)?)(?P:+\s*[#!]?|\s+[#!]?|\s*[#!])(?P\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[a-z][^\s:]*(\s+(commit)('?s)?)?)(?P:+\s*#?|\s+#?|\s*#)(?P[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"^(?Pbug|ticket|issue|pull|pr|merge|mr)('?s)?$", bt[-1]) else: sure_bug = re.match(r"^(?Pcommit)('?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\d+)" self.urlSnarfer(irc, msg, match, 'bug') def commitUrlSnarfer(self, irc, msg, match): r"(https?://)?\S+/commits?/([^\s?]*\?([^\s?&]+&)?id=)?(?P[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[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\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[0-9]+)', r'launchpad.net/bugs/\g', 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-)?(?Prequest|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