Compare commits
22 Commits
Author | SHA1 | Date |
---|---|---|
Brian S. Stephan | a1a256ca3b | |
Brian S. Stephan | ee6ae7080e | |
Brian S. Stephan | 333424025b | |
Brian S. Stephan | 98abab560e | |
Brian S. Stephan | 86e55cb812 | |
Brian S. Stephan | 420a7b1472 | |
Brian S. Stephan | a214d8acfd | |
Brian S. Stephan | 93c522037f | |
Brian S. Stephan | b7976c46d1 | |
Brian S. Stephan | d962b275ff | |
Brian S. Stephan | f898f35ce6 | |
Brian S. Stephan | 4289f95800 | |
Brian S. Stephan | 572ecddceb | |
Brian S. Stephan | 3aadde4b71 | |
Brian S. Stephan | c2d26f404e | |
Brian S. Stephan | ecaabbce89 | |
Brian S. Stephan | 051e656a82 | |
Brian S. Stephan | 0ea54a5ee2 | |
Brian S. Stephan | ffcdc3f8d8 | |
Brian S. Stephan | cff1a183cf | |
Brian S. Stephan | 68f7c80b7e | |
Brian S. Stephan | 7baa70d8f6 |
|
@ -1,10 +1,44 @@
|
|||
## Contributing
|
||||
# Contributing Guidelines
|
||||
|
||||
This is a pretty fun, non-serious hacking project, so if you're interested in
|
||||
contributing, sign up, clone the project, and submit some merge requests!
|
||||
There's a lot to add and work on, so join in.
|
||||
|
||||
dr.botzo is made available under the GPLv3 license. Contributions are welcome via pull requests. This document outlines
|
||||
the process to get your contribution accepted.
|
||||
|
||||
## Code Style
|
||||
|
||||
4 spaces per indent level. 120 character line length. Follow PEP8 as closely
|
||||
as reasonable. There's a prospector config, use it.
|
||||
|
||||
## Sign Offs/Custody of Contributions
|
||||
|
||||
I do not request the copyright of contributions be assigned to me or to the project, and I require no provision that I
|
||||
be allowed to relicense your contributions. My personal oath is to maintain inbound=outbound in my open source projects,
|
||||
and the expectation is authors are responsible for their contributions.
|
||||
|
||||
I am following the the [Developer Certificate of Origin (DCO)](https://developercertificate.org/), also available at
|
||||
`DCO.txt`. The DCO is a way for contributors to certify that they wrote or otherwise have the right to license their
|
||||
code contributions to the project. Contributors must sign-off that they adhere to these requirements by adding a
|
||||
`Signed-off-by` line to their commit message, and/or, for frequent contributors, by signing off on their entry in
|
||||
`MAINTAINERS.md`.
|
||||
|
||||
This process is followed by a number of open source projects, most notably the Linux kernel. Here's the gist of it:
|
||||
|
||||
```
|
||||
[Your normal Git commit message here.]
|
||||
|
||||
Signed-off-by: Random J Developer <random@developer.example.org>
|
||||
```
|
||||
|
||||
`git help commit` has more info on adding this:
|
||||
|
||||
```
|
||||
-s, --signoff
|
||||
Add Signed-off-by line by the committer at the end of the commit log
|
||||
message. The meaning of a signoff depends on the project, but it typically
|
||||
certifies that committer has the rights to submit this work under the same
|
||||
license and agrees to a Developer Certificate of Origin (see
|
||||
http://developercertificate.org/ for more information).
|
||||
```
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
Developer Certificate of Origin
|
||||
Version 1.1
|
||||
|
||||
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim copies of this
|
||||
license document, but changing it is not allowed.
|
||||
|
||||
|
||||
Developer's Certificate of Origin 1.1
|
||||
|
||||
By making a contribution to this project, I certify that:
|
||||
|
||||
(a) The contribution was created in whole or in part by me and I
|
||||
have the right to submit it under the open source license
|
||||
indicated in the file; or
|
||||
|
||||
(b) The contribution is based upon previous work that, to the best
|
||||
of my knowledge, is covered under an appropriate open source
|
||||
license and I have the right under that license to submit that
|
||||
work with modifications, whether created in whole or in part
|
||||
by me, under the same open source license (unless I am
|
||||
permitted to submit under a different license), as indicated
|
||||
in the file; or
|
||||
|
||||
(c) The contribution was provided directly to me by some other
|
||||
person who certified (a), (b) or (c) and I have not modified
|
||||
it.
|
||||
|
||||
(d) I understand and agree that this project and the contribution
|
||||
are public and that a record of the contribution (including all
|
||||
personal information I submit with it, including my sign-off) is
|
||||
maintained indefinitely and may be redistributed consistent with
|
||||
this project or the open source license(s) involved.
|
|
@ -1,7 +1,7 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
|
@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
|
|||
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, see <http://www.gnu.org/licenses/>.
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
|
@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
|
|||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<http://www.gnu.org/licenses/>.
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<http://www.gnu.org/philosophy/why-not-lgpl.html>.
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
|
@ -0,0 +1,10 @@
|
|||
# Maintainers
|
||||
|
||||
This file contains information about people permitted to make major decisions and direction on the project.
|
||||
|
||||
## Contributing Under the DCO
|
||||
|
||||
By adding your name and email address to this section, you certify that all of your subsequent contributions to dr.botzo
|
||||
are made under the terms of the Developer's Certificate of Origin 1.1, available at `DCO.txt`.
|
||||
|
||||
* Brian S. Stephan (<bss@incorporeal.org>)
|
|
@ -5,4 +5,10 @@ from django.contrib import admin
|
|||
from countdown.models import CountdownItem
|
||||
|
||||
|
||||
admin.site.register(CountdownItem)
|
||||
class CountdownItemAdmin(admin.ModelAdmin):
|
||||
"""Custom display for the countdown items."""
|
||||
|
||||
list_display = ('__str__', 'reminder_message')
|
||||
|
||||
|
||||
admin.site.register(CountdownItem, CountdownItemAdmin)
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.18 on 2023-03-01 00:26
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dispatch', '0006_xmlrpc_settings'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='dispatcheraction',
|
||||
old_name='type',
|
||||
new_name='action_type',
|
||||
),
|
||||
]
|
|
@ -38,10 +38,10 @@ class DispatcherAction(models.Model):
|
|||
)
|
||||
|
||||
dispatcher = models.ForeignKey('Dispatcher', related_name='actions', on_delete=models.CASCADE)
|
||||
type = models.CharField(max_length=16, choices=TYPE_CHOICES)
|
||||
action_type = models.CharField(max_length=16, choices=TYPE_CHOICES)
|
||||
destination = models.CharField(max_length=200)
|
||||
include_key = models.BooleanField(default=False)
|
||||
|
||||
def __str__(self):
|
||||
"""Provide string representation."""
|
||||
return "{0:s} -> {1:s} {2:s}".format(self.dispatcher.key, self.type, self.destination)
|
||||
return "{0:s} -> {1:s} {2:s}".format(self.dispatcher.key, self.action_type, self.destination)
|
||||
|
|
|
@ -12,7 +12,7 @@ class DispatcherActionSerializer(serializers.ModelSerializer):
|
|||
"""Meta options."""
|
||||
|
||||
model = DispatcherAction
|
||||
fields = ('id', 'dispatcher', 'type', 'destination')
|
||||
fields = ('id', 'dispatcher', 'action_type', 'destination')
|
||||
|
||||
|
||||
class DispatcherSerializer(serializers.ModelSerializer):
|
||||
|
|
|
@ -6,11 +6,11 @@ from dispatch.views import (DispatcherActionDetail, DispatcherActionList, Dispat
|
|||
|
||||
urlpatterns = [
|
||||
path('api/dispatchers/', DispatcherList.as_view(), name='dispatch_api_dispatchers'),
|
||||
path('api/dispatchers/<pk>/', DispatcherDetail.as_view(), name='dispatch_api_dispatcher_detail'),
|
||||
path('api/dispatchers/<pk>/message', DispatchMessage.as_view(), name='dispatch_api_dispatch_message'),
|
||||
path('api/dispatchers/<int:pk>/', DispatcherDetail.as_view(), name='dispatch_api_dispatcher_detail'),
|
||||
path('api/dispatchers/<int:pk>/message', DispatchMessage.as_view(), name='dispatch_api_dispatch_message'),
|
||||
path('api/dispatchers/<key>/', DispatcherDetailByKey.as_view(), name='dispatch_api_dispatcher_detail'),
|
||||
path('api/dispatchers/<key>/message', DispatchMessageByKey.as_view(), name='dispatch_api_dispatch_message'),
|
||||
|
||||
path('api/actions/', DispatcherActionList.as_view(), name='dispatch_api_actions'),
|
||||
path('api/actions/<pk>/', DispatcherActionDetail.as_view(), name='dispatch_api_action_detail'),
|
||||
path('api/actions/<int:pk>/', DispatcherActionDetail.as_view(), name='dispatch_api_action_detail'),
|
||||
]
|
||||
|
|
|
@ -28,6 +28,8 @@ class HasSendMessagePermission(IsAuthenticated):
|
|||
class DispatcherList(generics.ListAPIView):
|
||||
"""List all dispatchers."""
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
queryset = Dispatcher.objects.all()
|
||||
serializer_class = DispatcherSerializer
|
||||
|
||||
|
@ -35,6 +37,8 @@ class DispatcherList(generics.ListAPIView):
|
|||
class DispatcherDetail(generics.RetrieveAPIView):
|
||||
"""Detail the given dispatcher."""
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
queryset = Dispatcher.objects.all()
|
||||
serializer_class = DispatcherSerializer
|
||||
|
||||
|
@ -71,19 +75,19 @@ class DispatchMessage(generics.GenericAPIView):
|
|||
else:
|
||||
text = message.data['message']
|
||||
|
||||
if action.type == DispatcherAction.PRIVMSG_TYPE:
|
||||
if action.action_type == DispatcherAction.PRIVMSG_TYPE:
|
||||
# connect over XML-RPC and send
|
||||
try:
|
||||
bot_url = 'http://{0:s}:{1:d}/'.format(dispatcher.bot_xmlrpc_host, dispatcher.bot_xmlrpc_port)
|
||||
bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True)
|
||||
log.debug("sending '%s' to channel %s", text, action.destination)
|
||||
bot.reply(None, text, False, action.destination)
|
||||
except Exception as e:
|
||||
except xmlrpc.client.Fault as xmlex:
|
||||
new_data = copy.deepcopy(message.data)
|
||||
new_data['status'] = "FAILED - {0:s}".format(str(e))
|
||||
new_data['status'] = "FAILED - {0:s}".format(str(xmlex))
|
||||
new_message = self.serializer_class(data=new_data)
|
||||
return Response(new_message.initial_data)
|
||||
elif action.type == DispatcherAction.FILE_TYPE:
|
||||
elif action.action_type == DispatcherAction.FILE_TYPE:
|
||||
# write to file
|
||||
filename = os.path.abspath(action.destination)
|
||||
log.debug("sending '%s' to file %s", text, filename)
|
||||
|
@ -107,6 +111,8 @@ class DispatchMessageByKey(DispatchMessage):
|
|||
class DispatcherActionList(generics.ListAPIView):
|
||||
"""List all dispatchers."""
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
queryset = DispatcherAction.objects.all()
|
||||
serializer_class = DispatcherActionSerializer
|
||||
|
||||
|
@ -114,5 +120,7 @@ class DispatcherActionList(generics.ListAPIView):
|
|||
class DispatcherActionDetail(generics.RetrieveAPIView):
|
||||
"""Detail the given dispatcher."""
|
||||
|
||||
permission_classes = (IsAuthenticated,)
|
||||
|
||||
queryset = DispatcherAction.objects.all()
|
||||
serializer_class = DispatcherActionSerializer
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
"""Site processors to add additional template tags and whatnot."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.sites.shortcuts import get_current_site
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
|
||||
|
@ -11,4 +11,5 @@ def site(request):
|
|||
return {
|
||||
'site': site,
|
||||
'site_root': SimpleLazyObject(lambda: "{0}://{1}".format(protocol, site.domain)),
|
||||
'WEB_ENABLED_APPS': settings.WEB_ENABLED_APPS,
|
||||
}
|
||||
|
|
|
@ -43,7 +43,6 @@ INSTALLED_APPS = (
|
|||
'countdown',
|
||||
'dispatch',
|
||||
'facts',
|
||||
'gitlab_bot',
|
||||
'ircbot',
|
||||
'karma',
|
||||
'markov',
|
||||
|
@ -54,13 +53,13 @@ INSTALLED_APPS = (
|
|||
)
|
||||
|
||||
MIDDLEWARE = (
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
)
|
||||
|
||||
ROOT_URLCONF = 'dr_botzo.urls'
|
||||
|
@ -146,6 +145,16 @@ BOOTSTRAP3 = {
|
|||
'javascript_in_head': True,
|
||||
}
|
||||
|
||||
###############
|
||||
# web options #
|
||||
###############
|
||||
|
||||
# choose which apps to display in the web UI, for those that support this config
|
||||
WEB_ENABLED_APPS = [
|
||||
'karma',
|
||||
'races',
|
||||
]
|
||||
|
||||
|
||||
# IRC module stuff
|
||||
|
||||
|
@ -173,10 +182,6 @@ STORYCRAFT_DEFAULT_GAME_LENGTH = 20
|
|||
STORYCRAFT_DEFAULT_LINE_LENGTH = 140
|
||||
STORYCRAFT_DEFAULT_LINES_PER_TURN = 2
|
||||
|
||||
# weather
|
||||
|
||||
WEATHER_WEATHER_UNDERGROUND_API_KEY = None
|
||||
|
||||
|
||||
# load local settings
|
||||
|
||||
|
|
|
@ -30,9 +30,9 @@
|
|||
{% block navbar_menu %}
|
||||
<ul class="nav navbar-nav">
|
||||
<li><a href="{% url 'facts_index' %}">Item Sets</a></li>
|
||||
<li><a href="{% url 'karma_index' %}">Karma</a></li>
|
||||
{% if "karma" in WEB_ENABLED_APPS %}<li><a href="{% url 'karma_index' %}">Karma</a></li>{% endif %}
|
||||
<li><a href="{% url 'markov_index' %}">Markov</a></li>
|
||||
<li><a href="{% url 'races_index' %}">Races</a></li>
|
||||
{% if "races" in WEB_ENABLED_APPS %}<li><a href="{% url 'races_index' %}">Races</a></li>{% endif %}
|
||||
</ul>
|
||||
{% endblock %}
|
||||
<div class="navbar-right">
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
"""Admin stuff for GitLab bot models."""
|
||||
|
||||
from django.contrib import admin
|
||||
|
||||
from gitlab_bot.models import GitlabConfig, GitlabProjectConfig
|
||||
|
||||
admin.site.register(GitlabConfig)
|
||||
admin.site.register(GitlabProjectConfig)
|
|
@ -1,253 +0,0 @@
|
|||
"""Wrapped client for the configured GitLab bot."""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
|
||||
import gitlab
|
||||
|
||||
from gitlab_bot.models import GitlabConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitlabBot(object):
|
||||
|
||||
"""Bot for doing GitLab stuff. Might be used by the IRC bot, or daemons, or whatever."""
|
||||
|
||||
REVIEWS_START_FORMAT = "Starting review process."
|
||||
REVIEWS_RESET_FORMAT = "Review process reset."
|
||||
REVIEWS_REVIEW_REGISTERED_FORMAT = "Review identified."
|
||||
REVIEWS_PROGRESS_FORMAT = "{0:d} reviews of {1:d} necessary to proceed."
|
||||
REVIEWS_REVIEWS_COMPLETE = "Reviews complete."
|
||||
NEW_REVIEWER_FORMAT = "Assigning to {0:s} to review merge request."
|
||||
NEW_ACCEPTER_FORMAT = "Assigning to {0:s} to accept merge request."
|
||||
|
||||
NOTE_COMMENT = [
|
||||
"The computer is your friend.",
|
||||
"Trust the computer.",
|
||||
"Quality is mandatory.",
|
||||
"Errors show your disloyalty to the computer.",
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the actual GitLab client."""
|
||||
config = GitlabConfig.objects.first()
|
||||
self.client = gitlab.Gitlab(config.url, private_token=config.token)
|
||||
self.client.auth()
|
||||
|
||||
def random_reviews_start_message(self):
|
||||
return "{0:s} {1:s} {2:s}".format(self.REVIEWS_START_FORMAT, self.REVIEWS_PROGRESS_FORMAT,
|
||||
random.choice(self.NOTE_COMMENT))
|
||||
|
||||
def random_reviews_reset_message(self):
|
||||
return "{0:s} {1:s} {2:s}".format(self.REVIEWS_RESET_FORMAT, self.REVIEWS_PROGRESS_FORMAT,
|
||||
random.choice(self.NOTE_COMMENT))
|
||||
|
||||
def random_review_progress_message(self):
|
||||
return "{0:s} {1:s} {2:s}".format(self.REVIEWS_REVIEW_REGISTERED_FORMAT, self.REVIEWS_PROGRESS_FORMAT,
|
||||
random.choice(self.NOTE_COMMENT))
|
||||
|
||||
def random_reviews_complete_message(self):
|
||||
return "{0:s} {1:s}".format(self.REVIEWS_REVIEWS_COMPLETE, random.choice(self.NOTE_COMMENT))
|
||||
|
||||
def random_new_reviewer_message(self):
|
||||
return "{0:s} {1:s}".format(self.NEW_REVIEWER_FORMAT, random.choice(self.NOTE_COMMENT))
|
||||
|
||||
def random_reviews_done_message(self):
|
||||
return "{0:s} {1:s}".format(self.NEW_ACCEPTER_FORMAT, random.choice(self.NOTE_COMMENT))
|
||||
|
||||
def scan_project_for_reviews(self, project, merge_request_ids=None):
|
||||
project_obj = self.client.projects.get(project.project_id)
|
||||
if not project_obj:
|
||||
return
|
||||
|
||||
if merge_request_ids:
|
||||
merge_requests = []
|
||||
for merge_request_id in merge_request_ids:
|
||||
merge_requests.append(project_obj.mergerequests.get(id=merge_request_id))
|
||||
else:
|
||||
merge_requests = project_obj.mergerequests.list(state='opened')
|
||||
|
||||
for merge_request in merge_requests:
|
||||
log.debug("scanning merge request '%s'", merge_request.title)
|
||||
|
||||
if merge_request.state in ['merged', 'closed']:
|
||||
log.info("merge request '%s' is already %s, doing nothing", merge_request.title,
|
||||
merge_request.state)
|
||||
continue
|
||||
|
||||
request_state = _MergeRequestScanningState()
|
||||
notes = sorted(self.client.project_mergerequest_notes.list(project_id=merge_request.project_id,
|
||||
merge_request_id=merge_request.id,
|
||||
all=True),
|
||||
key=lambda x: x.id)
|
||||
for note in notes:
|
||||
if not note.system:
|
||||
log.debug("merge request '%s', note '%s' is a normal message", merge_request.title, note.id)
|
||||
# note that we can't ignore note.system = True + merge_request.author, that might have been
|
||||
# a new push or something, so we only ignore normal notes from the author
|
||||
if note.author == merge_request.author:
|
||||
log.debug("skipping note from the merge request author")
|
||||
elif note.author.username == self.client.user.username:
|
||||
log.debug("saw a message from myself, i might have already sent a notice i'm sitting on")
|
||||
if note.body.find(self.REVIEWS_RESET_FORMAT) >= 0 and request_state.unlogged_approval_reset:
|
||||
log.debug("saw a reset message, unsetting the flag")
|
||||
request_state.unlogged_approval_reset = False
|
||||
elif note.body.find(self.REVIEWS_START_FORMAT) >= 0 and request_state.unlogged_review_start:
|
||||
log.debug("saw a start message, unsetting the flag")
|
||||
request_state.unlogged_review_start = False
|
||||
elif note.body.find(self.REVIEWS_REVIEW_REGISTERED_FORMAT) >= 0 and request_state.unlogged_approval:
|
||||
log.debug("saw a review log message, unsetting the flag")
|
||||
request_state.unlogged_approval = False
|
||||
elif note.body.find(self.REVIEWS_REVIEWS_COMPLETE) >= 0 and request_state.unlogged_review_complete:
|
||||
log.debug("saw a review complete message, unsetting the flag")
|
||||
request_state.unlogged_review_complete = False
|
||||
else:
|
||||
log.debug("nothing in particular relevant in '%s'", note.body)
|
||||
else:
|
||||
if note.body.find("LGTM") >= 0:
|
||||
log.debug("merge request '%s', note '%s' has a LGTM", merge_request.title, note.id)
|
||||
request_state.unlogged_approval = True
|
||||
request_state.approver_list.append(note.author.username)
|
||||
log.debug("approvers: %s", request_state.approver_list)
|
||||
if len(request_state.approver_list) < project.code_reviews_necessary:
|
||||
log.debug("not enough code reviews yet, setting needs_reviewer")
|
||||
request_state.needs_reviewer = True
|
||||
request_state.needs_accepter = False
|
||||
else:
|
||||
log.debug("enough code reviews, setting needs_accepter")
|
||||
request_state.needs_accepter = True
|
||||
request_state.needs_reviewer = False
|
||||
request_state.unlogged_review_complete = True
|
||||
else:
|
||||
log.debug("merge request '%s', note '%s' does not have a LGTM", merge_request.title,
|
||||
note.id)
|
||||
else:
|
||||
log.debug("merge request '%s', note '%s' is a system message", merge_request.title, note.id)
|
||||
if re.match(r'Added \d+ commit', note.body):
|
||||
log.debug("resetting approval list, '%s' looks like a push!", note.body)
|
||||
|
||||
# only set the unlogged approval reset flag if there's some kind of progress
|
||||
if len(request_state.approver_list) > 0:
|
||||
request_state.unlogged_approval_reset = True
|
||||
request_state.needs_reviewer = True
|
||||
request_state.approver_list.clear()
|
||||
else:
|
||||
log.debug("leaving the approval list as it is, i don't think '%s' is a push", note.body)
|
||||
|
||||
# do some cleanup
|
||||
excluded_review_candidates = request_state.approver_list + [merge_request.author.username]
|
||||
review_candidates = [x for x in project.code_reviewers.split(',') if x not in excluded_review_candidates]
|
||||
if merge_request.assignee:
|
||||
if request_state.needs_reviewer and merge_request.assignee.username in review_candidates:
|
||||
log.debug("unsetting the needs_reviewer flag, the request is already assigned to one")
|
||||
request_state.needs_reviewer = False
|
||||
elif request_state.needs_reviewer and merge_request.assignee.username == merge_request.author.username:
|
||||
log.info("unsetting the needs_reviewer flag, the request is assigned to the author")
|
||||
log.info("in this case we are assuming that the author has work to do, and will re/unassign")
|
||||
request_state.needs_reviewer = False
|
||||
|
||||
excluded_accept_candidates = [merge_request.author.username]
|
||||
accept_candidates = [x for x in project.code_review_final_merge_assignees.split(',') if x not in excluded_accept_candidates]
|
||||
if merge_request.assignee:
|
||||
if request_state.needs_accepter and merge_request.assignee.username in accept_candidates:
|
||||
log.debug("unsetting the needs_accepter flag, the request is already assigned to one")
|
||||
request_state.needs_accepter = False
|
||||
elif request_state.needs_accepter and merge_request.assignee.username == merge_request.author.username:
|
||||
log.info("unsetting the needs_accepter flag, the request is assigned to the author")
|
||||
log.info("in this case we are assuming that the author has work to do, and will re/unassign")
|
||||
request_state.needs_accepter = False
|
||||
|
||||
log.debug("%s", request_state.__dict__)
|
||||
|
||||
# status message stuff
|
||||
if request_state.unlogged_review_start:
|
||||
log.info("sending message for start of reviews")
|
||||
msg = {'body': self.random_reviews_start_message().format(len(request_state.approver_list),
|
||||
project.code_reviews_necessary)}
|
||||
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
|
||||
merge_request_id=merge_request.id)
|
||||
if request_state.unlogged_approval_reset:
|
||||
log.info("sending message for review reset")
|
||||
msg = {'body': self.random_reviews_reset_message().format(len(request_state.approver_list),
|
||||
project.code_reviews_necessary)}
|
||||
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
|
||||
merge_request_id=merge_request.id)
|
||||
if request_state.unlogged_approval:
|
||||
log.info("sending message for code review progress")
|
||||
msg = {'body': self.random_review_progress_message().format(len(request_state.approver_list),
|
||||
project.code_reviews_necessary)}
|
||||
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
|
||||
merge_request_id=merge_request.id)
|
||||
if request_state.unlogged_review_complete:
|
||||
log.info("sending message for code review complete")
|
||||
msg = {'body': self.random_reviews_complete_message()}
|
||||
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
|
||||
merge_request_id=merge_request.id)
|
||||
|
||||
# if there's a reviewer necessary, assign the merge request
|
||||
if len(request_state.approver_list) < project.code_reviews_necessary and request_state.needs_reviewer:
|
||||
log.debug("%s needs a code review", merge_request.title)
|
||||
if merge_request.assignee is not None:
|
||||
log.debug("%s currently assigned to %s", merge_request.title, merge_request.assignee.username)
|
||||
|
||||
if merge_request.assignee is None or merge_request.assignee.username not in review_candidates:
|
||||
if len(review_candidates) > 0:
|
||||
new_reviewer = review_candidates[merge_request.iid % len(review_candidates)]
|
||||
log.debug("%s is the new reviewer", new_reviewer)
|
||||
|
||||
# get the user object for the new reviewer
|
||||
new_reviewer_obj = self.client.users.get_by_username(new_reviewer)
|
||||
|
||||
# create note for the update
|
||||
msg = {'body': self.random_new_reviewer_message().format(new_reviewer_obj.name)}
|
||||
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
|
||||
merge_request_id=merge_request.id)
|
||||
|
||||
# assign the merge request to the new reviewer
|
||||
self.client.update(merge_request, assignee_id=new_reviewer_obj.id)
|
||||
else:
|
||||
log.warning("no reviewers left to review %s, doing nothing", merge_request.title)
|
||||
else:
|
||||
log.debug("needs_reviewer set but the request is assigned to a reviewer, doing nothing")
|
||||
|
||||
# if there's an accepter necessary, assign the merge request
|
||||
if len(request_state.approver_list) >= project.code_reviews_necessary and request_state.needs_accepter:
|
||||
log.debug("%s needs an accepter", merge_request.title)
|
||||
if merge_request.assignee is not None:
|
||||
log.debug("%s currently assigned to %s", merge_request.title, merge_request.assignee.username)
|
||||
|
||||
if merge_request.assignee is None or merge_request.assignee.username not in accept_candidates:
|
||||
if len(accept_candidates) > 0:
|
||||
new_accepter = accept_candidates[merge_request.iid % len(accept_candidates)]
|
||||
log.debug("%s is the new accepter", new_accepter)
|
||||
|
||||
# get the user object for the new accepter
|
||||
new_accepter_obj = self.client.users.get_by_username(new_accepter)
|
||||
|
||||
# create note for the update
|
||||
msg = {'body': self.random_reviews_done_message().format(new_accepter_obj.name)}
|
||||
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
|
||||
merge_request_id=merge_request.id)
|
||||
|
||||
# assign the merge request to the new reviewer
|
||||
self.client.update(merge_request, assignee_id=new_accepter_obj.id)
|
||||
else:
|
||||
log.warning("no accepters left to accept %s, doing nothing", merge_request.title)
|
||||
|
||||
|
||||
class _MergeRequestScanningState(object):
|
||||
|
||||
"""Track the state of a merge request as it is scanned and appropriate action identified."""
|
||||
|
||||
def __init__(self):
|
||||
"""Set default flags/values."""
|
||||
self.approver_list = []
|
||||
|
||||
self.unlogged_review_start = True
|
||||
self.unlogged_approval_reset = False
|
||||
self.unlogged_approval = False
|
||||
self.unlogged_review_complete = False
|
||||
self.needs_reviewer = True
|
||||
self.needs_accepter = False
|
|
@ -1,20 +0,0 @@
|
|||
"""Find merge requests that need code reviewers."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
from gitlab_bot.lib import GitlabBot
|
||||
from gitlab_bot.models import GitlabProjectConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Find merge requests needing code reviewers/accepters"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
bot = GitlabBot()
|
||||
projects = GitlabProjectConfig.objects.filter(manage_merge_request_code_reviews=True)
|
||||
for project in projects:
|
||||
bot.scan_project_for_reviews(project)
|
|
@ -1,25 +0,0 @@
|
|||
"""Run the code review process on a specific merge request."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.management import BaseCommand
|
||||
|
||||
from gitlab_bot.lib import GitlabBot
|
||||
from gitlab_bot.models import GitlabProjectConfig
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Assign code reviewers/accepters for a specific merge request"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('project_id', type=int)
|
||||
parser.add_argument('merge_request_id', type=int)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
project = GitlabProjectConfig.objects.get(pk=options['project_id'])
|
||||
merge_request_ids = [options['merge_request_id'], ]
|
||||
|
||||
bot = GitlabBot()
|
||||
bot.scan_project_for_reviews(project, merge_request_ids=merge_request_ids)
|
|
@ -1,31 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GitlabConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
|
||||
('url', models.URLField()),
|
||||
('token', models.CharField(max_length=64)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GitlabProjectConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
|
||||
('project_id', models.CharField(max_length=64)),
|
||||
('manage_merge_request_code_reviews', models.BooleanField(default=False)),
|
||||
('code_reviews_necessary', models.PositiveSmallIntegerField(default=0)),
|
||||
('code_reviewers', models.TextField(blank=True, default='')),
|
||||
('code_review_final_merge_assignees', models.TextField(blank=True, default='')),
|
||||
('gitlab_config', models.ForeignKey(to='gitlab_bot.GitlabConfig', null=True, on_delete=models.CASCADE)),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -1,17 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('gitlab_bot', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='gitlabprojectconfig',
|
||||
name='project_id',
|
||||
field=models.CharField(unique=True, max_length=64),
|
||||
),
|
||||
]
|
|
@ -1,36 +0,0 @@
|
|||
"""Bot/daemons for doing stuff with GitLab."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.db import models
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GitlabConfig(models.Model):
|
||||
|
||||
"""Maintain bot-wide settings (URL, auth key, etc.)."""
|
||||
|
||||
url = models.URLField()
|
||||
token = models.CharField(max_length=64)
|
||||
|
||||
def __str__(self):
|
||||
"""String representation."""
|
||||
return "bot @ {0:s}".format(self.url)
|
||||
|
||||
|
||||
class GitlabProjectConfig(models.Model):
|
||||
|
||||
"""Maintain settings for a particular project in GitLab."""
|
||||
|
||||
gitlab_config = models.ForeignKey('GitlabConfig', null=True, on_delete=models.CASCADE)
|
||||
project_id = models.CharField(max_length=64, unique=True)
|
||||
|
||||
manage_merge_request_code_reviews = models.BooleanField(default=False)
|
||||
code_reviews_necessary = models.PositiveSmallIntegerField(default=0)
|
||||
code_reviewers = models.TextField(default='', blank=True)
|
||||
code_review_final_merge_assignees = models.TextField(default='', blank=True)
|
||||
|
||||
def __str__(self):
|
||||
"""String representation."""
|
||||
return "configuration for {0:s} @ {1:s}".format(self.project_id, self.gitlab_config.url)
|
223
ircbot/bot.py
223
ircbot/bot.py
|
@ -1,46 +1,36 @@
|
|||
"""Provide the base IRC client bot which other code can latch onto."""
|
||||
|
||||
import bisect
|
||||
import collections
|
||||
import copy
|
||||
import importlib
|
||||
import logging
|
||||
import re
|
||||
from xmlrpc.server import SimpleXMLRPCServer, SimpleXMLRPCRequestHandler
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import irc.buffer
|
||||
import irc.client
|
||||
import irc.modes
|
||||
from irc.bot import Channel
|
||||
from irc.connection import Factory
|
||||
from irc.dict import IRCDict
|
||||
import irc.modes
|
||||
from jaraco.stream import buffer
|
||||
|
||||
import ircbot.lib as ircbotlib
|
||||
from dr_botzo import __version__
|
||||
from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer
|
||||
|
||||
|
||||
log = logging.getLogger('ircbot.bot')
|
||||
|
||||
|
||||
class IrcBotXMLRPCRequestHandler(SimpleXMLRPCRequestHandler):
|
||||
"""Override the basic request handler to change the logging."""
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Use a logger rather than stderr."""
|
||||
log.debug("XML-RPC - %s - %s", self.client_address[0], format % args)
|
||||
|
||||
|
||||
class PrioritizedRegexHandler(collections.namedtuple('Base', ('priority', 'regex', 'callback'))):
|
||||
"""Regex handler that still uses the normal handler priority stuff."""
|
||||
|
||||
def __lt__(self, other):
|
||||
"""When sorting prioritized handlers, only use the priority"""
|
||||
"""When sorting prioritized handlers, only use the priority."""
|
||||
return self.priority < other.priority
|
||||
|
||||
|
||||
|
@ -52,7 +42,7 @@ class LenientServerConnection(irc.client.ServerConnection):
|
|||
method on a Reactor object.
|
||||
"""
|
||||
|
||||
buffer_class = irc.buffer.LenientDecodingLineBuffer
|
||||
buffer_class = buffer.LenientDecodingLineBuffer
|
||||
|
||||
server_config = None
|
||||
|
||||
|
@ -65,6 +55,9 @@ class LenientServerConnection(irc.client.ServerConnection):
|
|||
class DrReactor(irc.client.Reactor):
|
||||
"""Customize the basic IRC library's Reactor with more features."""
|
||||
|
||||
# used by Reactor.server() to initialize
|
||||
connection_class = LenientServerConnection
|
||||
|
||||
def __do_nothing(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
@ -73,18 +66,10 @@ class DrReactor(irc.client.Reactor):
|
|||
super(DrReactor, self).__init__(on_connect=on_connect, on_disconnect=on_disconnect)
|
||||
self.regex_handlers = {}
|
||||
|
||||
def server(self):
|
||||
"""Creates and returns a ServerConnection object."""
|
||||
c = LenientServerConnection(self)
|
||||
with self.mutex:
|
||||
self.connections.append(c)
|
||||
return c
|
||||
|
||||
def add_global_regex_handler(self, events, regex, handler, priority=0):
|
||||
"""Adds a global handler function for a specific event type and regex.
|
||||
"""Add a global handler function for a specific event type and regex.
|
||||
|
||||
Arguments:
|
||||
|
||||
events --- Event type(s) (a list of strings).
|
||||
|
||||
handler -- Callback function taking connection and event
|
||||
|
@ -114,10 +99,9 @@ class DrReactor(irc.client.Reactor):
|
|||
bisect.insort(event_regex_handlers, handler)
|
||||
|
||||
def remove_global_regex_handler(self, events, handler):
|
||||
"""Removes a global regex handler function.
|
||||
"""Remove a global regex handler function.
|
||||
|
||||
Arguments:
|
||||
|
||||
events -- Event type(s) (a list of strings).
|
||||
handler -- Callback function.
|
||||
|
||||
|
@ -178,6 +162,7 @@ class DrReactor(irc.client.Reactor):
|
|||
if sender_nick == channel.discord_bridge:
|
||||
short_what = ' '.join(what.split(' ')[1:])
|
||||
match = re.match(addressed_pattern, short_what, re.IGNORECASE)
|
||||
event.arguments[0] = short_what
|
||||
else:
|
||||
match = re.match(addressed_pattern, what, re.IGNORECASE)
|
||||
except IrcChannel.DoesNotExist:
|
||||
|
@ -228,8 +213,7 @@ class DrReactor(irc.client.Reactor):
|
|||
if result == "NO MORE":
|
||||
return
|
||||
except Exception as ex:
|
||||
log.error("caught exception!")
|
||||
log.exception(ex)
|
||||
log.exception("caught exception!")
|
||||
connection.privmsg(event.target, str(ex))
|
||||
|
||||
def try_recursion(self, connection, event):
|
||||
|
@ -416,7 +400,7 @@ class IRCBot(irc.client.SimpleIRCClient):
|
|||
|
||||
# load XML-RPC server
|
||||
self.xmlrpc = SimpleXMLRPCServer((self.server_config.xmlrpc_host, self.server_config.xmlrpc_port),
|
||||
requestHandler=IrcBotXMLRPCRequestHandler, allow_none=True)
|
||||
requestHandler=SimpleXMLRPCRequestHandler, allow_none=True)
|
||||
self.xmlrpc.register_introspection_functions()
|
||||
|
||||
t = threading.Thread(target=self._xmlrpc_listen, args=())
|
||||
|
@ -429,8 +413,7 @@ class IRCBot(irc.client.SimpleIRCClient):
|
|||
|
||||
def _connected_checker(self):
|
||||
if not self.connection.is_connected():
|
||||
self.connection.execute_delayed(self.reconnection_interval,
|
||||
self._connected_checker)
|
||||
self.reactor.scheduler.execute_after(self.reconnection_interval, self._connected_checker)
|
||||
self.jump_server()
|
||||
|
||||
def _connect(self):
|
||||
|
@ -448,8 +431,7 @@ class IRCBot(irc.client.SimpleIRCClient):
|
|||
|
||||
def _on_disconnect(self, c, e):
|
||||
self.channels = IRCDict()
|
||||
self.connection.execute_delayed(self.reconnection_interval,
|
||||
self._connected_checker)
|
||||
self.reactor.scheduler.execute_after(self.reconnection_interval, self._connected_checker)
|
||||
|
||||
def _on_join(self, c, e):
|
||||
ch = e.target
|
||||
|
@ -558,7 +540,7 @@ class IRCBot(irc.client.SimpleIRCClient):
|
|||
|
||||
for chan in IrcChannel.objects.filter(autojoin=True, server=connection.server_config):
|
||||
log.info("autojoining %s", chan.name)
|
||||
self.connection.join(chan)
|
||||
self.connection.join(chan.name)
|
||||
|
||||
for plugin in IrcPlugin.objects.filter(autoload=True):
|
||||
log.info("autoloading %s", plugin.path)
|
||||
|
@ -592,12 +574,11 @@ class IRCBot(irc.client.SimpleIRCClient):
|
|||
self.connection.disconnect(msg)
|
||||
|
||||
def get_version(self):
|
||||
"""Returns the bot version.
|
||||
"""Return the bot version.
|
||||
|
||||
Used when answering a CTCP VERSION request.
|
||||
"""
|
||||
return "Python irc.bot ({version})".format(
|
||||
version=irc.client.VERSION_STRING)
|
||||
return f"dr.botzo {__version__}"
|
||||
|
||||
def jump_server(self, msg="Changing servers"):
|
||||
"""Connect to a new server, potentially disconnecting from the current one."""
|
||||
|
@ -607,7 +588,7 @@ class IRCBot(irc.client.SimpleIRCClient):
|
|||
self._connect()
|
||||
|
||||
def on_ctcp(self, c, e):
|
||||
"""Default handler for ctcp events.
|
||||
"""Handle for ctcp events.
|
||||
|
||||
Replies to VERSION and PING requests and relays DCC requests
|
||||
to the on_dccchat method.
|
||||
|
@ -997,165 +978,3 @@ class IRCBot(irc.client.SimpleIRCClient):
|
|||
del sys.modules[path]
|
||||
|
||||
self.die(msg="Shutting down...")
|
||||
|
||||
|
||||
class Channel(object):
|
||||
"""A class for keeping information about an IRC channel."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize channel object."""
|
||||
self.userdict = IRCDict()
|
||||
self.operdict = IRCDict()
|
||||
self.voiceddict = IRCDict()
|
||||
self.ownerdict = IRCDict()
|
||||
self.halfopdict = IRCDict()
|
||||
self.modes = {}
|
||||
|
||||
def users(self):
|
||||
"""Returns an unsorted list of the channel's users."""
|
||||
return list(self.userdict.keys())
|
||||
|
||||
def opers(self):
|
||||
"""Returns an unsorted list of the channel's operators."""
|
||||
return list(self.operdict.keys())
|
||||
|
||||
def voiced(self):
|
||||
"""Returns an unsorted list of the persons that have voice mode set in the channel."""
|
||||
return list(self.voiceddict.keys())
|
||||
|
||||
def owners(self):
|
||||
"""Returns an unsorted list of the channel's owners."""
|
||||
return list(self.ownerdict.keys())
|
||||
|
||||
def halfops(self):
|
||||
"""Returns an unsorted list of the channel's half-operators."""
|
||||
return list(self.halfopdict.keys())
|
||||
|
||||
def has_user(self, nick):
|
||||
"""Check whether the channel has a user."""
|
||||
return nick in self.userdict
|
||||
|
||||
def is_oper(self, nick):
|
||||
"""Check whether a user has operator status in the channel."""
|
||||
return nick in self.operdict
|
||||
|
||||
def is_voiced(self, nick):
|
||||
"""Check whether a user has voice mode set in the channel."""
|
||||
return nick in self.voiceddict
|
||||
|
||||
def is_owner(self, nick):
|
||||
"""Check whether a user has owner status in the channel."""
|
||||
return nick in self.ownerdict
|
||||
|
||||
def is_halfop(self, nick):
|
||||
"""Check whether a user has half-operator status in the channel."""
|
||||
return nick in self.halfopdict
|
||||
|
||||
def add_user(self, nick):
|
||||
"""Add user."""
|
||||
self.userdict[nick] = 1
|
||||
|
||||
def remove_user(self, nick):
|
||||
"""Remove user."""
|
||||
for d in self.userdict, self.operdict, self.voiceddict:
|
||||
if nick in d:
|
||||
del d[nick]
|
||||
|
||||
def change_nick(self, before, after):
|
||||
"""Handle a nick change."""
|
||||
self.userdict[after] = self.userdict.pop(before)
|
||||
if before in self.operdict:
|
||||
self.operdict[after] = self.operdict.pop(before)
|
||||
if before in self.voiceddict:
|
||||
self.voiceddict[after] = self.voiceddict.pop(before)
|
||||
|
||||
def set_userdetails(self, nick, details):
|
||||
"""Set user details."""
|
||||
if nick in self.userdict:
|
||||
self.userdict[nick] = details
|
||||
|
||||
def set_mode(self, mode, value=None):
|
||||
"""Set mode on the channel.
|
||||
|
||||
Arguments:
|
||||
|
||||
mode -- The mode (a single-character string).
|
||||
|
||||
value -- Value
|
||||
"""
|
||||
if mode == "o":
|
||||
self.operdict[value] = 1
|
||||
elif mode == "v":
|
||||
self.voiceddict[value] = 1
|
||||
elif mode == "q":
|
||||
self.ownerdict[value] = 1
|
||||
elif mode == "h":
|
||||
self.halfopdict[value] = 1
|
||||
else:
|
||||
self.modes[mode] = value
|
||||
|
||||
def clear_mode(self, mode, value=None):
|
||||
"""Clear mode on the channel.
|
||||
|
||||
Arguments:
|
||||
|
||||
mode -- The mode (a single-character string).
|
||||
|
||||
value -- Value
|
||||
"""
|
||||
try:
|
||||
if mode == "o":
|
||||
del self.operdict[value]
|
||||
elif mode == "v":
|
||||
del self.voiceddict[value]
|
||||
elif mode == "q":
|
||||
del self.ownerdict[value]
|
||||
elif mode == "h":
|
||||
del self.halfopdict[value]
|
||||
else:
|
||||
del self.modes[mode]
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def has_mode(self, mode):
|
||||
"""Return if mode is in channel modes."""
|
||||
return mode in self.modes
|
||||
|
||||
def is_moderated(self):
|
||||
"""Return if the channel is +m."""
|
||||
return self.has_mode("m")
|
||||
|
||||
def is_secret(self):
|
||||
"""Return if the channel is +s."""
|
||||
return self.has_mode("s")
|
||||
|
||||
def is_protected(self):
|
||||
"""Return if the channel is +p."""
|
||||
return self.has_mode("p")
|
||||
|
||||
def has_topic_lock(self):
|
||||
"""Return if the channel is +t."""
|
||||
return self.has_mode("t")
|
||||
|
||||
def is_invite_only(self):
|
||||
"""Return if the channel is +i."""
|
||||
return self.has_mode("i")
|
||||
|
||||
def has_allow_external_messages(self):
|
||||
"""Return if the channel is +n."""
|
||||
return self.has_mode("n")
|
||||
|
||||
def has_limit(self):
|
||||
"""Return if the channel is +l."""
|
||||
return self.has_mode("l")
|
||||
|
||||
def limit(self):
|
||||
"""Return the channel limit count."""
|
||||
if self.has_limit():
|
||||
return self.modes["l"]
|
||||
else:
|
||||
return None
|
||||
|
||||
def has_key(self):
|
||||
"""Return if the channel is +k."""
|
||||
return self.has_mode("k")
|
||||
|
|
|
@ -118,7 +118,7 @@ class IrcChannel(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
"""Provide string representation."""
|
||||
return "{0:s}".format(self.name)
|
||||
return "{0:s} on {1:s}".format(self.name, self.server.name)
|
||||
|
||||
|
||||
class IrcPlugin(models.Model):
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""URL patterns for the karma views."""
|
||||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
from django.urls import path, re_path
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from karma.views import KarmaKeyViewSet, index, key_detail
|
||||
|
@ -10,7 +10,7 @@ router.register(r'keys', KarmaKeyViewSet)
|
|||
|
||||
urlpatterns = [
|
||||
path('', index, name='karma_index'),
|
||||
path('key/<karma_key>/', key_detail, name='karma_key_detail'),
|
||||
re_path(r'^key/(?P<karma_key>.+)/', key_detail, name='karma_key_detail'),
|
||||
|
||||
path('api/', include(router.urls)),
|
||||
]
|
||||
|
|
|
@ -1,27 +1,32 @@
|
|||
"""Present karma data."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
from rest_framework import viewsets
|
||||
|
||||
from karma.models import KarmaKey
|
||||
from karma.serializers import KarmaKeySerializer
|
||||
|
||||
log = logging.getLogger('karma.views')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def index(request):
|
||||
"""Display all karma keys."""
|
||||
entries = KarmaKey.objects.all().order_by('key')
|
||||
if 'karma' not in settings.WEB_ENABLED_APPS:
|
||||
raise PermissionDenied()
|
||||
|
||||
entries = KarmaKey.objects.all().order_by('key')
|
||||
return render(request, 'karma/index.html', {'entries': entries})
|
||||
|
||||
|
||||
def key_detail(request, karma_key):
|
||||
"""Display the requested karma key."""
|
||||
entry = get_object_or_404(KarmaKey, key=karma_key.lower())
|
||||
if 'karma' not in settings.WEB_ENABLED_APPS:
|
||||
raise PermissionDenied()
|
||||
|
||||
entry = get_object_or_404(KarmaKey, key=karma_key.lower())
|
||||
return render(request, 'karma/karma_key.html', {'entry': entry, 'entry_history': entry.history(mode='date')})
|
||||
|
||||
|
||||
|
|
|
@ -119,12 +119,12 @@ class Markov(Plugin):
|
|||
target_name = target_name.lower()
|
||||
|
||||
# find the stuff, or create it
|
||||
channel, c = IrcChannel.objects.get_or_create(name=target_name, server=self.connection.server_config)
|
||||
try:
|
||||
target = MarkovTarget.objects.get(name=target_name)
|
||||
target = MarkovTarget.objects.get(channel=channel)
|
||||
except MarkovTarget.DoesNotExist:
|
||||
# we need to create a context and a target, and we have to make the context first
|
||||
# make a context --- lacking a good idea, just create one with this target name until configured otherwise
|
||||
channel, c = IrcChannel.objects.get_or_create(name=target_name, server=self.connection.server_config)
|
||||
context, c = MarkovContext.objects.get_or_create(name=target_name)
|
||||
target, c = MarkovTarget.objects.get_or_create(name=target_name, context=context, channel=channel)
|
||||
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 3.2.18 on 2023-05-04 22:17
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('markov', '0007_alter_markovtarget_channel'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='markovtarget',
|
||||
name='name',
|
||||
field=models.CharField(max_length=200),
|
||||
),
|
||||
]
|
|
@ -21,7 +21,7 @@ class MarkovContext(models.Model):
|
|||
class MarkovTarget(models.Model):
|
||||
"""Define IRC targets that relate to a context, and can occasionally be talked to."""
|
||||
|
||||
name = models.CharField(max_length=200, unique=True)
|
||||
name = models.CharField(max_length=200)
|
||||
context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE)
|
||||
channel = models.ForeignKey(IrcChannel, on_delete=models.CASCADE)
|
||||
|
||||
|
@ -29,7 +29,7 @@ class MarkovTarget(models.Model):
|
|||
|
||||
def __str__(self):
|
||||
"""Provide string representation."""
|
||||
return "{0:s} -> {1:s}".format(self.name, self.context.name)
|
||||
return "{0:s} -> {1:s}".format(str(self.channel), self.context.name)
|
||||
|
||||
|
||||
class MarkovState(models.Model):
|
||||
|
|
|
@ -1,26 +1,28 @@
|
|||
"""Display race statuses and whatnot."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
|
||||
from races.models import Race, Racer, RaceUpdate
|
||||
|
||||
|
||||
log = logging.getLogger('races.views')
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def index(request):
|
||||
"""Display a list of races."""
|
||||
if 'races' not in settings.WEB_ENABLED_APPS:
|
||||
raise PermissionDenied()
|
||||
|
||||
races = Race.objects.all()
|
||||
|
||||
return render(request, 'races/index.html', {'races': races})
|
||||
|
||||
|
||||
def race_detail(request, race_id):
|
||||
"""Display a race detail."""
|
||||
if 'races' not in settings.WEB_ENABLED_APPS:
|
||||
raise PermissionDenied()
|
||||
|
||||
race = get_object_or_404(Race, pk=race_id)
|
||||
|
||||
return render(request, 'races/race_detail.html', {'race': race})
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.8
|
||||
# This file is autogenerated by pip-compile with Python 3.10
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in
|
||||
|
@ -22,7 +22,7 @@ click==8.1.3
|
|||
# via
|
||||
# pip-tools
|
||||
# safety
|
||||
coverage[toml]==7.1.0
|
||||
coverage[toml]==7.2.1
|
||||
# via pytest-cov
|
||||
distlib==0.3.6
|
||||
# via virtualenv
|
||||
|
@ -77,19 +77,15 @@ flake8-mutable==1.2.0
|
|||
# via -r requirements/requirements-dev.in
|
||||
gitdb==4.0.10
|
||||
# via gitpython
|
||||
gitpython==3.1.30
|
||||
gitpython==3.1.31
|
||||
# via bandit
|
||||
idna==3.4
|
||||
# via requests
|
||||
importlib-resources==5.10.2
|
||||
# via jaraco-text
|
||||
inflect==6.0.2
|
||||
# via
|
||||
# jaraco-itertools
|
||||
# jaraco-text
|
||||
# via jaraco-text
|
||||
iniconfig==2.0.0
|
||||
# via pytest
|
||||
irc==15.0.6
|
||||
irc==20.1.0
|
||||
# via -r requirements/requirements.in
|
||||
isort==5.12.0
|
||||
# via flake8-isort
|
||||
|
@ -99,13 +95,11 @@ jaraco-collections==3.8.0
|
|||
# via irc
|
||||
jaraco-context==4.3.0
|
||||
# via jaraco-text
|
||||
jaraco-functools==3.5.2
|
||||
jaraco-functools==3.6.0
|
||||
# via
|
||||
# irc
|
||||
# jaraco-text
|
||||
# tempora
|
||||
jaraco-itertools==6.2.1
|
||||
# via irc
|
||||
jaraco-logging==3.1.2
|
||||
# via irc
|
||||
jaraco-stream==3.0.3
|
||||
|
@ -116,12 +110,11 @@ jaraco-text==3.11.1
|
|||
# jaraco-collections
|
||||
mccabe==0.7.0
|
||||
# via flake8
|
||||
more-itertools==9.0.0
|
||||
more-itertools==9.1.0
|
||||
# via
|
||||
# irc
|
||||
# jaraco-classes
|
||||
# jaraco-functools
|
||||
# jaraco-itertools
|
||||
# jaraco-text
|
||||
packaging==21.3
|
||||
# via
|
||||
|
@ -134,7 +127,7 @@ parsedatetime==2.6
|
|||
# via -r requirements/requirements.in
|
||||
pbr==5.11.1
|
||||
# via stevedore
|
||||
pip-tools==6.12.2
|
||||
pip-tools==6.12.3
|
||||
# via -r requirements/requirements-dev.in
|
||||
platformdirs==3.0.0
|
||||
# via virtualenv
|
||||
|
@ -169,8 +162,6 @@ pytest-django==4.5.2
|
|||
# via -r requirements/requirements-dev.in
|
||||
python-dateutil==2.8.2
|
||||
# via -r requirements/requirements.in
|
||||
python-gitlab==3.13.0
|
||||
# via -r requirements/requirements.in
|
||||
python-mpd2==3.0.5
|
||||
# via -r requirements/requirements.in
|
||||
pytz==2022.7.1
|
||||
|
@ -183,12 +174,7 @@ pytz==2022.7.1
|
|||
pyyaml==6.0
|
||||
# via bandit
|
||||
requests==2.28.2
|
||||
# via
|
||||
# python-gitlab
|
||||
# requests-toolbelt
|
||||
# safety
|
||||
requests-toolbelt==0.10.1
|
||||
# via python-gitlab
|
||||
# via safety
|
||||
ruamel-yaml==0.17.21
|
||||
# via safety
|
||||
ruamel-yaml-clib==0.2.7
|
||||
|
@ -197,7 +183,6 @@ safety==2.3.5
|
|||
# via -r requirements/requirements-dev.in
|
||||
six==1.16.0
|
||||
# via
|
||||
# irc
|
||||
# python-dateutil
|
||||
# tox
|
||||
smmap==5.0.0
|
||||
|
@ -233,7 +218,7 @@ urllib3==1.26.14
|
|||
# via requests
|
||||
versioneer==0.28
|
||||
# via -r requirements/requirements-dev.in
|
||||
virtualenv==20.19.0
|
||||
virtualenv==20.20.0
|
||||
# via tox
|
||||
wheel==0.38.4
|
||||
# via
|
||||
|
@ -241,8 +226,6 @@ wheel==0.38.4
|
|||
# tox-wheel
|
||||
zalgo-text==0.6
|
||||
# via -r requirements/requirements.in
|
||||
zipp==3.13.0
|
||||
# via importlib-resources
|
||||
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
# pip
|
||||
|
|
|
@ -2,12 +2,11 @@ Django<4.0 # core
|
|||
django-adminplus # admin.site.register_view
|
||||
django-bootstrap3 # bootstrap layout
|
||||
django-extensions # more commands
|
||||
djangorestframework # dispatch WS API
|
||||
irc==15.0.6 # core, pinned until I can bother to update --- 17.x has API changes
|
||||
djangorestframework # WS API
|
||||
irc # core
|
||||
parsedatetime # relative date stuff in countdown
|
||||
ply # dice lex/yacc compiler
|
||||
python-dateutil # countdown relative math
|
||||
python-gitlab # client for the gitlab bot
|
||||
python-mpd2 # client for mpd
|
||||
pytz # timezone awareness
|
||||
zalgo-text # zalgoify text
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
#
|
||||
# This file is autogenerated by pip-compile with Python 3.8
|
||||
# This file is autogenerated by pip-compile with Python 3.10
|
||||
# by the following command:
|
||||
#
|
||||
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
|
||||
|
@ -8,10 +8,6 @@ asgiref==3.6.0
|
|||
# via django
|
||||
autocommand==2.2.2
|
||||
# via jaraco-text
|
||||
certifi==2022.12.7
|
||||
# via requests
|
||||
charset-normalizer==3.0.1
|
||||
# via requests
|
||||
django==3.2.18
|
||||
# via
|
||||
# -r requirements/requirements.in
|
||||
|
@ -26,15 +22,9 @@ django-extensions==3.2.1
|
|||
# via -r requirements/requirements.in
|
||||
djangorestframework==3.14.0
|
||||
# via -r requirements/requirements.in
|
||||
idna==3.4
|
||||
# via requests
|
||||
importlib-resources==5.10.2
|
||||
# via jaraco-text
|
||||
inflect==6.0.2
|
||||
# via
|
||||
# jaraco-itertools
|
||||
# jaraco-text
|
||||
irc==15.0.6
|
||||
# via jaraco-text
|
||||
irc==20.1.0
|
||||
# via -r requirements/requirements.in
|
||||
jaraco-classes==3.2.3
|
||||
# via jaraco-collections
|
||||
|
@ -42,13 +32,11 @@ jaraco-collections==3.8.0
|
|||
# via irc
|
||||
jaraco-context==4.3.0
|
||||
# via jaraco-text
|
||||
jaraco-functools==3.5.2
|
||||
jaraco-functools==3.6.0
|
||||
# via
|
||||
# irc
|
||||
# jaraco-text
|
||||
# tempora
|
||||
jaraco-itertools==6.2.1
|
||||
# via irc
|
||||
jaraco-logging==3.1.2
|
||||
# via irc
|
||||
jaraco-stream==3.0.3
|
||||
|
@ -57,12 +45,11 @@ jaraco-text==3.11.1
|
|||
# via
|
||||
# irc
|
||||
# jaraco-collections
|
||||
more-itertools==9.0.0
|
||||
more-itertools==9.1.0
|
||||
# via
|
||||
# irc
|
||||
# jaraco-classes
|
||||
# jaraco-functools
|
||||
# jaraco-itertools
|
||||
# jaraco-text
|
||||
parsedatetime==2.6
|
||||
# via -r requirements/requirements.in
|
||||
|
@ -72,8 +59,6 @@ pydantic==1.10.5
|
|||
# via inflect
|
||||
python-dateutil==2.8.2
|
||||
# via -r requirements/requirements.in
|
||||
python-gitlab==3.13.0
|
||||
# via -r requirements/requirements.in
|
||||
python-mpd2==3.0.5
|
||||
# via -r requirements/requirements.in
|
||||
pytz==2022.7.1
|
||||
|
@ -83,16 +68,8 @@ pytz==2022.7.1
|
|||
# djangorestframework
|
||||
# irc
|
||||
# tempora
|
||||
requests==2.28.2
|
||||
# via
|
||||
# python-gitlab
|
||||
# requests-toolbelt
|
||||
requests-toolbelt==0.10.1
|
||||
# via python-gitlab
|
||||
six==1.16.0
|
||||
# via
|
||||
# irc
|
||||
# python-dateutil
|
||||
# via python-dateutil
|
||||
sqlparse==0.4.3
|
||||
# via django
|
||||
tempora==5.2.1
|
||||
|
@ -101,9 +78,5 @@ tempora==5.2.1
|
|||
# jaraco-logging
|
||||
typing-extensions==4.5.0
|
||||
# via pydantic
|
||||
urllib3==1.26.14
|
||||
# via requests
|
||||
zalgo-text==0.6
|
||||
# via -r requirements/requirements.in
|
||||
zipp==3.13.0
|
||||
# via importlib-resources
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""Test the dispatch package's webservice."""
|
||||
from django.contrib.auth.models import User
|
||||
from rest_framework.status import HTTP_200_OK
|
||||
from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from dispatch.models import Dispatcher, DispatcherAction
|
||||
|
@ -27,3 +27,18 @@ class DispatchAPITest(APITestCase):
|
|||
resp = self.client.get('/dispatch/api/actions/')
|
||||
self.assertEqual(resp.status_code, HTTP_200_OK)
|
||||
self.assertEqual(len(resp.json()), DispatcherAction.objects.count())
|
||||
|
||||
def test_unauthed_dispatch_object_retrieval(self):
|
||||
"""Test that the list endpoints require authentication."""
|
||||
client = self.client_class()
|
||||
resp = client.get('/dispatch/api/dispatchers/')
|
||||
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
|
||||
resp = client.get('/dispatch/api/dispatchers/111/')
|
||||
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
|
||||
resp = client.get('/dispatch/api/dispatchers/fake/')
|
||||
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
|
||||
|
||||
resp = client.get('/dispatch/api/actions/')
|
||||
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
|
||||
resp = client.get('/dispatch/api/actions/111/')
|
||||
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
"""Test the race views."""
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.utils.timezone import now
|
||||
|
||||
from races.models import Race, Racer, RaceUpdate
|
||||
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
"""Test views and templates' adherence to WEB_ENABLED_APPS."""
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
class WebEnabledAppsAPITest(TestCase):
|
||||
"""Test that certain display elements and views can be enabled/disabled via settings."""
|
||||
|
||||
def setUp(self):
|
||||
"""Store the old setting so that we can restore it later."""
|
||||
self.old_sites_list = settings.WEB_ENABLED_APPS
|
||||
print("butt")
|
||||
|
||||
def tearDown(self):
|
||||
"""Restore the old setting stored earlier."""
|
||||
settings.WEB_ENABLED_APPS = self.old_sites_list
|
||||
print("butt")
|
||||
|
||||
def test_default_enabled(self):
|
||||
"""Test that the expected sites can be reached and displayed by default."""
|
||||
resp = self.client.get('/karma/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b'<a href="/karma/">', resp.content)
|
||||
resp = self.client.get('/races/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b'<a href="/races/">', resp.content)
|
||||
|
||||
def test_one_disabled(self):
|
||||
"""Test that we can disable one site but not all sites using this setting."""
|
||||
settings.WEB_ENABLED_APPS = ['karma']
|
||||
resp = self.client.get('/karma/')
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertIn(b'<a href="/karma/">', resp.content)
|
||||
resp = self.client.get('/races/')
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertNotIn(b'<a href="/races/">', resp.content)
|
||||
|
||||
def test_all_disabled(self):
|
||||
"""Test that we can disable all sites using this setting."""
|
||||
settings.WEB_ENABLED_APPS = []
|
||||
resp = self.client.get('/karma/')
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertNotIn(b'<a href="/karma/">', resp.content)
|
||||
resp = self.client.get('/races/')
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
self.assertNotIn(b'<a href="/races/">', resp.content)
|
2
tox.ini
2
tox.ini
|
@ -181,7 +181,7 @@ exclude =
|
|||
.tox/
|
||||
versioneer.py
|
||||
_version.py
|
||||
instance/
|
||||
**/migrations/
|
||||
extend-ignore = T101
|
||||
max-complexity = 10
|
||||
max-line-length = 120
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
"""Report on the weather via wttr.in."""
|
||||
import logging
|
||||
|
||||
from ircbot.lib import Plugin
|
||||
|
||||
from weather.lib import weather_summary
|
||||
|
||||
|
||||
log = logging.getLogger('weather.ircplugin')
|
||||
|
||||
|
||||
class Weather(Plugin):
|
||||
|
||||
"""Have IRC commands to do IRC things (join channels, quit, etc.)."""
|
||||
|
||||
def start(self):
|
||||
"""Set up the handlers."""
|
||||
|
||||
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!weather\s+(.*)$',
|
||||
self.handle_weather, -20)
|
||||
|
||||
|
@ -22,12 +19,12 @@ class Weather(Plugin):
|
|||
|
||||
def stop(self):
|
||||
"""Tear down handlers."""
|
||||
|
||||
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_weather)
|
||||
|
||||
super(Weather, self).stop()
|
||||
|
||||
def handle_weather(self, connection, event, match):
|
||||
"""Make the weather query and format it for IRC."""
|
||||
query = match.group(1)
|
||||
queryitems = query.split(" ")
|
||||
if len(queryitems) <= 0:
|
||||
|
|
Loading…
Reference in New Issue