├── .gitignore ├── AUTHORS ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Procfile ├── README.rst ├── app.json ├── aws ├── __init__.py ├── admin.py ├── apps.py ├── handlers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_task_environment.py │ ├── 0003_non_critical_tasks.py │ ├── 0004_add_overrides.py │ ├── 0005_add_pending_timestamp.py │ ├── 0006_add_task_error.py │ ├── 0007_add_profile.py │ ├── 0008_add_task_profiles.py │ ├── 0009_add_timeout.py │ ├── 0010_add_instance_limits.py │ ├── 0011_clarify_profile.py │ ├── 0012_rename_pending.py │ ├── 0013_add_spot.py │ ├── 0014_preferred_instances.py │ ├── 0015_auto_20180827_1042.py │ ├── 0016_remove_task_descriptor.py │ └── __init__.py ├── models.py ├── task_urls.py ├── tasks.py ├── tests.py ├── urls.py └── views.py ├── beekeeper ├── __init__.py ├── __main__.py ├── config.py ├── runner.py ├── utils.py └── views.py ├── config ├── __init__.py ├── celery.py ├── settings.py ├── urls.py └── wsgi.py ├── docker-compose.yml.example ├── docs ├── Makefile ├── _static │ └── logo.png ├── conf.py ├── index.rst ├── make.bat └── sample.env ├── github ├── __init__.py ├── admin.py ├── apps.py ├── hooks.py ├── management │ ├── __init__.py │ └── commands │ │ ├── __init__.py │ │ └── replay.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_tweak_ordering.py │ ├── 0003_add_branches.py │ └── __init__.py ├── models.py ├── signals.py ├── tests │ ├── __init__.py │ ├── create_repo │ │ ├── 0001.pull_request.Open PR 1.json │ │ └── 0002.ping.Create briefcase repo.json │ ├── replay │ │ ├── 0001.pull_request.Open PR 1.json │ │ ├── 0002.pull_request.Update PR 1.json │ │ ├── 0003.pull_request.Open PR 2.json │ │ ├── 0004.pull_request.Close PR 2.json │ │ ├── 0005.push.Merge PR 2.json │ │ ├── 0006.pull_request.Update PR 1.json │ │ ├── 0007.push.Single commit to master.json │ │ ├── 0008.push.Multiple commits to master.json │ │ ├── 0009.push.Commit to branch.json │ │ ├── 0010.pull_request.Merge and update PR1.json │ │ ├── 0011.pull_request.Correct config error.json │ │ ├── 0012.pull_request.Rename task descriptor.json │ │ ├── 0013.pull_request.Include beefore.json │ │ └── 0014.pull_request.actual_test_content.json │ └── test_hooks.py ├── urls.py └── views.py ├── manage.py ├── projects ├── __init__.py ├── admin.py ├── apps.py ├── handlers.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_tweak_model_ordering.py │ ├── 0003_build_error.py │ ├── 0004_non_critical_tasks.py │ ├── 0005_add_variables.py │ ├── 0006_add_global_variables.py │ ├── 0007_descriptor_not_task_name.py │ ├── 0008_rename_variables.py │ ├── 0009_add_task_profiles.py │ └── __init__.py ├── models.py ├── signals.py ├── templatetags │ ├── __init__.py │ └── build_status.py ├── urls.py └── views.py ├── requirements.txt ├── runtime.txt ├── setup.py ├── static └── css │ └── beekeeper.css ├── templates ├── admin │ └── base_site.html ├── aws │ └── current_tasks.html ├── base.html ├── home.html ├── projects │ ├── build.html │ ├── change.html │ ├── project.html │ ├── shields │ │ ├── fail.svg │ │ ├── non_critical_fail.svg │ │ ├── pass.svg │ │ └── unknown.svg │ └── task.html └── registration │ └── login.html ├── wait-for-it.sh └── worker.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | Pipfile 27 | Pipfile.lock 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *,cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | postgres/ 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # IPython Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | 91 | # Rope project settings 92 | .ropeproject 93 | 94 | # SQlite3 database 95 | db.sqlite3 96 | 97 | # local data 98 | local/ 99 | 100 | # runtime data 101 | runtime/ 102 | 103 | # active docker-compose 104 | docker-compose.yml 105 | 106 | # Active static files 107 | config/staticfiles/ 108 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | BeeKeeper was originally created in August 2015. 2 | 3 | The PRIMARY AUTHORS are (and/or have been): 4 | Russell Keith-Magee 5 | 6 | And here is an inevitably incomplete list of MUCH-APPRECIATED CONTRIBUTORS -- 7 | people who have submitted patches, reported bugs, added translations, helped 8 | answer newbie questions, and generally made BeeKeeper that much better: 9 | 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | PyBee <3's contributions! 4 | 5 | Please be aware, PyBee operates under a Code of Conduct. 6 | 7 | See [CONTRIBUTING to PyBee](http://pybee.org/contributing) for details. 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | ENV DJANGO_ENV dev 5 | ENV DOCKER_CONTAINER 1 6 | 7 | COPY ./requirements.txt /code/requirements.txt 8 | RUN pip install -r /code/requirements.txt 9 | 10 | COPY . /code/ 11 | WORKDIR /code/ 12 | 13 | EXPOSE 8000 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Russell Keith-Magee. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | 2. Redistributions in binary form must reproduce the above copyright 11 | notice, this list of conditions and the following disclaimer in the 12 | documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of BeeKeeper nor the names of its contributors may 15 | be used to endorse or promote products derived from this software without 16 | specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include CONTRIBUTING.md 3 | include LICENSE 4 | recursive-include beekeeper *.py -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn config.wsgi 2 | worker: celery worker -c 2 -A config --loglevel=INFO -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | THIS PROJECT HAS BEEN ARCHIVED 2 | ============================== 3 | 4 | **The BeeWare Project has stopped using BeeKeeper for CI; we have migrated all our CI to Github Actions.** 5 | 6 | BeeKeeper 7 | ========= 8 | 9 | 10 | 11 | BeeKeeper is a tool for running Docker containers on demand, 12 | with GitHub status reporting. As a side effect, this makes it very 13 | effective as an automated code review tool, a CI system, 14 | an automated deployment system, and many other things. 15 | 16 | Getting started 17 | --------------- 18 | 19 | |heroku| 20 | 21 | To deploy a new BeeKeeper instance, clone this repo, and then run:: 22 | 23 | $ heroku create 24 | $ git push heroku master 25 | $ heroku addons:create heroku-postgresql:hobby-dev 26 | $ heroku addons:create heroku-redis:hobby-dev 27 | $ heroku config:set BEEKEEPER_URL=https:// 28 | $ heroku scale worker=1 29 | $ heroku run ./manage.py migrate 30 | $ heroku run ./manage.py createsuperuser 31 | 32 | Local Development 33 | ~~~~~~~~~~~~~~~~~ 34 | 35 | To get started developing on BeeKeeper locally, use `docker-compose`. 36 | Unfortunately, these steps have only been tested on MacOS. 37 | 38 | - Install `docker-compose`_ 39 | - Clone the beekeeper repo. 40 | - Create your own config: :code:`cp docker-compose.yml.example docker-compose.yml` 41 | - In the beekeeper repo directory: :code:`docker-compose up` 42 | - You should be able to browse to http://localhost:8000 and see beekeeper working! 43 | 44 | To get an admin user: 45 | 46 | - :code:`ctrl-c` to quit the server 47 | - :code:`docker-compose run web python manage.py createsuperuser` 48 | 49 | You can also change the :code:`docker-compose.yml` to use a local :code:`.env` 50 | instead of :code:`docs/sample.env`, and add your own secret variables 51 | 52 | Django 53 | ~~~~~~ 54 | 55 | Configure a `SECRET_KEY` for the Django instance:: 56 | 57 | >>> from django.core.management.utils import get_random_secret_key 58 | >>> get_random_secret_key() 59 | 'qn219x$#ox1$+(m4hdzi-q+5g&o9^7)(x5l8^y51+rrcvs*o3-' 60 | 61 | Then set a `SECRET_KEY` configuration variable in your Heroku instance, and 62 | put:: 63 | 64 | SECRET_KEY= 65 | 66 | in the .env file in the project home directory. 67 | 68 | Sendgrid 69 | ~~~~~~~~ 70 | 71 | Sign up for a Sendgrid account, then get an API key. Create a 72 | `SENDGRID_API_KEY` configuration value on Heroku, and put:: 73 | 74 | SENDGRID_API_KEY= 75 | 76 | In the .env file in the project home directory. 77 | 78 | Amazon AWS 79 | ~~~~~~~~~~ 80 | 81 | Log into (or sign up for) your Amazon AWS account, and obtain an access key 82 | and secret access key. Create the `AWS_ACCESS_KEY_ID`, `AWS_REGION` and 83 | `AWS_SECRET_ACCESS_KEY` Heroku configuration variables, and put:: 84 | 85 | AWS_ACCESS_KEY_ID= 86 | AWS_REGION= 87 | AWS_SECRET_ACCESS_KEY= 88 | 89 | In the .env file in the project home directory. 90 | 91 | Go to the ECS panel and create an EC2 cluster in an AWS 92 | region of your choice. If you create an "empty" cluster, BeeKeeper 93 | will spin up new instances whenever build tasks are submitted. If you 94 | create a non-empty cluster, those resources will be permanently 95 | available for builds - but you'll also be paying for that availability. 96 | 97 | Once you've created your cluster, set the `AWC_EC2_KEY_PAIR_NAME`, 98 | `AWS_ECS_AMI`, `AWS_ECS_CLUSTER_NAME`, `AWS_ECS_SUBNET_ID` and 99 | `AWS_ECS_SECURITY_GROUP_IDS` Heroku configuration variables, and put:: 100 | 101 | AWS_EC2_KEY_PAIR_NAME= 102 | AWS_ECS_AMI= 103 | AWS_ECS_CLUSTER_NAME= 104 | AWS_ECS_SUBNET_ID= 105 | AWS_ECS_SECURITY_GROUP_IDS= 106 | 107 | In the .env file in the project home directory. 108 | 109 | Docker images 110 | ~~~~~~~~~~~~~ 111 | 112 | Check out a Comb, (BeeWare uses `this one 113 | `__). Create a file named `.env` in 114 | the root of that checkout, and create a file called `.env` that contains the 115 | following content:: 116 | 117 | AWS_REGION= 118 | AWS_ACCESS_KEY_ID= 119 | AWS_SECRET_ACCESS_KEY= 120 | 121 | Then waggle the tasks in the comb:: 122 | 123 | $ pip install waggle 124 | $ waggle waggler 125 | 126 | Profiles 127 | ~~~~~~~~ 128 | 129 | Log into the admin, and create an AWS profile with the slug of `default`. This 130 | is the machine type that will run tests by default. A reasonable starting point 131 | is: 132 | 133 | * **Instance type:** `t2.micro` 134 | * **CPU:** 0 135 | * **Memory:** 200 136 | 137 | The value for CPU specifies how many compute units will be reserved by a task 138 | running with that profile; 1 CPU represents 1024 compute units. The value for 139 | memory indicates how much RAM (in MB) will be reserved for the task. 140 | 141 | You may also want to add other profile types (e.g., a hi-cpu type). The slug 142 | you specify for the profile can then be referenced by build tasks deployed on 143 | the BeeKeeper cluster. 144 | 145 | Github 146 | ~~~~~~ 147 | 148 | Last, go to the repository you want to manage with BeeKeeper, go to Settings, 149 | then Webhooks, and add a new webhook for 150 | `https://.herokuapp.com/github/notify>`. When prompted for a 151 | secret, you can generate one using Python:: 152 | 153 | >>> from django.utils.crypto import get_random_string 154 | >>> get_random_string(50) 155 | 'nuiVypAArY7lFDgMdyC5kwutDGQdDc6rXljuIcI5iBttpPebui' 156 | 157 | Once the webhook has been created, create a `GITHUB_WEBHOOK_KEY` Heroku 158 | configuration variable to this string, and put:: 159 | 160 | GITHUB_WEBHOOK_KEY= 161 | 162 | in the .env file in the project home directory. 163 | 164 | Then, generate a `personal access token 165 | `__, create `GITHUB_USERNAME` and `GITHUB_ACCESS_TOKEN` Heroku 167 | configuration variables with that value, and put:: 168 | 169 | GITHUB_USERNAME= 170 | GITHUB_ACCESS_TOKEN= 171 | 172 | in the .env file in the project home directory. 173 | 174 | When the webhook is created, it will ping your BeeKeeper instance. This should 175 | result in BeeKeeper responding and recording the existence of the project. 176 | Any user logged in as an admin should see the project listed on the BeeKeeper 177 | homepage. If you approve the project, any PR or repository push will start a 178 | build as described in the `beekeeper.yml` file in the project home directory. 179 | 180 | Documentation 181 | ------------- 182 | 183 | Documentation for BeeKeeper can be found on `Read The Docs`_. 184 | 185 | Community 186 | --------- 187 | 188 | BeeKeeper is part of the `BeeWare suite`_. You can talk to the community through: 189 | 190 | * `@pybeeware on Twitter`_ 191 | 192 | * The `pybee/general`_ channel on Gitter. 193 | 194 | We foster a welcoming and respectful community as described in our 195 | `BeeWare Community Code of Conduct`_. 196 | 197 | Contributing 198 | ------------ 199 | 200 | If you experience problems with BeeKeeper, `log them on GitHub`_. If you 201 | want to contribute code, please `fork the code`_ and `submit a pull request`_. 202 | 203 | .. _BeeWare suite: http://pybee.org 204 | .. _Read The Docs: http://pybee-beekeeper.readthedocs.io 205 | .. _@pybeeware on Twitter: https://twitter.com/pybeeware 206 | .. _pybee/general: https://gitter.im/pybee/general 207 | .. _BeeWare Community Code of Conduct: http://pybee.org/community/behavior/ 208 | .. _log them on Github: https://github.com/pybee/beekeeper/issues 209 | .. _fork the code: https://github.com/pybee/beekeeper 210 | .. _submit a pull request: https://github.com/pybee/beekeeper/pulls 211 | .. _docker-compose: https://docs.docker.com/compose/install/#install-compose 212 | .. |heroku| image:: https://www.herokucdn.com/deploy/button.svg 213 | :target: https://heroku.com/deploy?template=https://github.com/pybee/beekeeper/tree/master 214 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BeeKeeper", 3 | "description": "Look after all the little worker bees.", 4 | "env": { 5 | "SECRET_KEY": { 6 | "description": "The Django secret key", 7 | "generator": "secret" 8 | }, 9 | "PRODUCTION": "True" 10 | }, 11 | "scripts": { 12 | "postdeploy": "./manage.py migrate" 13 | }, 14 | "formation": { 15 | "worker": { 16 | "quantity": 1, 17 | "size": "free" 18 | } 19 | }, 20 | "addons": [ 21 | "heroku-redis", 22 | "heroku-postgresql" 23 | ] 24 | } -------------------------------------------------------------------------------- /aws/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'aws.apps.AWSConfig' 2 | -------------------------------------------------------------------------------- /aws/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin, messages 2 | from django.utils.safestring import mark_safe 3 | 4 | from .models import Task, Profile, Instance 5 | 6 | 7 | @admin.register(Task) 8 | class TaskAdmin(admin.ModelAdmin): 9 | list_display = ['project', 'build_pk', 'name', 'phase', 'is_critical', 'image', 'status', 'result'] 10 | list_filter = ['status', 'result', 'is_critical'] 11 | raw_id_fields = ['build',] 12 | 13 | def build_pk(self, task): 14 | return task.build.display_pk 15 | build_pk.short_description = 'Build' 16 | 17 | def project(self, task): 18 | return task.build.change.project 19 | project.short_description = 'Project' 20 | 21 | 22 | @admin.register(Profile) 23 | class ProfileAdmin(admin.ModelAdmin): 24 | list_display = ['slug', 'name', 'instance_type'] 25 | 26 | 27 | 28 | def terminate(modeladmin, request, queryset): 29 | for obj in queryset: 30 | try: 31 | obj.terminate() 32 | messages.info(request, 'Terminating %s' % obj) 33 | except Exception as e: 34 | messages.error(request, str(e)) 35 | terminate.short_description = "Terminate instance" 36 | 37 | 38 | @admin.register(Instance) 39 | class InstanceAdmin(admin.ModelAdmin): 40 | list_display = ['profile', 'ec2_id', 'container_arn', 'created', 'active', 'preferred'] 41 | list_filter = ['active', 'preferred'] 42 | raw_id_fields = ['tasks'] 43 | actions = [terminate] 44 | -------------------------------------------------------------------------------- /aws/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AWSConfig(AppConfig): 5 | name = 'aws' 6 | verbose_name = 'Amazon Web Services ECS' 7 | 8 | def ready(self): 9 | from django.db.models import signals as django 10 | from projects import signals as projects 11 | from projects.models import Build 12 | from .handlers import start_build 13 | 14 | projects.start_build.connect(start_build, sender=Build) 15 | -------------------------------------------------------------------------------- /aws/handlers.py: -------------------------------------------------------------------------------- 1 | from .tasks import check_build 2 | 3 | def start_build(sender, build, *args, **kwargs): 4 | check_build.delay(str(build.pk)) 5 | -------------------------------------------------------------------------------- /aws/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-07 08:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ('projects', '0002_tweak_model_ordering'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Task', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('status', models.IntegerField(choices=[(10, 'Created'), (19, 'Pending'), (20, 'Running'), (100, 'Done'), (200, 'Error'), (9998, 'Stopping'), (9999, 'Stopped')], default=10)), 23 | ('result', models.IntegerField(choices=[(0, 'Pending'), (10, 'Fail'), (19, 'Qualified pass'), (20, 'Pass')], default=0)), 24 | ('name', models.CharField(db_index=True, max_length=100)), 25 | ('slug', models.CharField(db_index=True, max_length=100)), 26 | ('phase', models.IntegerField()), 27 | ('started', models.DateTimeField(blank=True, null=True)), 28 | ('updated', models.DateTimeField(auto_now=True)), 29 | ('completed', models.DateTimeField(blank=True, null=True)), 30 | ('descriptor', models.CharField(max_length=100)), 31 | ('arn', models.CharField(blank=True, max_length=100, null=True)), 32 | ('build', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tasks', to='projects.Build')), 33 | ], 34 | options={ 35 | 'ordering': ('phase', 'name'), 36 | }, 37 | ), 38 | migrations.AlterUniqueTogether( 39 | name='task', 40 | unique_together=set([('build', 'slug')]), 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /aws/migrations/0002_task_environment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-08 01:39 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields.jsonb 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('aws', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='task', 18 | name='environment', 19 | field=django.contrib.postgres.fields.jsonb.JSONField(default={}), 20 | preserve_default=False, 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /aws/migrations/0003_non_critical_tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-09 02:03 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0002_task_environment'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='task', 17 | name='is_critical', 18 | field=models.BooleanField(default=True), 19 | preserve_default=False, 20 | ), 21 | migrations.AlterField( 22 | model_name='task', 23 | name='result', 24 | field=models.IntegerField(choices=[(0, 'Pending'), (10, 'Fail'), (19, 'Non-critical Fail'), (20, 'Pass')], default=0), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /aws/migrations/0004_add_overrides.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-13 06:38 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields.jsonb 6 | from django.db import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('aws', '0003_non_critical_tasks'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='task', 18 | name='overrides', 19 | field=django.contrib.postgres.fields.jsonb.JSONField(default={}), 20 | preserve_default=False, 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /aws/migrations/0005_add_pending_timestamp.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-15 04:36 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0004_add_overrides'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='task', 17 | name='pending', 18 | field=models.DateTimeField(blank=True, null=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /aws/migrations/0006_add_task_error.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-15 05:07 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0005_add_pending_timestamp'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='task', 17 | name='error', 18 | field=models.TextField(blank=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /aws/migrations/0007_add_profile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-16 05:42 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0006_add_task_error'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='task', 17 | name='overrides', 18 | ), 19 | migrations.AddField( 20 | model_name='task', 21 | name='profile', 22 | field=models.CharField(max_length=100, null=True), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /aws/migrations/0008_add_task_profiles.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-20 04:11 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0007_add_profile'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Profile', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=100)), 20 | ('slug', models.CharField(db_index=True, max_length=100)), 21 | ('instance_type', models.CharField(choices=[('t2.nano', 't2.nano'), ('t2.micro', 't2.micro'), ('t2.small', 't2.small'), ('t2.medium', 't2.medium'), ('t2.large', 't2.large'), ('t2.xlarge', 't2.xlarge'), ('t2.2xlarge', 't2.2xlarge'), ('m4.large', 'm4.large'), ('m4.xlarge', 'm4.xlarge'), ('m4.2xlarge', 'm4.2xlarge'), ('m4.4xlarge', 'm4.4xlarge'), ('m4.10xlarge', 'm4.10xlarge'), ('m4.16xlarge', 'm4.16xlarge'), ('c4.large', 'c4.large'), ('c4.xlarge', 'c4.xlarge'), ('c4.2xlarge', 'c4.2xlarge'), ('c4.4xlarge', 'c4.4xlarge'), ('c4.8xlarge', 'c4.8xlarge'), ('p2.xlarge', 'p2.xlarge'), ('p2.8xlarge', 'p2.8xlarge'), ('p2.16xlarge', 'p2.16xlarge'), ('g3.4xlarge', 'g3.4xlarge'), ('g3.8xlarge', 'g3.8xlarge'), ('g3.16xlarge', 'g3.16xlarge'), ('r4.large', 'r4.large'), ('r4.xlarge', 'r4.xlarge'), ('r4.2xlarge', 'r4.2xlarge'), ('r4.4xlarge', 'r4.4xlarge'), ('r4.8xlarge', 'r4.8xlarge'), ('r4.16xlarge', 'r4.16xlarge')], max_length=20)), 22 | ('cpu', models.IntegerField(default=0)), 23 | ('memory', models.IntegerField(default=0)), 24 | ('ami', models.CharField(default='ami-57d9cd2e', max_length=100)), 25 | ('idle', models.IntegerField(default=60)), 26 | ], 27 | options={ 28 | 'ordering': ('slug',), 29 | }, 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /aws/migrations/0009_add_timeout.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-21 03:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | dependencies = [ 13 | ('aws', '0008_add_task_profiles'), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Instance', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('container_arn', models.CharField(blank=True, db_index=True, max_length=100, null=True)), 22 | ('ec2_id', models.CharField(db_index=True, max_length=100)), 23 | ('created', models.DateTimeField(default=django.utils.timezone.now)), 24 | ('checked', models.DateTimeField(auto_now=True)), 25 | ('terminated', models.DateTimeField(blank=True, null=True)), 26 | ('active', models.BooleanField(default=True)), 27 | ], 28 | ), 29 | migrations.RenameField( 30 | model_name='profile', 31 | old_name='idle', 32 | new_name='cooldown', 33 | ), 34 | migrations.AddField( 35 | model_name='profile', 36 | name='timeout', 37 | field=models.IntegerField(default=3600), 38 | ), 39 | migrations.AlterField( 40 | model_name='profile', 41 | name='ami', 42 | field=models.CharField(default='ami-57d9cd2e', max_length=100, verbose_name='AMI'), 43 | ), 44 | migrations.AddField( 45 | model_name='instance', 46 | name='profile', 47 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='instances', to='aws.Profile'), 48 | ), 49 | migrations.AddField( 50 | model_name='instance', 51 | name='tasks', 52 | field=models.ManyToManyField(blank=True, to='aws.Task'), 53 | ), 54 | ] 55 | -------------------------------------------------------------------------------- /aws/migrations/0010_add_instance_limits.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-21 03:26 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0009_add_timeout'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='profile', 17 | name='max_instances', 18 | field=models.IntegerField(blank=True, null=True), 19 | ), 20 | migrations.AddField( 21 | model_name='profile', 22 | name='min_instances', 23 | field=models.IntegerField(default=0), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /aws/migrations/0011_clarify_profile.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-21 03:48 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0010_add_instance_limits'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name='task', 17 | old_name='profile', 18 | new_name='profile_slug', 19 | ), 20 | migrations.AlterField( 21 | model_name='task', 22 | name='profile_slug', 23 | field=models.CharField(default='default', max_length=100), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /aws/migrations/0012_rename_pending.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-21 08:00 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0011_clarify_profile'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name='task', 17 | old_name='pending', 18 | new_name='queued', 19 | ), 20 | migrations.AlterField( 21 | model_name='instance', 22 | name='tasks', 23 | field=models.ManyToManyField(blank=True, related_name='instances', to='aws.Task'), 24 | ), 25 | migrations.AlterField( 26 | model_name='task', 27 | name='status', 28 | field=models.IntegerField(choices=[(10, 'Created'), (19, 'Waiting'), (20, 'Running'), (100, 'Done'), (200, 'Error'), (9998, 'Stopping'), (9999, 'Stopped')], default=10), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /aws/migrations/0013_add_spot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2017-09-21 00:48 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0012_rename_pending'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='profile', 17 | name='spot', 18 | field=models.BooleanField(default=False), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /aws/migrations/0014_preferred_instances.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2017-10-07 14:53 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0013_add_spot'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='instance', 17 | name='preferred', 18 | field=models.BooleanField(default=False), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /aws/migrations/0015_auto_20180827_1042.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2018-08-27 02:42 3 | from __future__ import unicode_literals 4 | 5 | import django.contrib.postgres.fields.jsonb 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('aws', '0014_preferred_instances'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='task', 18 | name='image', 19 | field=models.CharField(blank=True, max_length=100, null=True), 20 | ), 21 | migrations.AlterField( 22 | model_name='profile', 23 | name='instance_type', 24 | field=models.CharField(choices=[('t2.nano', 't2.nano'), ('t2.micro', 't2.micro'), ('t2.small', 't2.small'), ('t2.medium', 't2.medium'), ('t2.large', 't2.large'), ('t2.xlarge', 't2.xlarge'), ('t2.2xlarge', 't2.2xlarge'), ('m4.large', 'm4.large'), ('m4.xlarge', 'm4.xlarge'), ('m4.2xlarge', 'm4.2xlarge'), ('m4.4xlarge', 'm4.4xlarge'), ('m4.10xlarge', 'm4.10xlarge'), ('m4.16xlarge', 'm4.16xlarge'), ('c5.large', 'c5.large'), ('c5.xlarge', 'c5.xlarge'), ('c5.2xlarge', 'c5.2xlarge'), ('c5.4xlarge', 'c5.4xlarge'), ('c5.9xlarge', 'c5.9xlarge'), ('c5.18xlarge', 'c5.18xlarge'), ('c4.large', 'c4.large'), ('c4.xlarge', 'c4.xlarge'), ('c4.2xlarge', 'c4.2xlarge'), ('c4.4xlarge', 'c4.4xlarge'), ('c4.8xlarge', 'c4.8xlarge'), ('p2.xlarge', 'p2.xlarge'), ('p2.8xlarge', 'p2.8xlarge'), ('p2.16xlarge', 'p2.16xlarge'), ('g3.4xlarge', 'g3.4xlarge'), ('g3.8xlarge', 'g3.8xlarge'), ('g3.16xlarge', 'g3.16xlarge'), ('r4.large', 'r4.large'), ('r4.xlarge', 'r4.xlarge'), ('r4.2xlarge', 'r4.2xlarge'), ('r4.4xlarge', 'r4.4xlarge'), ('r4.8xlarge', 'r4.8xlarge'), ('r4.16xlarge', 'r4.16xlarge')], max_length=20), 25 | ), 26 | migrations.AlterField( 27 | model_name='task', 28 | name='environment', 29 | field=django.contrib.postgres.fields.jsonb.JSONField(blank=True), 30 | ), 31 | ] 32 | -------------------------------------------------------------------------------- /aws/migrations/0016_remove_task_descriptor.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.8 on 2018-08-27 05:58 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('aws', '0015_auto_20180827_1042'), 12 | ] 13 | 14 | operations = [ 15 | migrations.RemoveField( 16 | model_name='task', 17 | name='descriptor', 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /aws/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/aws/migrations/__init__.py -------------------------------------------------------------------------------- /aws/task_urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | from aws import views as aws 5 | 6 | 7 | urlpatterns = [ 8 | url(r'^(?P[-\w\._:]+)$', aws.task, name='task'), 9 | url(r'^(?P[-\w\._:]+)/status$', aws.task_status, name='task-status'), 10 | ] 11 | -------------------------------------------------------------------------------- /aws/tests.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | # Create your tests here. 4 | -------------------------------------------------------------------------------- /aws/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url 2 | from django.contrib import admin 3 | 4 | from . import views as aws 5 | 6 | 7 | urlpatterns = [ 8 | url(r'^$', aws.current_tasks, name='current-tasks'), 9 | ] 10 | -------------------------------------------------------------------------------- /aws/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import boto3 4 | 5 | from django.conf import settings 6 | from django.http import Http404, HttpResponse 7 | from django.shortcuts import render 8 | 9 | from projects.models import Build 10 | 11 | from .models import Task 12 | 13 | 14 | def task(request, owner, repo_name, change_pk, build_pk, task_slug): 15 | try: 16 | task = Task.objects.get( 17 | build__change__project__repository__owner__login=owner, 18 | build__change__project__repository__name=repo_name, 19 | build__change__pk=change_pk, 20 | build__pk=build_pk, 21 | slug=task_slug 22 | ) 23 | except Task.DoesNotExist: 24 | raise Http404 25 | 26 | return render(request, 'projects/task.html', { 27 | 'project': task.build.change.project, 28 | 'change': task.build.change, 29 | 'commit': task.build.commit, 30 | 'build': task.build, 31 | 'task': task, 32 | }) 33 | 34 | 35 | def task_status(request, owner, repo_name, change_pk, build_pk, task_slug): 36 | try: 37 | task = Task.objects.get( 38 | build__change__project__repository__owner__login=owner, 39 | build__change__project__repository__name=repo_name, 40 | build__change__pk=change_pk, 41 | build__pk=build_pk, 42 | slug=task_slug 43 | ) 44 | except Task.DoesNotExist: 45 | raise Http404 46 | 47 | try: 48 | kwargs = { 49 | 'nextToken': request.GET['nextToken'] 50 | } 51 | except KeyError: 52 | kwargs = {} 53 | 54 | aws_session = boto3.session.Session( 55 | region_name=settings.AWS_REGION, 56 | aws_access_key_id=settings.AWS_ACCESS_KEY_ID, 57 | aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, 58 | ) 59 | logs = aws_session.client('logs') 60 | 61 | try: 62 | log_response = logs.get_log_events( 63 | logGroupName='beekeeper', 64 | logStreamName=task.log_stream_name, 65 | **kwargs 66 | ) 67 | log_data = '\n'.join( 68 | event['message'] 69 | for event in log_response['events'] 70 | ) 71 | message = None 72 | next_token = log_response['nextForwardToken'] 73 | no_more_logs = log_response['nextForwardToken'] == kwargs.get('nextToken', None) 74 | except Exception as e: 75 | if task.has_error: 76 | log_data = None 77 | message = 'No logs; task did not start.' 78 | next_token = '' 79 | no_more_logs = True 80 | else: 81 | log_data = None 82 | message = 'Waiting for logs to become available...' 83 | next_token = '' 84 | no_more_logs = False 85 | 86 | return HttpResponse(json.dumps({ 87 | 'started': task.has_started, 88 | 'log': log_data, 89 | 'message': message, 90 | 'status': task.full_status_display(), 91 | 'result': task.result, 92 | 'nextToken': next_token, 93 | 'finished': task.is_finished and no_more_logs, 94 | }), content_type="application/json") 95 | 96 | 97 | def current_tasks(request): 98 | return render(request, 'aws/current_tasks.html', { 99 | 'pending': Task.objects.created().filter(build__status__in=( 100 | Build.STATUS_CREATED, 101 | Build.STATUS_RUNNING) 102 | ).order_by('-updated'), 103 | 'started': Task.objects.not_finished().filter(build__status__in=( 104 | Build.STATUS_CREATED, 105 | Build.STATUS_RUNNING) 106 | ).order_by('-updated'), 107 | 'recents': Task.objects.recently_finished().order_by('-updated'), 108 | }) 109 | -------------------------------------------------------------------------------- /beekeeper/__init__.py: -------------------------------------------------------------------------------- 1 | # Examples of valid version strings 2 | # __version__ = '1.2.3.dev1' # Development release 1 3 | # __version__ = '1.2.3a1' # Alpha Release 1 4 | # __version__ = '1.2.3b1' # Beta Release 1 5 | # __version__ = '1.2.3rc1' # RC Release 1 6 | # __version__ = '1.2.3' # Final Release 7 | # __version__ = '1.2.3.post1' # Post Release 1 8 | 9 | __version__ = '0.1.0' 10 | -------------------------------------------------------------------------------- /beekeeper/__main__.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import os 3 | 4 | from beekeeper import runner 5 | 6 | 7 | def main(): 8 | parser = ArgumentParser() 9 | parser.add_argument( 10 | '--action', '-a', dest='action', default='pull_request', 11 | choices=['pull_request', 'push', 'tag'], 12 | help='Specify the build action to run.', 13 | ) 14 | parser.add_argument( 15 | '--slug', '-s', dest='slug', 16 | help='Specify the tasks to run (e.g., `beefore:pycodestyle` to run a specific task, ' 17 | 'or `beefore` to run all tasks in a phase.', 18 | ) 19 | parser.add_argument( 20 | 'project_dir', nargs='?', default='.', 21 | help='Directory containing a configured BeeKeeper project.' 22 | ) 23 | options = parser.parse_args() 24 | 25 | runner.run_project( 26 | project_dir=os.path.abspath(options.project_dir), 27 | slug=options.slug, 28 | action=options.action, 29 | ) 30 | 31 | 32 | if __name__ == '__main__': 33 | main() 34 | -------------------------------------------------------------------------------- /beekeeper/config.py: -------------------------------------------------------------------------------- 1 | 2 | def load_task_configs(config): 3 | task_data = [] 4 | for phase, phase_configs in enumerate(config): 5 | for phase_name, phase_config in phase_configs.items(): 6 | if 'subtasks' in phase_config: 7 | for task_configs in phase_config['subtasks']: 8 | for task_name, task_config in task_configs.items(): 9 | # If an image is provided at the subtask level, 10 | # use it; otherwise use the phase's task definition. 11 | image = None 12 | if task_config: 13 | image = task_config.get('image', None) 14 | if image is None: 15 | # Backwards compatibility - look for a 16 | # 'task' instead of 'image'; 17 | # if it exists, prepend 'beekeeper/' 18 | try: 19 | image = 'beekeeper/' + task_config['task'] 20 | except KeyError: 21 | image = None 22 | if image is None: 23 | image = phase_config.get('image', None) 24 | if image is None: 25 | # Backwards compatibility - look for a 26 | # 'task' instead of 'image'; 27 | # if it exists, prepend 'beekeeper/' 28 | try: 29 | image = 'beekeeper/' + phase_config['task'] 30 | except KeyError: 31 | image = None 32 | if image is None: 33 | raise ValueError("Subtask %s in phase %s task %s doesn't contain a task image." % ( 34 | task_name, phase, phase_name 35 | )) 36 | 37 | # The environment is the phase environment, overridden 38 | # by the task environment. 39 | task_env = phase_config.get('environment', {}).copy() 40 | if task_config: 41 | task_env.update(task_config.get('environment', {})) 42 | task_profile = task_config.get('profile', phase_config.get('profile', 'default')) 43 | 44 | full_name = task_config.get('name', task_name) 45 | else: 46 | full_name = task_name 47 | task_profile = 'default' 48 | 49 | task_data.append({ 50 | 'name': full_name, 51 | 'slug': "%s:%s" % (phase_name, task_name), 52 | 'phase': phase, 53 | 'is_critical': task_config.get('critical', True), 54 | 'environment': task_env, 55 | 'profile_slug': task_profile, 56 | 'image': image, 57 | }) 58 | elif 'image' in phase_config: 59 | task_data.append({ 60 | 'name': phase_config.get('name', phase_name), 61 | 'slug': phase_name, 62 | 'phase': phase, 63 | 'is_critical': phase_config.get('critical', True), 64 | 'environment': phase_config.get('environment', {}), 65 | 'profile_slug': phase_config.get('profile', 'default'), 66 | 'image': phase_config['image'], 67 | }) 68 | elif 'task' in phase_config: 69 | # Backward compatibility - look for a 70 | # 'task' instead of 'image'; if it exists, prepend 'beekeeper/' 71 | task_data.append({ 72 | 'name': phase_config.get('name', phase_name), 73 | 'slug': phase_name, 74 | 'phase': phase, 75 | 'is_critical': phase_config.get('critical', True), 76 | 'environment': phase_config.get('environment', {}), 77 | 'profile_slug': phase_config.get('profile', 'default'), 78 | 'image': 'beekeeper/' + phase_config['task'], 79 | }) 80 | else: 81 | raise ValueError("Phase %s task %s doesn't contain a task or subtask image." % ( 82 | phase, phase_name 83 | )) 84 | return task_data 85 | -------------------------------------------------------------------------------- /beekeeper/runner.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import yaml 5 | 6 | from beekeeper.config import load_task_configs 7 | 8 | 9 | def run_task(name, phase, image, project_dir, is_critical, environment, **extra): 10 | print() 11 | print("---------------------------------------------------------------------------") 12 | print("{phase}: {name}".format(phase=phase, name=name)) 13 | print("---------------------------------------------------------------------------") 14 | env_args = ' '.join( 15 | '-e {var}="{value}"'.format(var=var, value=value) 16 | for var, value in environment.items() 17 | ) 18 | result = subprocess.run( 19 | 'docker run -v {project_dir}:/app {env_args} {image}'.format( 20 | env_args=env_args, 21 | project_dir=project_dir, 22 | image=image 23 | ), 24 | shell=True, 25 | cwd=project_dir, 26 | ) 27 | 28 | print("---------------------------------------------------------------------------") 29 | if result.returncode == 0: 30 | print("PASS: {name}".format(name=name)) 31 | return True 32 | elif not is_critical: 33 | print("FAIL (non critical): {name}".format(name=name)) 34 | return True 35 | else: 36 | print("FAIL: {name}".format(name=name)) 37 | return False 38 | 39 | 40 | def run_project(project_dir, slug=None, action='pull_request'): 41 | with open(os.path.join(project_dir, 'beekeeper.yml')) as config_file: 42 | config = yaml.load(config_file.read()) 43 | 44 | all_tasks = load_task_configs(config[action]) 45 | 46 | if slug and ':' in slug: 47 | tasks = [ 48 | task 49 | for task in all_tasks 50 | if task['slug'] == slug 51 | ] 52 | elif slug: 53 | tasks = [ 54 | task 55 | for task in all_tasks 56 | if task['slug'].startswith(slug + ':') 57 | ] 58 | else: 59 | tasks = all_tasks 60 | 61 | phase = None 62 | successes = [] 63 | failures = [] 64 | for task in tasks: 65 | if task['phase'] != phase: 66 | if failures: 67 | break 68 | phase = task['phase'] 69 | print("***** PHASE {phase} *************************************************************".format(**task)) 70 | 71 | task['environment'].update({ 72 | 'TASK': task['slug'].split(':')[-1], 73 | }) 74 | 75 | success = run_task(project_dir=project_dir, **task) 76 | 77 | if success: 78 | successes.append({ 79 | 'phase': task['phase'], 80 | 'name': task['name'], 81 | 'is_critical': task['is_critical'] 82 | }) 83 | else: 84 | failures.append({ 85 | 'phase': task['phase'], 86 | 'name': task['name'], 87 | 'is_critical': task['is_critical'] 88 | }) 89 | 90 | print() 91 | print("*************************************************************************") 92 | if failures: 93 | print(f"BeeKeeper suite failed in phase {phase}:") 94 | for result in failures: 95 | print(f" * {result['name']}") 96 | else: 97 | print("BeeKeeper suite passed.") 98 | -------------------------------------------------------------------------------- /beekeeper/utils.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /beekeeper/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.decorators import login_required 2 | from django.shortcuts import render, redirect 3 | 4 | from projects.models import Project 5 | 6 | 7 | def home(request): 8 | if request.method == "POST" and request.user.is_superuser: 9 | pks = [int(pk) for pk in request.POST.getlist('projects')] 10 | projects = Project.objects.filter(pk__in=pks) 11 | if 'approve' in request.POST: 12 | for project in projects: 13 | project.approve() 14 | elif 'ignore' in request.POST: 15 | for project in projects: 16 | project.ignore() 17 | 18 | return redirect('home') 19 | 20 | return render(request, 'home.html', { 21 | 'projects': Project.objects.active(), 22 | 'new_projects': Project.objects.pending_approval() 23 | }) 24 | -------------------------------------------------------------------------------- /config/__init__.py: -------------------------------------------------------------------------------- 1 | # This will make sure the app is always imported when 2 | # Django starts so that shared_task will use this app. 3 | from .celery import app as celery_app 4 | 5 | __all__ = ['celery_app'] 6 | -------------------------------------------------------------------------------- /config/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | from celery import Celery 3 | 4 | # set the default Django settings module for the 'celery' program. 5 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') 6 | 7 | app = Celery('beekeeper') 8 | 9 | # Using a string here means the worker don't have to serialize 10 | # the configuration object to child processes. 11 | # - namespace='CELERY' means all celery-related configuration keys 12 | # should have a `CELERY_` prefix. 13 | app.config_from_object('django.conf:settings', namespace='CELERY') 14 | 15 | # Load task modules from all registered Django app configs. 16 | app.autodiscover_tasks() 17 | 18 | 19 | @app.task(bind=True) 20 | def debug_task(self): 21 | print('Request: {0!r}'.format(self.request)) 22 | -------------------------------------------------------------------------------- /config/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for beekeepers project. 3 | 4 | Generated by 'django-admin startproject' using Django 1.11.2. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/topics/settings/ 8 | 9 | For the full list of settings and their values, see 10 | https://docs.djangoproject.com/en/1.11/ref/settings/ 11 | """ 12 | 13 | import os 14 | import dj_database_url 15 | from dotenv import load_dotenv 16 | 17 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 18 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 19 | PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) 20 | load_dotenv(os.path.join(BASE_DIR, '.env')) 21 | 22 | # Quick-start development settings - unsuitable for production 23 | # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ 24 | 25 | # SECURITY WARNING: keep the secret key used in production secret! 26 | SECRET_KEY = os.getenv('SECRET_KEY') 27 | 28 | # SECURITY WARNING: don't run with debug turned on in production! 29 | DEBUG = os.getenv('PRODUCTION', 'False').title() == 'False' 30 | 31 | IS_DOCKER = os.getenv('DOCKER_CONTAINER', False) 32 | 33 | ALLOWED_HOSTS = ['*'] 34 | 35 | 36 | # Application definition 37 | 38 | INSTALLED_APPS = [ 39 | 'django.contrib.admin', 40 | 'django.contrib.auth', 41 | 'django.contrib.contenttypes', 42 | 'django.contrib.sessions', 43 | 'django.contrib.messages', 44 | 45 | # Disable Django's own staticfiles handling in favour of WhiteNoise, for 46 | # greater consistency between gunicorn and `./manage.py runserver`. See: 47 | # http://whitenoise.evans.io/en/stable/django.html#using-whitenoise-in-development 48 | 'whitenoise.runserver_nostatic', 49 | 'django.contrib.staticfiles', 50 | 51 | 'storages', 52 | 'rhouser', 53 | 54 | 'github', 55 | 'projects', 56 | 'aws', 57 | ] 58 | 59 | MIDDLEWARE = [ 60 | 'django.middleware.security.SecurityMiddleware', 61 | 'whitenoise.middleware.WhiteNoiseMiddleware', 62 | 'django.contrib.sessions.middleware.SessionMiddleware', 63 | 'django.middleware.common.CommonMiddleware', 64 | 'django.middleware.csrf.CsrfViewMiddleware', 65 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 66 | 'django.contrib.messages.middleware.MessageMiddleware', 67 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 68 | ] 69 | 70 | ROOT_URLCONF = 'config.urls' 71 | 72 | TEMPLATES = [ 73 | { 74 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 75 | 'DIRS': [ 76 | os.path.join(BASE_DIR, 'templates') 77 | ], 78 | 'APP_DIRS': True, 79 | 'OPTIONS': { 80 | 'context_processors': [ 81 | 'django.template.context_processors.debug', 82 | 'django.template.context_processors.request', 83 | 'django.contrib.auth.context_processors.auth', 84 | 'django.contrib.messages.context_processors.messages', 85 | ], 86 | 'debug': DEBUG, 87 | }, 88 | }, 89 | ] 90 | 91 | WSGI_APPLICATION = 'config.wsgi.application' 92 | 93 | 94 | # Database 95 | # https://docs.djangoproject.com/en/1.11/ref/settings/#databases 96 | 97 | DATABASES = { 98 | 'default': { 99 | 'ENGINE': 'django.db.backends.postgresql', 100 | 'NAME': 'beekeeper', 101 | 'USER': 'postgres', 102 | 'HOST': 'db', 103 | 'PORT': 5432, 104 | } 105 | } 106 | 107 | # Update database configuration with $DATABASE_URL. 108 | if not IS_DOCKER: 109 | db_from_env = dj_database_url.config(conn_max_age=500) 110 | DATABASES['default'].update(db_from_env) 111 | 112 | AUTH_USER_MODEL = 'rhouser.User' 113 | 114 | # Password validation 115 | # https://docs.djangoproject.com/en/1.11/ref/settings/#auth-password-validators 116 | 117 | AUTH_PASSWORD_VALIDATORS = [ 118 | { 119 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 120 | }, 121 | { 122 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 123 | }, 124 | { 125 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 126 | }, 127 | { 128 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 129 | }, 130 | ] 131 | 132 | LOGIN_REDIRECT_URL = '/' 133 | LOGOUT_REDIRECT_URL = 'login' 134 | 135 | 136 | # Internationalization 137 | # https://docs.djangoproject.com/en/1.11/topics/i18n/ 138 | 139 | LANGUAGE_CODE = 'en-us' 140 | 141 | TIME_ZONE = 'Australia/Perth' 142 | 143 | USE_I18N = True 144 | 145 | USE_L10N = True 146 | 147 | USE_TZ = True 148 | 149 | # Static files (CSS, JavaScript, Images) 150 | # https://docs.djangoproject.com/en/1.11/howto/static-files/ 151 | 152 | STATIC_ROOT = os.path.join(PROJECT_ROOT, 'staticfiles') 153 | STATIC_URL = '/static/' 154 | 155 | # Extra places for collectstatic to find static files. 156 | STATICFILES_DIRS = [ 157 | os.path.join(BASE_DIR, "static"), 158 | ] 159 | 160 | MEDIA_ROOT = os.path.join(BASE_DIR, 'runtime', 'media') 161 | MEDIA_URL = '/media/' 162 | 163 | # Simplified static file serving. 164 | # https://warehouse.python.org/project/whitenoise/ 165 | STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' 166 | 167 | # Honor the 'X-Forwarded-Proto' header for request.is_secure() 168 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') 169 | 170 | ###################################################################### 171 | # Set up the Redis Queue for workers. 172 | ###################################################################### 173 | CELERY_BROKER_URL = os.getenv('REDIS_URL', 'redis://localhost:6379/0') 174 | CELERY_RESULT_BACKEND = os.getenv('REDIS_URL', 'redis://localhost:6379/0') 175 | 176 | ###################################################################### 177 | # Media file storage 178 | ###################################################################### 179 | DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' 180 | 181 | ###################################################################### 182 | # Beekeeper configuration 183 | ###################################################################### 184 | BEEKEEPER_NAMESPACE = os.environ.get('BEEKEEPER_NAMESPACE', 'beekeeper') 185 | BEEKEEPER_URL = os.environ.get('BEEKEEPER_URL') 186 | BEEKEEPER_BUILD_APP = os.environ.get('BEEKEEPER_BUILD_APP', 'aws') 187 | 188 | ###################################################################### 189 | # AWS configuration 190 | ###################################################################### 191 | AWS_STORAGE_BUCKET_NAME = 'beekeeper' 192 | 193 | AWS_ACCESS_KEY_ID = os.environ.get('AWS_ACCESS_KEY_ID') 194 | AWS_SECRET_ACCESS_KEY = os.environ.get('AWS_SECRET_ACCESS_KEY') 195 | AWS_REGION = os.environ.get('AWS_REGION') 196 | 197 | AWS_EC2_KEY_PAIR_NAME = os.environ.get('AWS_EC2_KEY_PAIR_NAME') 198 | 199 | AWS_ECS_CLUSTER_NAME = os.environ.get('AWS_ECS_CLUSTER_NAME', 'workers') 200 | AWS_ECS_SUBNET_ID = os.environ.get('AWS_ECS_SUBNET_ID') 201 | AWS_ECS_SECURITY_GROUP_IDS = os.environ.get('AWS_ECS_SECURITY_GROUP_IDS') 202 | 203 | ###################################################################### 204 | # Sendgrid 205 | ###################################################################### 206 | EMAIL_BACKEND = "sgbackend.SendGridBackend" 207 | SENDGRID_API_KEY = os.environ.get('SENDGRID_API_KEY') 208 | 209 | ###################################################################### 210 | # Github 211 | ###################################################################### 212 | GITHUB_WEBHOOK_KEY = os.environ.get('GITHUB_WEBHOOK_KEY') 213 | GITHUB_USERNAME = os.environ.get('GITHUB_USERNAME') 214 | GITHUB_ACCESS_TOKEN = os.environ.get('GITHUB_ACCESS_TOKEN') 215 | 216 | LOGGING = { 217 | 'version': 1, 218 | 'disable_existing_loggers': False, 219 | 'handlers': { 220 | 'console': { 221 | 'class': 'logging.StreamHandler', 222 | }, 223 | }, 224 | 'loggers': { 225 | 'django': { 226 | 'handlers': ['console'], 227 | 'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'), 228 | }, 229 | }, 230 | } 231 | -------------------------------------------------------------------------------- /config/urls.py: -------------------------------------------------------------------------------- 1 | """beekeeper URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.11/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf.urls import url, include 17 | from django.contrib import admin 18 | 19 | from beekeeper import views as beekeeper 20 | 21 | 22 | urlpatterns = [ 23 | url(r'^admin/', admin.site.urls), 24 | url(r'^accounts/', include('django.contrib.auth.urls')), 25 | 26 | url(r'^github/', include('github.urls', namespace='github')), 27 | url(r'^projects/', include('projects.urls', namespace='projects')), 28 | 29 | url(r'^tasks/', include('aws.urls', namespace='aws')), 30 | 31 | url(r'^$', beekeeper.home, name='home') 32 | ] 33 | -------------------------------------------------------------------------------- /config/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for BeeKeeper project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.11/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docker-compose.yml.example: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | caddy: 5 | image: abiosoft/caddy 6 | command: "${CADDY_OPTIONS} 'proxy / web:8000'" 7 | ports: 8 | - "${HTTP_PORT:-8080}:80" 9 | - "${HTTPS_PORT:-8443}:443" 10 | volumes: 11 | - ./.caddy:/root/.caddy 12 | db: 13 | image: postgres:9.6.5 14 | volumes: 15 | - ./postgres_data:/var/lib/postgresql/data/ 16 | env_file: .env 17 | redis: 18 | image: "redis:alpine" 19 | migrate: 20 | build: . 21 | env_file: .env 22 | command: ./wait-for-it.sh db:5432 -- python manage.py migrate --noinput 23 | volumes: 24 | - .:/code 25 | depends_on: 26 | - db 27 | static: 28 | build: . 29 | env_file: .env 30 | command: python manage.py collectstatic --noinput 31 | volumes: 32 | - .:/code 33 | depends_on: 34 | - db 35 | - migrate 36 | web: 37 | build: . 38 | env_file: .env 39 | command: gunicorn --access-logfile - -b 0.0.0.0:8000 config.wsgi 40 | volumes: 41 | - .:/code 42 | ports: 43 | - "8000:8000" 44 | depends_on: 45 | - migrate 46 | - static 47 | - db 48 | - redis 49 | stdin_open: true 50 | tty: true 51 | environment: 52 | - REDIS_URL=redis://redis 53 | celery: 54 | build: . 55 | command: celery worker -c 2 -A config --loglevel=INFO 56 | volumes: 57 | - .:/code 58 | depends_on: 59 | - migrate 60 | - db 61 | - redis 62 | environment: 63 | - REDIS_URL=redis://redis 64 | 65 | volumes: 66 | postgres_data: -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/voc.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/voc.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/voc" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/voc" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/_static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/docs/_static/logo.png -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # BeeKeeper documentation build configuration file, created by 4 | # copying VOC documentation build configuration file (Thurs Aug 17 2017) 5 | # generated by sphinx-quickstart on Sat Jul 27 14:58:42 2013. 6 | # 7 | # This file is execfile()d with the current directory set to its containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys, os 16 | 17 | # If extensions (or modules to document with autodoc) are in another directory, 18 | # add these directories to sys.path here. If the directory is relative to the 19 | # documentation root, use os.path.abspath to make it absolute, like shown here. 20 | #sys.path.insert(0, os.path.abspath('.')) 21 | 22 | # -- General configuration ----------------------------------------------------- 23 | 24 | # If your documentation needs a minimal Sphinx version, state it here. 25 | #needs_sphinx = '1.0' 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be extensions 28 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 29 | extensions = [] 30 | 31 | # Add any paths that contain templates here, relative to this directory. 32 | templates_path = ['_templates'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'BeeKeeper' 45 | copyright = u'2017, Russell Keith-Magee' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = '0.1' 53 | # The full version, including alpha/beta/rc tags. 54 | release = '0.1' 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = ['_build'] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = 'sphinx_rtd_theme' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | #html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | #html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | #html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | #html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = 'beekeeperdoc' 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | latex_elements = { 174 | # The paper size ('letterpaper' or 'a4paper'). 175 | #'papersize': 'letterpaper', 176 | 177 | # The font size ('10pt', '11pt' or '12pt'). 178 | #'pointsize': '10pt', 179 | 180 | # Additional stuff for the LaTeX preamble. 181 | #'preamble': '', 182 | } 183 | 184 | # Grouping the document tree into LaTeX files. List of tuples 185 | # (source start file, target name, title, author, documentclass [howto/manual]). 186 | latex_documents = [ 187 | ('index', 'beekeeper.tex', u'BeeKeeper Documentation', 188 | u'Russell Keith-Magee', 'manual'), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | #latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | #latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | #latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | #latex_show_urls = False 204 | 205 | # Documents to append as an appendix to all manuals. 206 | #latex_appendices = [] 207 | 208 | # If false, no module index is generated. 209 | #latex_domain_indices = True 210 | 211 | 212 | # -- Options for manual page output -------------------------------------------- 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [ 217 | ('index', 'beekeeper', u'BeeKeeper Documentation', 218 | [u'Russell Keith-Magee'], 1) 219 | ] 220 | 221 | # If true, show URL addresses after external links. 222 | #man_show_urls = False 223 | 224 | 225 | # -- Options for Texinfo output ------------------------------------------------ 226 | 227 | # Grouping the document tree into Texinfo files. List of tuples 228 | # (source start file, target name, title, author, 229 | # dir menu entry, description, category) 230 | texinfo_documents = [ 231 | ('index', 'beekeeper', u'BeeKeeper Documentation', 232 | u'Russell Keith-Magee', 'BeeKeeper', 'Tools to look after all the little worker bees building and testing BeeWare components.', 233 | 'Miscellaneous'), 234 | ] 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #texinfo_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #texinfo_domain_indices = True 241 | 242 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 243 | #texinfo_show_urls = 'footnote' 244 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. raw:: html 2 | 3 | 26 | 27 | .. image:: _static/logo.png 28 | :target: https://pybee.org/project/projects/tools/beekeeper/ 29 | 30 | .. include:: ../README.rst 31 | 32 | .. toctree:: 33 | :maxdepth: 2 34 | :glob: 35 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. linkcheck to check all external links for integrity 37 | echo. doctest to run all doctests embedded in the documentation if enabled 38 | goto end 39 | ) 40 | 41 | if "%1" == "clean" ( 42 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 43 | del /q /s %BUILDDIR%\* 44 | goto end 45 | ) 46 | 47 | if "%1" == "html" ( 48 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 49 | if errorlevel 1 exit /b 1 50 | echo. 51 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 52 | goto end 53 | ) 54 | 55 | if "%1" == "dirhtml" ( 56 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 57 | if errorlevel 1 exit /b 1 58 | echo. 59 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 60 | goto end 61 | ) 62 | 63 | if "%1" == "singlehtml" ( 64 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 68 | goto end 69 | ) 70 | 71 | if "%1" == "pickle" ( 72 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished; now you can process the pickle files. 76 | goto end 77 | ) 78 | 79 | if "%1" == "json" ( 80 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished; now you can process the JSON files. 84 | goto end 85 | ) 86 | 87 | if "%1" == "htmlhelp" ( 88 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can run HTML Help Workshop with the ^ 92 | .hhp project file in %BUILDDIR%/htmlhelp. 93 | goto end 94 | ) 95 | 96 | if "%1" == "qthelp" ( 97 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 98 | if errorlevel 1 exit /b 1 99 | echo. 100 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 101 | .qhcp project file in %BUILDDIR%/qthelp, like this: 102 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Trebuchet.qhcp 103 | echo.To view the help file: 104 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Trebuchet.ghc 105 | goto end 106 | ) 107 | 108 | if "%1" == "devhelp" ( 109 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 110 | if errorlevel 1 exit /b 1 111 | echo. 112 | echo.Build finished. 113 | goto end 114 | ) 115 | 116 | if "%1" == "epub" ( 117 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 118 | if errorlevel 1 exit /b 1 119 | echo. 120 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 121 | goto end 122 | ) 123 | 124 | if "%1" == "latex" ( 125 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 129 | goto end 130 | ) 131 | 132 | if "%1" == "text" ( 133 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The text files are in %BUILDDIR%/text. 137 | goto end 138 | ) 139 | 140 | if "%1" == "man" ( 141 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 145 | goto end 146 | ) 147 | 148 | if "%1" == "texinfo" ( 149 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 150 | if errorlevel 1 exit /b 1 151 | echo. 152 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 153 | goto end 154 | ) 155 | 156 | if "%1" == "gettext" ( 157 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 158 | if errorlevel 1 exit /b 1 159 | echo. 160 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 161 | goto end 162 | ) 163 | 164 | if "%1" == "changes" ( 165 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 166 | if errorlevel 1 exit /b 1 167 | echo. 168 | echo.The overview file is in %BUILDDIR%/changes. 169 | goto end 170 | ) 171 | 172 | if "%1" == "linkcheck" ( 173 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 174 | if errorlevel 1 exit /b 1 175 | echo. 176 | echo.Link check complete; look for any errors in the above output ^ 177 | or in %BUILDDIR%/linkcheck/output.txt. 178 | goto end 179 | ) 180 | 181 | if "%1" == "doctest" ( 182 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 183 | if errorlevel 1 exit /b 1 184 | echo. 185 | echo.Testing of doctests in the sources finished, look at the ^ 186 | results in %BUILDDIR%/doctest/output.txt. 187 | goto end 188 | ) 189 | 190 | :end 191 | -------------------------------------------------------------------------------- /docs/sample.env: -------------------------------------------------------------------------------- 1 | SECRET_KEY=secret_squirrel 2 | AWS_ACCESS_KEY_ID=secret_key 3 | AWS_EC2_KEY_PAIR_NAME=key_name 4 | AWS_ECS_AMI=ami_name 5 | AWS_ECS_CLUSTER_NAME=cluster_name 6 | AWS_SECRET_ACCESS_KEY=access_key 7 | AWS_REGION=region 8 | AWS_ECS_SUBNET_ID=sucnet_id 9 | AWS_ECS_SECURITY_GROUP_IDS=security_group_ids 10 | BEEKEEPER_URL=url_goes_here 11 | SENDGRID_API_KEY=sendgrid_key 12 | GITHUB_WEBHOOK_KEY=webhook_key 13 | GITHUB_USERNAME=github_user 14 | GITHUB_ACCESS_TOKEN=github_token 15 | HTTP_PORT=80 16 | HTTPS_PORT=443 17 | CADDY_OPTIONS=-agree -email=your_email -host=beekeeper.beeware.org -port=443 18 | PRODUCTION=False 19 | DOCKER_CONTAINER=True 20 | POSTGRES_DB=beekeeper -------------------------------------------------------------------------------- /github/__init__.py: -------------------------------------------------------------------------------- 1 | from .hooks import ping_handler, push_handler, pull_request_handler 2 | 3 | default_app_config = 'github.apps.GithubConfig' 4 | 5 | hooks = { 6 | 'ping': ping_handler, 7 | 'push': push_handler, 8 | 'pull_request': pull_request_handler, 9 | } 10 | -------------------------------------------------------------------------------- /github/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.safestring import mark_safe 3 | 4 | from .models import User, Repository, Branch, Commit, PullRequest, PullRequestUpdate, Push 5 | 6 | 7 | @admin.register(User) 8 | class UserAdmin(admin.ModelAdmin): 9 | list_display = ['login', 'user_with_avatar'] 10 | list_filter = ['user_type'] 11 | raw_id_fields = ['user'] 12 | 13 | def user_with_avatar(self, user): 14 | return mark_safe('Github avatar for %s %s' % ( 15 | user.avatar_url, user, user 16 | )) 17 | user_with_avatar.short_description = 'User' 18 | 19 | 20 | class BranchInline(admin.TabularInline): 21 | model = Branch 22 | fields = ['name', 'active'] 23 | extra = 0 24 | 25 | 26 | @admin.register(Repository) 27 | class RepositoryAdmin(admin.ModelAdmin): 28 | list_display = ['user_with_avatar', 'name', 'description'] 29 | raw_id_fields = ['owner',] 30 | inlines = [BranchInline] 31 | 32 | def user_with_avatar(self, repo): 33 | return mark_safe('Github avatar for %s %s' % ( 34 | repo.owner.avatar_url, repo.owner, repo.owner 35 | )) 36 | user_with_avatar.short_description = 'Owner' 37 | 38 | 39 | @admin.register(Commit) 40 | class CommitAdmin(admin.ModelAdmin): 41 | list_display = ['sha', 'repository', 'user_with_avatar', 'created'] 42 | raw_id_fields = ['repository', 'user'] 43 | date_heirarchy = 'created' 44 | 45 | def user_with_avatar(self, commit): 46 | return mark_safe('Github avatar for %s %s' % ( 47 | commit.user.avatar_url, commit.user, commit.user 48 | )) 49 | user_with_avatar.short_description = 'User' 50 | 51 | 52 | class PullRequestUpdateInline(admin.TabularInline): 53 | model = PullRequestUpdate 54 | list_display = ['created', 'user_with_avatar', 'commit'] 55 | raw_id_fields = ['commit'] 56 | extra = 0 57 | 58 | def user_with_avatar(self, pru): 59 | return mark_safe('Github avatar for %s %s' % ( 60 | pru.commit.user.avatar_url, pru.commit.user, pru.commit.user 61 | )) 62 | user_with_avatar.short_description = 'User' 63 | 64 | 65 | @admin.register(PullRequest) 66 | class PullRequestAdmin(admin.ModelAdmin): 67 | list_display = ['number', 'repository', 'user_with_avatar', 'created', 'state'] 68 | list_filter = ['state'] 69 | date_heirarchy = 'created' 70 | raw_id_fields = ['repository', 'user'] 71 | inlines = [PullRequestUpdateInline] 72 | 73 | def user_with_avatar(self, pr): 74 | return mark_safe('Github avatar for %s %s' % ( 75 | pr.user.avatar_url, pr.user, pr.user 76 | )) 77 | user_with_avatar.short_description = 'User' 78 | 79 | 80 | @admin.register(Push) 81 | class PushAdmin(admin.ModelAdmin): 82 | list_display = ['commit', 'user_with_avatar', 'created'] 83 | date_heirarchy = 'created' 84 | raw_id_fields = ['commit'] 85 | 86 | def user_with_avatar(self, push): 87 | return mark_safe('Github avatar for %s %s' % ( 88 | push.commit.user.avatar_url, push.commit.user, push.commit.user 89 | )) 90 | user_with_avatar.short_description = 'user' 91 | -------------------------------------------------------------------------------- /github/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class GithubConfig(AppConfig): 5 | name = 'github' 6 | 7 | -------------------------------------------------------------------------------- /github/hooks.py: -------------------------------------------------------------------------------- 1 | from dateutil import parser as datetime_parser 2 | 3 | 4 | def get_or_create_user(user_data): 5 | "Extract and update a user from payload data" 6 | from .models import User as GithubUser 7 | 8 | try: 9 | user = GithubUser.objects.get(github_id=user_data['id']) 10 | except GithubUser.DoesNotExist: 11 | user = GithubUser(github_id=user_data['id']) 12 | 13 | user.login = user_data['login'] 14 | user.avatar_url = user_data['avatar_url'] 15 | user.html_url = user_data['html_url'] 16 | user.user_type = GithubUser.USER_TYPE_VALUES[user_data['type']] 17 | user.save() 18 | 19 | return user 20 | 21 | 22 | def get_or_create_repository(repo_data): 23 | from .models import Repository 24 | 25 | # Make sure we have a record for the owner of the repository 26 | owner = get_or_create_user(repo_data['owner']) 27 | 28 | try: 29 | repo = Repository.objects.get(github_id=repo_data['id']) 30 | except Repository.DoesNotExist: 31 | repo = Repository(github_id=repo_data['id']) 32 | 33 | repo.owner = owner 34 | repo.name = repo_data['name'] 35 | repo.html_url = repo_data['html_url'] 36 | repo.description = repo_data['description'] 37 | repo.save() 38 | 39 | return repo 40 | 41 | 42 | def ping_handler(payload): 43 | "A handler for the Github Ping message" 44 | 45 | # Make sure we have a record for the repository 46 | repo = get_or_create_repository(payload['repository']) 47 | 48 | return 'OK' 49 | 50 | 51 | def push_handler(payload): 52 | "A handler for Github push messages" 53 | from .models import Commit, Push 54 | from .signals import new_build 55 | 56 | # Make sure we have a record for the submitter of the pull 57 | user = get_or_create_user(payload['sender']) 58 | 59 | # Make sure we have a record for the repository 60 | repo = get_or_create_repository(payload['repository']) 61 | 62 | branch_name = payload['ref'][11:] 63 | if branch_name in repo.active_branch_names: 64 | # If this push is on the master branch, 65 | # make sure we have a record for the commit 66 | commit_data = payload['head_commit'] 67 | 68 | try: 69 | commit = Commit.objects.get(sha=commit_data['id']) 70 | except Commit.DoesNotExist: 71 | commit = Commit(sha=commit_data['id']) 72 | 73 | commit.repository = repo 74 | commit.user = user 75 | commit.branch_name = branch_name 76 | commit.message = commit_data['message'] 77 | commit.url = commit_data['url'] 78 | commit.created = datetime_parser.parse(commit_data['timestamp']) 79 | commit.save() 80 | 81 | # And create a push record. 82 | try: 83 | push = Push.objects.get(commit=commit) 84 | except Push.DoesNotExist: 85 | push = Push(commit=commit) 86 | push.created = datetime_parser.parse(commit_data['timestamp']) 87 | push.save() 88 | 89 | new_build.send(sender=Push, push=push) 90 | 91 | return 'OK' 92 | 93 | 94 | def pull_request_handler(payload): 95 | "A handler for pull request messages" 96 | from .models import Commit, PullRequest, PullRequestUpdate 97 | from .signals import new_build 98 | 99 | # Make sure we have a record for the submitter of the PR 100 | submitter = get_or_create_user(payload['pull_request']['user']) 101 | 102 | # Make sure we have a record for the repository 103 | repo = get_or_create_repository(payload['repository']) 104 | 105 | # Make sure we have a record of the head commit 106 | commit_sha = payload['pull_request']['head']['sha'] 107 | try: 108 | commit = Commit.objects.get(sha=commit_sha) 109 | except Commit.DoesNotExist: 110 | # For some reason, Github doesn't expose the commit 111 | # message in the pull request payload. 112 | from github3 import GitHub 113 | from django.conf import settings 114 | gh_session = GitHub( 115 | settings.GITHUB_USERNAME, 116 | password=settings.GITHUB_ACCESS_TOKEN 117 | ) 118 | gh_repo = gh_session.repository( 119 | repo.owner.login, 120 | repo.name 121 | ) 122 | gh_commit = gh_repo.commit(commit_sha) 123 | 124 | commit = Commit.objects.create( 125 | repository=repo, 126 | sha=commit_sha, 127 | user=submitter, 128 | message=gh_commit.commit.message, 129 | branch_name=payload['pull_request']['head']['ref'], 130 | created=datetime_parser.parse(payload['pull_request']['updated_at']), 131 | url='https://github.com/%s/%s/commit/%s' % ( 132 | repo.owner.login, 133 | repo.name, 134 | commit_sha 135 | ) 136 | ) 137 | 138 | # Make sure we have a record for the PR 139 | pr_data = payload['pull_request'] 140 | try: 141 | pr = PullRequest.objects.get(github_id=pr_data['id']) 142 | except PullRequest.DoesNotExist: 143 | pr = PullRequest(github_id=pr_data['id']) 144 | 145 | pr.user = submitter 146 | pr.repository = repo 147 | pr.number = pr_data['number'] 148 | pr.html_url = pr_data['html_url'] 149 | pr.diff_url = pr_data['diff_url'] 150 | pr.patch_url = pr_data['patch_url'] 151 | pr.state = PullRequest.STATE_VALUES[pr_data['state']] 152 | pr.title = pr_data['title'] 153 | pr.created = datetime_parser.parse(pr_data['created_at']) 154 | pr.updated = datetime_parser.parse(pr_data['updated_at']) 155 | pr.save() 156 | 157 | # And create a pull request update for this PR. 158 | try: 159 | update = PullRequestUpdate.objects.get(pull_request=pr, commit=commit) 160 | except PullRequestUpdate.DoesNotExist: 161 | update = PullRequestUpdate( 162 | pull_request=pr, 163 | commit=commit, 164 | ) 165 | update.created = datetime_parser.parse(pr_data['updated_at']) 166 | update.save() 167 | 168 | if payload['action'] in ['opened', 'synchronize']: 169 | new_build.send(sender=PullRequestUpdate, update=update) 170 | elif payload['action'] == 'closed': 171 | for change in pr.changes.active(): 172 | change.complete() 173 | 174 | return 'OK' 175 | -------------------------------------------------------------------------------- /github/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/github/management/__init__.py -------------------------------------------------------------------------------- /github/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/github/management/commands/__init__.py -------------------------------------------------------------------------------- /github/management/commands/replay.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | 5 | from django.core.management.base import BaseCommand 6 | from github import hooks as github_hooks 7 | 8 | 9 | class Command(BaseCommand): 10 | help = 'Replay a directory of Github webhook data.' 11 | missing_args_message = ( 12 | "No replay directory specified. Please provide the path of at least " 13 | "one replay directory in the command line." 14 | ) 15 | 16 | def add_arguments(self, parser): 17 | parser.add_argument('args', metavar='fixture', nargs='+', help='Replay directories.') 18 | 19 | def handle(self, *fixture_labels, **options): 20 | self.verbosity = options['verbosity'] 21 | 22 | self.replay(fixture_labels) 23 | 24 | def replay(self, fixture_labels): 25 | for label in fixture_labels: 26 | for filename in sorted(os.listdir(os.path.abspath(label))): 27 | try: 28 | index, hook_type, description, filetype = filename.split('.') 29 | if self.verbosity >= 1: 30 | self.stdout.write( 31 | "Replaying %s event: %s..." % (hook_type, description) 32 | ) 33 | 34 | with open(os.path.join(os.path.abspath(label), filename)) as data: 35 | payload = json.load(data) 36 | 37 | github_hooks[hook_type](payload) 38 | time.sleep(1) 39 | except ValueError: 40 | self.stderr.write('Ignoring file %s' % filename) 41 | except KeyError: 42 | if self.verbosity >= 1: 43 | self.stdout.write( 44 | "No handler for %s events" % hook_type 45 | ) 46 | -------------------------------------------------------------------------------- /github/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-02 08:28 3 | from __future__ import unicode_literals 4 | 5 | from django.conf import settings 6 | from django.db import migrations, models 7 | import django.db.models.deletion 8 | import django.utils.timezone 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Commit', 22 | fields=[ 23 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 24 | ('branch', models.CharField(db_index=True, max_length=100)), 25 | ('sha', models.CharField(db_index=True, max_length=40)), 26 | ('created', models.DateTimeField()), 27 | ('message', models.TextField()), 28 | ('url', models.URLField()), 29 | ], 30 | options={ 31 | 'ordering': ('created',), 32 | }, 33 | ), 34 | migrations.CreateModel( 35 | name='PullRequest', 36 | fields=[ 37 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 38 | ('number', models.IntegerField(db_index=True)), 39 | ('github_id', models.IntegerField(db_index=True)), 40 | ('created', models.DateTimeField()), 41 | ('updated', models.DateTimeField(auto_now=True)), 42 | ('title', models.CharField(max_length=100)), 43 | ('html_url', models.URLField()), 44 | ('diff_url', models.URLField()), 45 | ('patch_url', models.URLField()), 46 | ('state', models.IntegerField(choices=[(10, 'Open'), (100, 'Closed')], default=10)), 47 | ], 48 | options={ 49 | 'ordering': ('number',), 50 | }, 51 | ), 52 | migrations.CreateModel( 53 | name='PullRequestUpdate', 54 | fields=[ 55 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 56 | ('created', models.DateTimeField()), 57 | ('commit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pull_request_updates', to='github.Commit')), 58 | ('pull_request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='updates', to='github.PullRequest')), 59 | ], 60 | options={ 61 | 'ordering': ('created',), 62 | }, 63 | ), 64 | migrations.CreateModel( 65 | name='Push', 66 | fields=[ 67 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 68 | ('created', models.DateTimeField()), 69 | ('commit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pushes', to='github.Commit')), 70 | ], 71 | options={ 72 | 'ordering': ('created',), 73 | }, 74 | ), 75 | migrations.CreateModel( 76 | name='Repository', 77 | fields=[ 78 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 79 | ('name', models.CharField(db_index=True, max_length=100)), 80 | ('github_id', models.IntegerField(db_index=True)), 81 | ('created', models.DateTimeField(default=django.utils.timezone.now)), 82 | ('updated', models.DateTimeField(auto_now=True)), 83 | ('html_url', models.URLField()), 84 | ('description', models.CharField(max_length=500)), 85 | ], 86 | options={ 87 | 'verbose_name_plural': 'repositories', 88 | 'ordering': ('name',), 89 | }, 90 | ), 91 | migrations.CreateModel( 92 | name='User', 93 | fields=[ 94 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 95 | ('github_id', models.IntegerField(db_index=True)), 96 | ('login', models.CharField(db_index=True, max_length=100)), 97 | ('avatar_url', models.URLField()), 98 | ('html_url', models.URLField()), 99 | ('user_type', models.IntegerField(choices=[(10, 'User'), (20, 'Organization')], default=10)), 100 | ('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='github_user', to=settings.AUTH_USER_MODEL)), 101 | ], 102 | options={ 103 | 'ordering': ('login',), 104 | }, 105 | ), 106 | migrations.AddField( 107 | model_name='repository', 108 | name='owner', 109 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='repositories', to='github.User'), 110 | ), 111 | migrations.AddField( 112 | model_name='pullrequest', 113 | name='repository', 114 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pull_requests', to='github.Repository'), 115 | ), 116 | migrations.AddField( 117 | model_name='pullrequest', 118 | name='user', 119 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='pull_requests', to='github.User'), 120 | ), 121 | migrations.AddField( 122 | model_name='commit', 123 | name='repository', 124 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commits', to='github.Repository'), 125 | ), 126 | migrations.AddField( 127 | model_name='commit', 128 | name='user', 129 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commits', to='github.User'), 130 | ), 131 | ] 132 | -------------------------------------------------------------------------------- /github/migrations/0002_tweak_ordering.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-08 01:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('github', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='push', 17 | options={'ordering': ('created',), 'verbose_name_plural': 'pushes'}, 18 | ), 19 | migrations.AlterField( 20 | model_name='pullrequest', 21 | name='updated', 22 | field=models.DateTimeField(), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /github/migrations/0003_add_branches.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-11 05:45 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('github', '0002_tweak_ordering'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Branch', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('name', models.CharField(max_length=100)), 21 | ('active', models.BooleanField(default=True)), 22 | ], 23 | options={ 24 | 'verbose_name_plural': 'branches', 25 | 'ordering': ('name',), 26 | }, 27 | ), 28 | migrations.AlterModelOptions( 29 | name='pullrequest', 30 | options={'ordering': ('repository__name', 'number')}, 31 | ), 32 | migrations.RenameField( 33 | model_name='commit', 34 | old_name='branch', 35 | new_name='branch_name', 36 | ), 37 | migrations.AddField( 38 | model_name='repository', 39 | name='master_branch_name', 40 | field=models.CharField(default='master', max_length=100), 41 | ), 42 | migrations.AddField( 43 | model_name='branch', 44 | name='repository', 45 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='branches', to='github.Repository'), 46 | ), 47 | ] 48 | -------------------------------------------------------------------------------- /github/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/github/migrations/__init__.py -------------------------------------------------------------------------------- /github/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db import models 3 | from django.utils import timezone 4 | 5 | 6 | class User(models.Model): 7 | USER_TYPE_USER = 10 8 | USER_TYPE_ORGANIZATION = 20 9 | USER_TYPE_CHOICES = [ 10 | (USER_TYPE_USER, "User"), 11 | (USER_TYPE_ORGANIZATION, "Organization"), 12 | ] 13 | USER_TYPE_VALUES = { 14 | "User": USER_TYPE_USER, 15 | "Organization": USER_TYPE_ORGANIZATION, 16 | } 17 | 18 | user = models.OneToOneField(settings.AUTH_USER_MODEL, null=True, blank=True, related_name='github_user') 19 | github_id = models.IntegerField(db_index=True) 20 | login = models.CharField(max_length=100, db_index=True) 21 | avatar_url = models.URLField() 22 | html_url = models.URLField() 23 | user_type = models.IntegerField(choices=USER_TYPE_CHOICES, default=USER_TYPE_USER) 24 | 25 | class Meta: 26 | ordering = ('login',) 27 | 28 | def __str__(self): 29 | return "@%s" % self.login 30 | 31 | 32 | class Repository(models.Model): 33 | owner = models.ForeignKey(User, related_name='repositories') 34 | name = models.CharField(max_length=100, db_index=True) 35 | github_id = models.IntegerField(db_index=True) 36 | 37 | created = models.DateTimeField(default=timezone.now) 38 | updated = models.DateTimeField(auto_now=True) 39 | 40 | html_url = models.URLField() 41 | description = models.CharField(max_length=500) 42 | 43 | master_branch_name = models.CharField(max_length=100, default="master") 44 | 45 | class Meta: 46 | verbose_name_plural = 'repositories' 47 | ordering = ('name',) 48 | 49 | def __str__(self): 50 | return "github:%s" % self.full_name 51 | 52 | def save(self, *args, **kwargs): 53 | super().save(*args, **kwargs) 54 | 55 | # Create a master branch if there are no branches. 56 | if not self.branches.exists(): 57 | self.branches.create(name='master') 58 | 59 | @property 60 | def full_name(self): 61 | return '%s/%s' % (self.owner.login, self.name) 62 | 63 | @property 64 | def active_branch_names(self): 65 | return set(self.branches.filter(active=True).values_list('name', flat=True)) 66 | 67 | 68 | class Branch(models.Model): 69 | repository = models.ForeignKey(Repository, related_name='branches') 70 | name = models.CharField(max_length=100) 71 | 72 | active = models.BooleanField(default=True) 73 | 74 | class Meta: 75 | verbose_name_plural = 'branches' 76 | ordering = ('name',) 77 | 78 | def __str__(self): 79 | return self.name 80 | 81 | 82 | class Commit(models.Model): 83 | repository = models.ForeignKey(Repository, related_name='commits') 84 | branch_name = models.CharField(max_length=100, db_index=True) 85 | sha = models.CharField(max_length=40, db_index=True) 86 | user = models.ForeignKey(User, related_name='commits') 87 | 88 | created = models.DateTimeField() 89 | 90 | message = models.TextField() 91 | url = models.URLField() 92 | 93 | class Meta: 94 | ordering = ('created',) 95 | 96 | def __str__(self): 97 | return "Commit %s on %s" % (self.sha, self.repository) 98 | 99 | @property 100 | def display_sha(self): 101 | return self.sha[:8] 102 | 103 | @property 104 | def title(self): 105 | return self.message.split('\n', 1)[0] 106 | 107 | 108 | class PullRequestQuerySet(models.QuerySet): 109 | def open(self): 110 | return self.filter(state=PullRequest.STATE_OPEN) 111 | 112 | def closed(self): 113 | return self.filter(state=PullRequest.STATE_CLOSED) 114 | 115 | 116 | class PullRequest(models.Model): 117 | STATE_OPEN = 10 118 | STATE_CLOSED = 100 119 | STATE_CHOICES = [ 120 | (STATE_OPEN, 'Open'), 121 | (STATE_CLOSED, 'Closed'), 122 | ] 123 | STATE_VALUES = { 124 | 'open': STATE_OPEN, 125 | 'closed': STATE_CLOSED, 126 | } 127 | 128 | objects = PullRequestQuerySet.as_manager() 129 | 130 | repository = models.ForeignKey(Repository, related_name='pull_requests') 131 | number = models.IntegerField(db_index=True) 132 | github_id = models.IntegerField(db_index=True) 133 | 134 | created = models.DateTimeField() 135 | updated = models.DateTimeField() 136 | 137 | user = models.ForeignKey(User, related_name='pull_requests') 138 | title = models.CharField(max_length=100) 139 | html_url = models.URLField() 140 | diff_url = models.URLField() 141 | patch_url = models.URLField() 142 | state = models.IntegerField(choices=STATE_CHOICES, default=STATE_OPEN) 143 | 144 | class Meta: 145 | ordering = ('repository__name', 'number',) 146 | 147 | def __str__(self): 148 | return "PR %s on %s" % (self.number, self.repository) 149 | 150 | 151 | class PullRequestUpdate(models.Model): 152 | pull_request = models.ForeignKey(PullRequest, related_name='updates') 153 | commit = models.ForeignKey(Commit, related_name='pull_request_updates') 154 | 155 | created = models.DateTimeField() 156 | 157 | class Meta: 158 | ordering = ('created',) 159 | 160 | def __str__(self): 161 | return "Update %s to PR %s on %s" % ( 162 | self.commit.sha, self.pull_request.number, self.pull_request.repository 163 | ) 164 | 165 | 166 | class Push(models.Model): 167 | commit = models.ForeignKey(Commit, related_name='pushes') 168 | 169 | created = models.DateTimeField() 170 | 171 | class Meta: 172 | verbose_name_plural = 'pushes' 173 | ordering = ('created',) 174 | 175 | def __str__(self): 176 | return "Push %s to branch %s on %s" % ( 177 | self.commit.sha, self.commit.branch_name, self.commit.repository 178 | ) 179 | -------------------------------------------------------------------------------- /github/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | new_build = Signal() 4 | -------------------------------------------------------------------------------- /github/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/github/tests/__init__.py -------------------------------------------------------------------------------- /github/tests/create_repo/0002.ping.Create briefcase repo.json: -------------------------------------------------------------------------------- 1 | { 2 | "zen": "Responsive is better than fast.", 3 | "hook_id": 14721260, 4 | "hook": { 5 | "type": "Repository", 6 | "id": 14721260, 7 | "name": "web", 8 | "active": true, 9 | "events": [ 10 | "*" 11 | ], 12 | "config": { 13 | "content_type": "form", 14 | "insecure_ssl": "0", 15 | "secret": "********", 16 | "url": "https://beekeeper.herokuapp.com/github/notify" 17 | }, 18 | "updated_at": "2017-07-04T02:14:00Z", 19 | "created_at": "2017-07-04T02:14:00Z", 20 | "url": "https://api.github.com/repos/pybee/briefcase/hooks/14721260", 21 | "test_url": "https://api.github.com/repos/pybee/briefcase/hooks/14721260/test", 22 | "ping_url": "https://api.github.com/repos/pybee/briefcase/hooks/14721260/pings", 23 | "last_response": { 24 | "code": null, 25 | "status": "unused", 26 | "message": null 27 | } 28 | }, 29 | "repository": { 30 | "id": 39841700, 31 | "name": "briefcase", 32 | "full_name": "pybee/briefcase", 33 | "owner": { 34 | "login": "pybee", 35 | "id": 5001767, 36 | "avatar_url": "https://avatars3.githubusercontent.com/u/5001767?v=3", 37 | "gravatar_id": "", 38 | "url": "https://api.github.com/users/pybee", 39 | "html_url": "https://github.com/pybee", 40 | "followers_url": "https://api.github.com/users/pybee/followers", 41 | "following_url": "https://api.github.com/users/pybee/following{/other_user}", 42 | "gists_url": "https://api.github.com/users/pybee/gists{/gist_id}", 43 | "starred_url": "https://api.github.com/users/pybee/starred{/owner}{/repo}", 44 | "subscriptions_url": "https://api.github.com/users/pybee/subscriptions", 45 | "organizations_url": "https://api.github.com/users/pybee/orgs", 46 | "repos_url": "https://api.github.com/users/pybee/repos", 47 | "events_url": "https://api.github.com/users/pybee/events{/privacy}", 48 | "received_events_url": "https://api.github.com/users/pybee/received_events", 49 | "type": "Organization", 50 | "site_admin": false 51 | }, 52 | "private": false, 53 | "html_url": "https://github.com/pybee/briefcase", 54 | "description": "Tools to support converting a Python project into a standalone native application.", 55 | "fork": false, 56 | "url": "https://api.github.com/repos/pybee/briefcase", 57 | "forks_url": "https://api.github.com/repos/pybee/briefcase/forks", 58 | "keys_url": "https://api.github.com/repos/pybee/briefcase/keys{/key_id}", 59 | "collaborators_url": "https://api.github.com/repos/pybee/briefcase/collaborators{/collaborator}", 60 | "teams_url": "https://api.github.com/repos/pybee/briefcase/teams", 61 | "hooks_url": "https://api.github.com/repos/pybee/briefcase/hooks", 62 | "issue_events_url": "https://api.github.com/repos/pybee/briefcase/issues/events{/number}", 63 | "events_url": "https://api.github.com/repos/pybee/briefcase/events", 64 | "assignees_url": "https://api.github.com/repos/pybee/briefcase/assignees{/user}", 65 | "branches_url": "https://api.github.com/repos/pybee/briefcase/branches{/branch}", 66 | "tags_url": "https://api.github.com/repos/pybee/briefcase/tags", 67 | "blobs_url": "https://api.github.com/repos/pybee/briefcase/git/blobs{/sha}", 68 | "git_tags_url": "https://api.github.com/repos/pybee/briefcase/git/tags{/sha}", 69 | "git_refs_url": "https://api.github.com/repos/pybee/briefcase/git/refs{/sha}", 70 | "trees_url": "https://api.github.com/repos/pybee/briefcase/git/trees{/sha}", 71 | "statuses_url": "https://api.github.com/repos/pybee/briefcase/statuses/{sha}", 72 | "languages_url": "https://api.github.com/repos/pybee/briefcase/languages", 73 | "stargazers_url": "https://api.github.com/repos/pybee/briefcase/stargazers", 74 | "contributors_url": "https://api.github.com/repos/pybee/briefcase/contributors", 75 | "subscribers_url": "https://api.github.com/repos/pybee/briefcase/subscribers", 76 | "subscription_url": "https://api.github.com/repos/pybee/briefcase/subscription", 77 | "commits_url": "https://api.github.com/repos/pybee/briefcase/commits{/sha}", 78 | "git_commits_url": "https://api.github.com/repos/pybee/briefcase/git/commits{/sha}", 79 | "comments_url": "https://api.github.com/repos/pybee/briefcase/comments{/number}", 80 | "issue_comment_url": "https://api.github.com/repos/pybee/briefcase/issues/comments{/number}", 81 | "contents_url": "https://api.github.com/repos/pybee/briefcase/contents/{+path}", 82 | "compare_url": "https://api.github.com/repos/pybee/briefcase/compare/{base}...{head}", 83 | "merges_url": "https://api.github.com/repos/pybee/briefcase/merges", 84 | "archive_url": "https://api.github.com/repos/pybee/briefcase/{archive_format}{/ref}", 85 | "downloads_url": "https://api.github.com/repos/pybee/briefcase/downloads", 86 | "issues_url": "https://api.github.com/repos/pybee/briefcase/issues{/number}", 87 | "pulls_url": "https://api.github.com/repos/pybee/briefcase/pulls{/number}", 88 | "milestones_url": "https://api.github.com/repos/pybee/briefcase/milestones{/number}", 89 | "notifications_url": "https://api.github.com/repos/pybee/briefcase/notifications{?since,all,participating}", 90 | "labels_url": "https://api.github.com/repos/pybee/briefcase/labels{/name}", 91 | "releases_url": "https://api.github.com/repos/pybee/briefcase/releases{/id}", 92 | "deployments_url": "https://api.github.com/repos/pybee/briefcase/deployments", 93 | "created_at": "2015-07-28T15:20:03Z", 94 | "updated_at": "2017-06-29T14:12:20Z", 95 | "pushed_at": "2017-07-04T00:45:15Z", 96 | "git_url": "git://github.com/pybee/briefcase.git", 97 | "ssh_url": "git@github.com:pybee/briefcase.git", 98 | "clone_url": "https://github.com/pybee/briefcase.git", 99 | "svn_url": "https://github.com/pybee/briefcase", 100 | "homepage": "https://briefcase.readthedocs.io/", 101 | "size": 278, 102 | "stargazers_count": 188, 103 | "watchers_count": 188, 104 | "language": "Python", 105 | "has_issues": true, 106 | "has_projects": true, 107 | "has_downloads": true, 108 | "has_wiki": true, 109 | "has_pages": false, 110 | "forks_count": 25, 111 | "mirror_url": null, 112 | "open_issues_count": 17, 113 | "forks": 25, 114 | "open_issues": 17, 115 | "watchers": 188, 116 | "default_branch": "master" 117 | }, 118 | "sender": { 119 | "login": "freakboy3742", 120 | "id": 37345, 121 | "avatar_url": "https://avatars2.githubusercontent.com/u/37345?v=3", 122 | "gravatar_id": "", 123 | "url": "https://api.github.com/users/freakboy3742", 124 | "html_url": "https://github.com/freakboy3742", 125 | "followers_url": "https://api.github.com/users/freakboy3742/followers", 126 | "following_url": "https://api.github.com/users/freakboy3742/following{/other_user}", 127 | "gists_url": "https://api.github.com/users/freakboy3742/gists{/gist_id}", 128 | "starred_url": "https://api.github.com/users/freakboy3742/starred{/owner}{/repo}", 129 | "subscriptions_url": "https://api.github.com/users/freakboy3742/subscriptions", 130 | "organizations_url": "https://api.github.com/users/freakboy3742/orgs", 131 | "repos_url": "https://api.github.com/users/freakboy3742/repos", 132 | "events_url": "https://api.github.com/users/freakboy3742/events{/privacy}", 133 | "received_events_url": "https://api.github.com/users/freakboy3742/received_events", 134 | "type": "User", 135 | "site_admin": false 136 | } 137 | } -------------------------------------------------------------------------------- /github/tests/replay/0007.push.Single commit to master.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/heads/master", 3 | "before": "02bc552855735a0a4f74bfe2d8d2011bc003460c", 4 | "after": "ada065a187f4e27f16de633a06ee7cf57679a805", 5 | "created": false, 6 | "deleted": false, 7 | "forced": false, 8 | "base_ref": null, 9 | "compare": "https://github.com/pybee/webhook-trigger/compare/02bc55285573...ada065a187f4", 10 | "commits": [ 11 | { 12 | "id": "ada065a187f4e27f16de633a06ee7cf57679a805", 13 | "tree_id": "030086eb901e7fd83002a560ce5bcdfce8e55dfe", 14 | "distinct": true, 15 | "message": "Commit directly to mainline.", 16 | "timestamp": "2017-06-26T10:26:50+08:00", 17 | "url": "https://github.com/pybee/webhook-trigger/commit/ada065a187f4e27f16de633a06ee7cf57679a805", 18 | "author": { 19 | "name": "Russell Keith-Magee", 20 | "email": "russell@keith-magee.com", 21 | "username": "freakboy3742" 22 | }, 23 | "committer": { 24 | "name": "Russell Keith-Magee", 25 | "email": "russell@keith-magee.com", 26 | "username": "freakboy3742" 27 | }, 28 | "added": [ 29 | 30 | ], 31 | "removed": [ 32 | 33 | ], 34 | "modified": [ 35 | "README.md" 36 | ] 37 | } 38 | ], 39 | "head_commit": { 40 | "id": "ada065a187f4e27f16de633a06ee7cf57679a805", 41 | "tree_id": "030086eb901e7fd83002a560ce5bcdfce8e55dfe", 42 | "distinct": true, 43 | "message": "Commit directly to mainline.", 44 | "timestamp": "2017-06-26T10:26:50+08:00", 45 | "url": "https://github.com/pybee/webhook-trigger/commit/ada065a187f4e27f16de633a06ee7cf57679a805", 46 | "author": { 47 | "name": "Russell Keith-Magee", 48 | "email": "russell@keith-magee.com", 49 | "username": "freakboy3742" 50 | }, 51 | "committer": { 52 | "name": "Russell Keith-Magee", 53 | "email": "russell@keith-magee.com", 54 | "username": "freakboy3742" 55 | }, 56 | "added": [ 57 | 58 | ], 59 | "removed": [ 60 | 61 | ], 62 | "modified": [ 63 | "README.md" 64 | ] 65 | }, 66 | "repository": { 67 | "id": 95284391, 68 | "name": "webhook-trigger", 69 | "full_name": "pybee/webhook-trigger", 70 | "owner": { 71 | "name": "pybee", 72 | "email": "", 73 | "login": "pybee", 74 | "id": 5001767, 75 | "avatar_url": "https://avatars3.githubusercontent.com/u/5001767?v=3", 76 | "gravatar_id": "", 77 | "url": "https://api.github.com/users/pybee", 78 | "html_url": "https://github.com/pybee", 79 | "followers_url": "https://api.github.com/users/pybee/followers", 80 | "following_url": "https://api.github.com/users/pybee/following{/other_user}", 81 | "gists_url": "https://api.github.com/users/pybee/gists{/gist_id}", 82 | "starred_url": "https://api.github.com/users/pybee/starred{/owner}{/repo}", 83 | "subscriptions_url": "https://api.github.com/users/pybee/subscriptions", 84 | "organizations_url": "https://api.github.com/users/pybee/orgs", 85 | "repos_url": "https://api.github.com/users/pybee/repos", 86 | "events_url": "https://api.github.com/users/pybee/events{/privacy}", 87 | "received_events_url": "https://api.github.com/users/pybee/received_events", 88 | "type": "Organization", 89 | "site_admin": false 90 | }, 91 | "private": true, 92 | "html_url": "https://github.com/pybee/webhook-trigger", 93 | "description": "A test repository that can be used to test Github web hooks", 94 | "fork": false, 95 | "url": "https://github.com/pybee/webhook-trigger", 96 | "forks_url": "https://api.github.com/repos/pybee/webhook-trigger/forks", 97 | "keys_url": "https://api.github.com/repos/pybee/webhook-trigger/keys{/key_id}", 98 | "collaborators_url": "https://api.github.com/repos/pybee/webhook-trigger/collaborators{/collaborator}", 99 | "teams_url": "https://api.github.com/repos/pybee/webhook-trigger/teams", 100 | "hooks_url": "https://api.github.com/repos/pybee/webhook-trigger/hooks", 101 | "issue_events_url": "https://api.github.com/repos/pybee/webhook-trigger/issues/events{/number}", 102 | "events_url": "https://api.github.com/repos/pybee/webhook-trigger/events", 103 | "assignees_url": "https://api.github.com/repos/pybee/webhook-trigger/assignees{/user}", 104 | "branches_url": "https://api.github.com/repos/pybee/webhook-trigger/branches{/branch}", 105 | "tags_url": "https://api.github.com/repos/pybee/webhook-trigger/tags", 106 | "blobs_url": "https://api.github.com/repos/pybee/webhook-trigger/git/blobs{/sha}", 107 | "git_tags_url": "https://api.github.com/repos/pybee/webhook-trigger/git/tags{/sha}", 108 | "git_refs_url": "https://api.github.com/repos/pybee/webhook-trigger/git/refs{/sha}", 109 | "trees_url": "https://api.github.com/repos/pybee/webhook-trigger/git/trees{/sha}", 110 | "statuses_url": "https://api.github.com/repos/pybee/webhook-trigger/statuses/{sha}", 111 | "languages_url": "https://api.github.com/repos/pybee/webhook-trigger/languages", 112 | "stargazers_url": "https://api.github.com/repos/pybee/webhook-trigger/stargazers", 113 | "contributors_url": "https://api.github.com/repos/pybee/webhook-trigger/contributors", 114 | "subscribers_url": "https://api.github.com/repos/pybee/webhook-trigger/subscribers", 115 | "subscription_url": "https://api.github.com/repos/pybee/webhook-trigger/subscription", 116 | "commits_url": "https://api.github.com/repos/pybee/webhook-trigger/commits{/sha}", 117 | "git_commits_url": "https://api.github.com/repos/pybee/webhook-trigger/git/commits{/sha}", 118 | "comments_url": "https://api.github.com/repos/pybee/webhook-trigger/comments{/number}", 119 | "issue_comment_url": "https://api.github.com/repos/pybee/webhook-trigger/issues/comments{/number}", 120 | "contents_url": "https://api.github.com/repos/pybee/webhook-trigger/contents/{+path}", 121 | "compare_url": "https://api.github.com/repos/pybee/webhook-trigger/compare/{base}...{head}", 122 | "merges_url": "https://api.github.com/repos/pybee/webhook-trigger/merges", 123 | "archive_url": "https://api.github.com/repos/pybee/webhook-trigger/{archive_format}{/ref}", 124 | "downloads_url": "https://api.github.com/repos/pybee/webhook-trigger/downloads", 125 | "issues_url": "https://api.github.com/repos/pybee/webhook-trigger/issues{/number}", 126 | "pulls_url": "https://api.github.com/repos/pybee/webhook-trigger/pulls{/number}", 127 | "milestones_url": "https://api.github.com/repos/pybee/webhook-trigger/milestones{/number}", 128 | "notifications_url": "https://api.github.com/repos/pybee/webhook-trigger/notifications{?since,all,participating}", 129 | "labels_url": "https://api.github.com/repos/pybee/webhook-trigger/labels{/name}", 130 | "releases_url": "https://api.github.com/repos/pybee/webhook-trigger/releases{/id}", 131 | "deployments_url": "https://api.github.com/repos/pybee/webhook-trigger/deployments", 132 | "created_at": 1498291581, 133 | "updated_at": "2017-06-25T00:39:15Z", 134 | "pushed_at": 1498444021, 135 | "git_url": "git://github.com/pybee/webhook-trigger.git", 136 | "ssh_url": "git@github.com:pybee/webhook-trigger.git", 137 | "clone_url": "https://github.com/pybee/webhook-trigger.git", 138 | "svn_url": "https://github.com/pybee/webhook-trigger", 139 | "homepage": null, 140 | "size": 2, 141 | "stargazers_count": 0, 142 | "watchers_count": 0, 143 | "language": null, 144 | "has_issues": true, 145 | "has_projects": true, 146 | "has_downloads": true, 147 | "has_wiki": true, 148 | "has_pages": false, 149 | "forks_count": 1, 150 | "mirror_url": null, 151 | "open_issues_count": 1, 152 | "forks": 1, 153 | "open_issues": 1, 154 | "watchers": 0, 155 | "default_branch": "master", 156 | "stargazers": 0, 157 | "master_branch": "master", 158 | "organization": "pybee" 159 | }, 160 | "pusher": { 161 | "name": "freakboy3742", 162 | "email": "russell@keith-magee.com" 163 | }, 164 | "organization": { 165 | "login": "pybee", 166 | "id": 5001767, 167 | "url": "https://api.github.com/orgs/pybee", 168 | "repos_url": "https://api.github.com/orgs/pybee/repos", 169 | "events_url": "https://api.github.com/orgs/pybee/events", 170 | "hooks_url": "https://api.github.com/orgs/pybee/hooks", 171 | "issues_url": "https://api.github.com/orgs/pybee/issues", 172 | "members_url": "https://api.github.com/orgs/pybee/members{/member}", 173 | "public_members_url": "https://api.github.com/orgs/pybee/public_members{/member}", 174 | "avatar_url": "https://avatars3.githubusercontent.com/u/5001767?v=3", 175 | "description": "The IDEs of Python!" 176 | }, 177 | "sender": { 178 | "login": "freakboy3742", 179 | "id": 37345, 180 | "avatar_url": "https://avatars2.githubusercontent.com/u/37345?v=3", 181 | "gravatar_id": "", 182 | "url": "https://api.github.com/users/freakboy3742", 183 | "html_url": "https://github.com/freakboy3742", 184 | "followers_url": "https://api.github.com/users/freakboy3742/followers", 185 | "following_url": "https://api.github.com/users/freakboy3742/following{/other_user}", 186 | "gists_url": "https://api.github.com/users/freakboy3742/gists{/gist_id}", 187 | "starred_url": "https://api.github.com/users/freakboy3742/starred{/owner}{/repo}", 188 | "subscriptions_url": "https://api.github.com/users/freakboy3742/subscriptions", 189 | "organizations_url": "https://api.github.com/users/freakboy3742/orgs", 190 | "repos_url": "https://api.github.com/users/freakboy3742/repos", 191 | "events_url": "https://api.github.com/users/freakboy3742/events{/privacy}", 192 | "received_events_url": "https://api.github.com/users/freakboy3742/received_events", 193 | "type": "User", 194 | "site_admin": false 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /github/tests/replay/0009.push.Commit to branch.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/heads/beekeeper-config", 3 | "before": "0000000000000000000000000000000000000000", 4 | "after": "417bdde21ac32122883ce3eabe7e7d1d969ddaf3", 5 | "created": true, 6 | "deleted": false, 7 | "forced": false, 8 | "base_ref": null, 9 | "compare": "https://github.com/pybee/webhook-trigger/commit/417bdde21ac3", 10 | "commits": [ 11 | { 12 | "id": "417bdde21ac32122883ce3eabe7e7d1d969ddaf3", 13 | "tree_id": "d899d916a92374e8b4145bd7fb03a23b37d3fcb1", 14 | "distinct": true, 15 | "message": "Added Beekeeper config.", 16 | "timestamp": "2017-06-26T16:00:26+08:00", 17 | "url": "https://github.com/pybee/webhook-trigger/commit/417bdde21ac32122883ce3eabe7e7d1d969ddaf3", 18 | "author": { 19 | "name": "Russell Keith-Magee", 20 | "email": "russell@keith-magee.com", 21 | "username": "freakboy3742" 22 | }, 23 | "committer": { 24 | "name": "Russell Keith-Magee", 25 | "email": "russell@keith-magee.com", 26 | "username": "freakboy3742" 27 | }, 28 | "added": [ 29 | "beekeeper.yml" 30 | ], 31 | "removed": [ 32 | 33 | ], 34 | "modified": [ 35 | 36 | ] 37 | } 38 | ], 39 | "head_commit": { 40 | "id": "417bdde21ac32122883ce3eabe7e7d1d969ddaf3", 41 | "tree_id": "d899d916a92374e8b4145bd7fb03a23b37d3fcb1", 42 | "distinct": true, 43 | "message": "Added Beekeeper config.", 44 | "timestamp": "2017-06-26T16:00:26+08:00", 45 | "url": "https://github.com/pybee/webhook-trigger/commit/417bdde21ac32122883ce3eabe7e7d1d969ddaf3", 46 | "author": { 47 | "name": "Russell Keith-Magee", 48 | "email": "russell@keith-magee.com", 49 | "username": "freakboy3742" 50 | }, 51 | "committer": { 52 | "name": "Russell Keith-Magee", 53 | "email": "russell@keith-magee.com", 54 | "username": "freakboy3742" 55 | }, 56 | "added": [ 57 | "beekeeper.yml" 58 | ], 59 | "removed": [ 60 | 61 | ], 62 | "modified": [ 63 | 64 | ] 65 | }, 66 | "repository": { 67 | "id": 95284391, 68 | "name": "webhook-trigger", 69 | "full_name": "pybee/webhook-trigger", 70 | "owner": { 71 | "name": "pybee", 72 | "email": "", 73 | "login": "pybee", 74 | "id": 5001767, 75 | "avatar_url": "https://avatars3.githubusercontent.com/u/5001767?v=3", 76 | "gravatar_id": "", 77 | "url": "https://api.github.com/users/pybee", 78 | "html_url": "https://github.com/pybee", 79 | "followers_url": "https://api.github.com/users/pybee/followers", 80 | "following_url": "https://api.github.com/users/pybee/following{/other_user}", 81 | "gists_url": "https://api.github.com/users/pybee/gists{/gist_id}", 82 | "starred_url": "https://api.github.com/users/pybee/starred{/owner}{/repo}", 83 | "subscriptions_url": "https://api.github.com/users/pybee/subscriptions", 84 | "organizations_url": "https://api.github.com/users/pybee/orgs", 85 | "repos_url": "https://api.github.com/users/pybee/repos", 86 | "events_url": "https://api.github.com/users/pybee/events{/privacy}", 87 | "received_events_url": "https://api.github.com/users/pybee/received_events", 88 | "type": "Organization", 89 | "site_admin": false 90 | }, 91 | "private": true, 92 | "html_url": "https://github.com/pybee/webhook-trigger", 93 | "description": "A test repository that can be used to test Github web hooks", 94 | "fork": false, 95 | "url": "https://github.com/pybee/webhook-trigger", 96 | "forks_url": "https://api.github.com/repos/pybee/webhook-trigger/forks", 97 | "keys_url": "https://api.github.com/repos/pybee/webhook-trigger/keys{/key_id}", 98 | "collaborators_url": "https://api.github.com/repos/pybee/webhook-trigger/collaborators{/collaborator}", 99 | "teams_url": "https://api.github.com/repos/pybee/webhook-trigger/teams", 100 | "hooks_url": "https://api.github.com/repos/pybee/webhook-trigger/hooks", 101 | "issue_events_url": "https://api.github.com/repos/pybee/webhook-trigger/issues/events{/number}", 102 | "events_url": "https://api.github.com/repos/pybee/webhook-trigger/events", 103 | "assignees_url": "https://api.github.com/repos/pybee/webhook-trigger/assignees{/user}", 104 | "branches_url": "https://api.github.com/repos/pybee/webhook-trigger/branches{/branch}", 105 | "tags_url": "https://api.github.com/repos/pybee/webhook-trigger/tags", 106 | "blobs_url": "https://api.github.com/repos/pybee/webhook-trigger/git/blobs{/sha}", 107 | "git_tags_url": "https://api.github.com/repos/pybee/webhook-trigger/git/tags{/sha}", 108 | "git_refs_url": "https://api.github.com/repos/pybee/webhook-trigger/git/refs{/sha}", 109 | "trees_url": "https://api.github.com/repos/pybee/webhook-trigger/git/trees{/sha}", 110 | "statuses_url": "https://api.github.com/repos/pybee/webhook-trigger/statuses/{sha}", 111 | "languages_url": "https://api.github.com/repos/pybee/webhook-trigger/languages", 112 | "stargazers_url": "https://api.github.com/repos/pybee/webhook-trigger/stargazers", 113 | "contributors_url": "https://api.github.com/repos/pybee/webhook-trigger/contributors", 114 | "subscribers_url": "https://api.github.com/repos/pybee/webhook-trigger/subscribers", 115 | "subscription_url": "https://api.github.com/repos/pybee/webhook-trigger/subscription", 116 | "commits_url": "https://api.github.com/repos/pybee/webhook-trigger/commits{/sha}", 117 | "git_commits_url": "https://api.github.com/repos/pybee/webhook-trigger/git/commits{/sha}", 118 | "comments_url": "https://api.github.com/repos/pybee/webhook-trigger/comments{/number}", 119 | "issue_comment_url": "https://api.github.com/repos/pybee/webhook-trigger/issues/comments{/number}", 120 | "contents_url": "https://api.github.com/repos/pybee/webhook-trigger/contents/{+path}", 121 | "compare_url": "https://api.github.com/repos/pybee/webhook-trigger/compare/{base}...{head}", 122 | "merges_url": "https://api.github.com/repos/pybee/webhook-trigger/merges", 123 | "archive_url": "https://api.github.com/repos/pybee/webhook-trigger/{archive_format}{/ref}", 124 | "downloads_url": "https://api.github.com/repos/pybee/webhook-trigger/downloads", 125 | "issues_url": "https://api.github.com/repos/pybee/webhook-trigger/issues{/number}", 126 | "pulls_url": "https://api.github.com/repos/pybee/webhook-trigger/pulls{/number}", 127 | "milestones_url": "https://api.github.com/repos/pybee/webhook-trigger/milestones{/number}", 128 | "notifications_url": "https://api.github.com/repos/pybee/webhook-trigger/notifications{?since,all,participating}", 129 | "labels_url": "https://api.github.com/repos/pybee/webhook-trigger/labels{/name}", 130 | "releases_url": "https://api.github.com/repos/pybee/webhook-trigger/releases{/id}", 131 | "deployments_url": "https://api.github.com/repos/pybee/webhook-trigger/deployments", 132 | "created_at": 1498291581, 133 | "updated_at": "2017-06-25T00:39:15Z", 134 | "pushed_at": 1498464038, 135 | "git_url": "git://github.com/pybee/webhook-trigger.git", 136 | "ssh_url": "git@github.com:pybee/webhook-trigger.git", 137 | "clone_url": "https://github.com/pybee/webhook-trigger.git", 138 | "svn_url": "https://github.com/pybee/webhook-trigger", 139 | "homepage": null, 140 | "size": 5, 141 | "stargazers_count": 0, 142 | "watchers_count": 0, 143 | "language": null, 144 | "has_issues": true, 145 | "has_projects": true, 146 | "has_downloads": true, 147 | "has_wiki": true, 148 | "has_pages": false, 149 | "forks_count": 1, 150 | "mirror_url": null, 151 | "open_issues_count": 1, 152 | "forks": 1, 153 | "open_issues": 1, 154 | "watchers": 0, 155 | "default_branch": "master", 156 | "stargazers": 0, 157 | "master_branch": "master", 158 | "organization": "pybee" 159 | }, 160 | "pusher": { 161 | "name": "freakboy3742", 162 | "email": "russell@keith-magee.com" 163 | }, 164 | "organization": { 165 | "login": "pybee", 166 | "id": 5001767, 167 | "url": "https://api.github.com/orgs/pybee", 168 | "repos_url": "https://api.github.com/orgs/pybee/repos", 169 | "events_url": "https://api.github.com/orgs/pybee/events", 170 | "hooks_url": "https://api.github.com/orgs/pybee/hooks", 171 | "issues_url": "https://api.github.com/orgs/pybee/issues", 172 | "members_url": "https://api.github.com/orgs/pybee/members{/member}", 173 | "public_members_url": "https://api.github.com/orgs/pybee/public_members{/member}", 174 | "avatar_url": "https://avatars3.githubusercontent.com/u/5001767?v=3", 175 | "description": "The IDEs of Python!" 176 | }, 177 | "sender": { 178 | "login": "freakboy3742", 179 | "id": 37345, 180 | "avatar_url": "https://avatars2.githubusercontent.com/u/37345?v=3", 181 | "gravatar_id": "", 182 | "url": "https://api.github.com/users/freakboy3742", 183 | "html_url": "https://github.com/freakboy3742", 184 | "followers_url": "https://api.github.com/users/freakboy3742/followers", 185 | "following_url": "https://api.github.com/users/freakboy3742/following{/other_user}", 186 | "gists_url": "https://api.github.com/users/freakboy3742/gists{/gist_id}", 187 | "starred_url": "https://api.github.com/users/freakboy3742/starred{/owner}{/repo}", 188 | "subscriptions_url": "https://api.github.com/users/freakboy3742/subscriptions", 189 | "organizations_url": "https://api.github.com/users/freakboy3742/orgs", 190 | "repos_url": "https://api.github.com/users/freakboy3742/repos", 191 | "events_url": "https://api.github.com/users/freakboy3742/events{/privacy}", 192 | "received_events_url": "https://api.github.com/users/freakboy3742/received_events", 193 | "type": "User", 194 | "site_admin": false 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /github/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.contrib import admin 3 | 4 | from github import views as github 5 | 6 | 7 | urlpatterns = [ 8 | url(r'^notify$', github.webhook, name='webhook'), 9 | ] 10 | -------------------------------------------------------------------------------- /github/views.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | from hashlib import sha1 3 | import json 4 | import urllib 5 | 6 | 7 | from django.conf import settings 8 | from django.http import HttpResponse, HttpResponseForbidden, HttpResponseServerError 9 | from django.views.decorators.csrf import csrf_exempt 10 | from django.views.decorators.http import require_POST 11 | 12 | from github3 import GitHub 13 | from ipaddress import ip_address, ip_network 14 | 15 | from github import hooks 16 | 17 | 18 | @require_POST 19 | @csrf_exempt 20 | def webhook(request): 21 | # Verify if request came from GitHub 22 | forwarded_for = u'{}'.format(request.META.get('HTTP_X_FORWARDED_FOR')) 23 | client_ip_address = ip_address(forwarded_for) 24 | 25 | gh_session = GitHub( 26 | settings.GITHUB_USERNAME, 27 | password=settings.GITHUB_ACCESS_TOKEN 28 | ) 29 | whitelist = gh_session.meta()['hooks'] 30 | 31 | for valid_ip in whitelist: 32 | if client_ip_address in ip_network(valid_ip): 33 | break 34 | else: 35 | return HttpResponseForbidden('Permission denied.') 36 | 37 | # Verify the request signature 38 | header_signature = request.META.get('HTTP_X_HUB_SIGNATURE') 39 | if header_signature is None: 40 | return HttpResponseForbidden('Permission denied.') 41 | 42 | sha_name, signature = header_signature.split('=') 43 | if sha_name != 'sha1': 44 | return HttpResponseServerError('Operation not supported.', status=501) 45 | 46 | mac = hmac.new( 47 | settings.GITHUB_WEBHOOK_KEY.encode('utf-8'), 48 | msg=request.body, 49 | digestmod=sha1 50 | ) 51 | if not hmac.compare_digest(mac.hexdigest().encode('utf-8'), signature.encode('utf-8')): 52 | return HttpResponseForbidden('Permission denied.') 53 | 54 | # Get the event type 55 | event = request.META.get('HTTP_X_GITHUB_EVENT', 'ping') 56 | 57 | # Decode the payload 58 | if request.content_type == 'application/x-www-form-urlencoded': 59 | # Remove the payload= prefix and URL unquote 60 | content = urllib.parse.unquote_plus(request.body.decode('utf-8')) 61 | payload = json.loads(content[8:]) 62 | elif request.content_type == 'application/json': 63 | content = request.body.decode('utf-8') 64 | payload = json.loads(content) 65 | else: 66 | payload = None 67 | 68 | try: 69 | return HttpResponse(hooks[event](payload)) 70 | except KeyError: 71 | # In case we receive an event that's not handled 72 | return HttpResponse(status=204) 73 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 7 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | # The above import may fail for some other reason. Ensure that the 11 | # issue is really that Django is missing to avoid masking other 12 | # exceptions on Python 2. 13 | try: 14 | import django 15 | except ImportError: 16 | raise ImportError( 17 | "Couldn't import Django. Are you sure it's installed and " 18 | "available on your PYTHONPATH environment variable? Did you " 19 | "forget to activate a virtual environment?" 20 | ) 21 | raise 22 | execute_from_command_line(sys.argv) 23 | -------------------------------------------------------------------------------- /projects/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'projects.apps.ProjectsConfig' 2 | -------------------------------------------------------------------------------- /projects/admin.py: -------------------------------------------------------------------------------- 1 | from django.apps import apps 2 | from django.conf import settings 3 | from django.contrib import admin, messages 4 | from django.utils.safestring import mark_safe 5 | 6 | from .models import Project, ProjectSetting, Change, Build 7 | 8 | 9 | def approve(modeladmin, request, queryset): 10 | for obj in queryset: 11 | obj.approve() 12 | messages.info(request, 'Approving %s for build' % obj) 13 | approve.short_description = "Approve for build" 14 | 15 | 16 | def attic(modeladmin, request, queryset): 17 | for obj in queryset: 18 | obj.complete() 19 | messages.info(request, 'Moving %s to the attic' % obj) 20 | attic.short_description = "Move to attic" 21 | 22 | 23 | def ignore(modeladmin, request, queryset): 24 | for obj in queryset: 25 | obj.ignore() 26 | messages.info(request, 'Ignoring %s' % obj) 27 | ignore.short_description = "Ignore" 28 | 29 | 30 | class ProjectSettingInline(admin.TabularInline): 31 | model = ProjectSetting 32 | list_display = ['descriptor', 'key', 'value'] 33 | extra = 0 34 | 35 | 36 | @admin.register(Project) 37 | class ProjectAdmin(admin.ModelAdmin): 38 | list_display = ['repository', 'status'] 39 | list_filter = ['status'] 40 | raw_id_fields = ['repository'] 41 | actions = [approve, attic, ignore] 42 | inlines = [ProjectSettingInline] 43 | 44 | 45 | @admin.register(ProjectSetting) 46 | class ProjectSettingAdmin(admin.ModelAdmin): 47 | model = ProjectSetting 48 | list_display = ['project', 'descriptor', 'key', 'value'] 49 | raw_id_fields = ['project'] 50 | extra = 0 51 | 52 | 53 | class BuildInline(admin.TabularInline): 54 | model = Build 55 | list_display = ['created', 'commit', 'status', 'result'] 56 | raw_id_fields = ['commit',] 57 | extra = 0 58 | 59 | 60 | @admin.register(Change) 61 | class ChangeAdmin(admin.ModelAdmin): 62 | list_display = ['project', 'title', 'status', 'completed'] 63 | list_filter = ['change_type', 'status'] 64 | raw_id_fields = ['project', 'pull_request', 'push'] 65 | actions = [approve, attic, ignore] 66 | inlines = [BuildInline] 67 | 68 | def title(self, change): 69 | return change.title 70 | title.short_description = 'Title' 71 | 72 | 73 | def restart_build(modeladmin, request, queryset): 74 | for obj in queryset: 75 | obj.restart() 76 | messages.info(request, 'Restarting build %s' % obj) 77 | restart_build.short_description = "Restart build" 78 | 79 | 80 | def resume_build(modeladmin, request, queryset): 81 | for obj in queryset: 82 | obj.resume() 83 | messages.info(request, 'Resuming build %s' % obj) 84 | resume_build.short_description = "Resume build" 85 | 86 | 87 | def stop_build(modeladmin, request, queryset): 88 | for obj in queryset: 89 | obj.stop() 90 | messages.info(request, 'Stopping build %s' % obj) 91 | stop_build.short_description = "Stop build" 92 | 93 | 94 | class TaskInline(admin.TabularInline): 95 | model = apps.get_model(settings.BEEKEEPER_BUILD_APP, 'Task') 96 | fields = ['name', 'phase', 'status', 'result'] 97 | extra = 0 98 | 99 | 100 | @admin.register(Build) 101 | class BuildAdmin(admin.ModelAdmin): 102 | list_display = ['display_pk', 'project', 'change', 'commit_sha', 'user_with_avatar', 'status', 'result'] 103 | list_filter = ['change__change_type', 'status'] 104 | raw_id_fields = ['commit', 'change'] 105 | actions = [restart_build, resume_build, stop_build] 106 | inlines = [TaskInline] 107 | 108 | def display_pk(self, build): 109 | return build.display_pk 110 | display_pk.short_description = 'Build' 111 | 112 | def project(self, build): 113 | return build.change.project 114 | project.short_description = 'Project' 115 | 116 | def commit_sha(self, build): 117 | return build.commit.display_sha 118 | commit_sha.short_description = 'Commit' 119 | 120 | def user_with_avatar(self, build): 121 | return mark_safe('Github avatar for %s %s' % ( 122 | build.commit.user.avatar_url, build.commit.user, build.commit.user 123 | )) 124 | user_with_avatar.short_description = 'user' 125 | -------------------------------------------------------------------------------- /projects/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ProjectsConfig(AppConfig): 5 | name = 'projects' 6 | 7 | def ready(self): 8 | from django.db.models import signals as django 9 | from github import signals as github 10 | from github.models import Repository, Commit, PullRequestUpdate, Push 11 | from .handlers import new_project, new_pull_request_build, new_push_build 12 | 13 | django.post_save.connect(new_project, sender=Repository) 14 | 15 | github.new_build.connect(new_push_build, sender=Push) 16 | github.new_build.connect(new_pull_request_build, sender=PullRequestUpdate) 17 | -------------------------------------------------------------------------------- /projects/handlers.py: -------------------------------------------------------------------------------- 1 | from github.models import PullRequest 2 | 3 | from .models import Project, Change, Build 4 | 5 | 6 | def new_project(sender, instance, created, *args, **kwargs): 7 | # When a github repository is saved, make sure there is 8 | # a project. Create one if it doesn't exist. 9 | try: 10 | Project.objects.get(repository=instance) 11 | except Project.DoesNotExist: 12 | Project.objects.create(repository=instance) 13 | 14 | 15 | def new_push_build(sender, push=None, *args, **kwargs): 16 | """Create a new build in response to a push.""" 17 | try: 18 | project = Project.objects.get(repository=push.commit.repository) 19 | if project.status == Project.STATUS_ACTIVE: 20 | 21 | # Stop all push builds on the same branch of this project 22 | for change in project.pushes.active( 23 | ).filter( 24 | push__commit__branch_name=push.commit.branch_name 25 | ): 26 | change.complete() 27 | 28 | # Find (or create) a change relating to this pull. 29 | try: 30 | change = Change.objects.get( 31 | project=project, 32 | change_type=Change.CHANGE_TYPE_PUSH, 33 | pull_request=None, 34 | push=push 35 | ) 36 | except Change.DoesNotExist: 37 | change = Change.objects.create( 38 | project=project, 39 | change_type=Change.CHANGE_TYPE_PUSH, 40 | pull_request=None, 41 | push=push, 42 | ) 43 | 44 | # Create a new build. 45 | build = Build.objects.create(change=change, commit=push.commit) 46 | build.start() 47 | 48 | except Project.DoesNotExist: 49 | pass 50 | 51 | 52 | def new_pull_request_build(sender, update=None, *args, **kwargs): 53 | """Create a new build in response to a pull request update.""" 54 | try: 55 | project = Project.objects.get(repository=update.pull_request.repository) 56 | if project.status == Project.STATUS_ACTIVE: 57 | # Find (or create) a change relating to this pull request. 58 | try: 59 | change = Change.objects.get( 60 | project=project, 61 | change_type=Change.CHANGE_TYPE_PULL_REQUEST, 62 | pull_request=update.pull_request, 63 | push=None, 64 | ) 65 | except Change.DoesNotExist: 66 | change = Change.objects.create( 67 | project=project, 68 | change_type=Change.CHANGE_TYPE_PULL_REQUEST, 69 | pull_request=update.pull_request, 70 | push=None, 71 | ) 72 | 73 | # Stop all pending builds on this change. 74 | for build in change.builds.started(): 75 | build.stop() 76 | 77 | # Create a new build. 78 | build = Build.objects.create(change=change, commit=update.commit) 79 | build.start() 80 | 81 | except Project.DoesNotExist: 82 | pass 83 | -------------------------------------------------------------------------------- /projects/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-02 08:28 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import django.utils.timezone 8 | import uuid 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [ 16 | ('github', '0001_initial'), 17 | ] 18 | 19 | operations = [ 20 | migrations.CreateModel( 21 | name='Build', 22 | fields=[ 23 | ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), 24 | ('status', models.IntegerField(choices=[(10, 'Created'), (20, 'Running'), (100, 'Done'), (9999, 'Cancelled')], default=10)), 25 | ('result', models.IntegerField(choices=[(0, 'Pending'), (10, 'Fail'), (19, 'Qualified pass'), (20, 'Pass')], default=0)), 26 | ('created', models.DateTimeField(default=django.utils.timezone.now)), 27 | ('updated', models.DateTimeField(auto_now=True)), 28 | ], 29 | options={ 30 | 'ordering': ('-created',), 31 | }, 32 | ), 33 | migrations.CreateModel( 34 | name='Change', 35 | fields=[ 36 | ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), 37 | ('status', models.IntegerField(choices=[(10, 'New'), (100, 'Active'), (1000, 'Attic'), (9999, 'Ignored')], default=100)), 38 | ('change_type', models.IntegerField(choices=[(10, 'Pull Request'), (20, 'Push')])), 39 | ('created', models.DateTimeField(default=django.utils.timezone.now)), 40 | ('updated', models.DateTimeField(auto_now=True)), 41 | ('completed', models.DateTimeField(blank=True, null=True)), 42 | ], 43 | ), 44 | migrations.CreateModel( 45 | name='Project', 46 | fields=[ 47 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 48 | ('status', models.IntegerField(choices=[(10, 'New'), (100, 'Active'), (1000, 'Attic'), (9999, 'Ignored')], default=10)), 49 | ('created', models.DateTimeField(default=django.utils.timezone.now)), 50 | ('updated', models.DateTimeField(auto_now=True)), 51 | ('repository', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='project', to='github.Repository')), 52 | ], 53 | options={ 54 | 'ordering': ('repository__name',), 55 | }, 56 | ), 57 | migrations.AddField( 58 | model_name='change', 59 | name='project', 60 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='projects.Project'), 61 | ), 62 | migrations.AddField( 63 | model_name='change', 64 | name='pull_request', 65 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='github.PullRequest'), 66 | ), 67 | migrations.AddField( 68 | model_name='change', 69 | name='push', 70 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='changes', to='github.Push'), 71 | ), 72 | migrations.AddField( 73 | model_name='build', 74 | name='change', 75 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='projects.Change'), 76 | ), 77 | migrations.AddField( 78 | model_name='build', 79 | name='commit', 80 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='builds', to='github.Commit'), 81 | ), 82 | ] 83 | -------------------------------------------------------------------------------- /projects/migrations/0002_tweak_model_ordering.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-07 08:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('projects', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='build', 17 | options={'ordering': ('-updated',)}, 18 | ), 19 | migrations.AlterModelOptions( 20 | name='change', 21 | options={'ordering': ('project__repository__name', '-updated')}, 22 | ), 23 | migrations.AlterField( 24 | model_name='build', 25 | name='status', 26 | field=models.IntegerField(choices=[(10, 'Created'), (20, 'Running'), (100, 'Done'), (200, 'Error'), (9998, 'Stopping'), (9999, 'Stopped')], default=10), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /projects/migrations/0003_build_error.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-08 02:00 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('projects', '0002_tweak_model_ordering'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='build', 17 | name='error', 18 | field=models.TextField(blank=True), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /projects/migrations/0004_non_critical_tasks.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-09 02:03 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('projects', '0003_build_error'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='build', 17 | name='result', 18 | field=models.IntegerField(choices=[(0, 'Pending'), (10, 'Fail'), (19, 'Non-critical Fail'), (20, 'Pass')], default=0), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /projects/migrations/0005_add_variables.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-18 09:16 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('projects', '0004_non_critical_tasks'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Variable', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('task_name', models.CharField(db_index=True, max_length=100)), 21 | ('key', models.CharField(max_length=100)), 22 | ('value', models.CharField(max_length=2043)), 23 | ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variables', to='projects.Project')), 24 | ], 25 | options={ 26 | 'ordering': ('task_name', 'key'), 27 | }, 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /projects/migrations/0006_add_global_variables.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-18 09:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('projects', '0005_add_variables'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='variable', 18 | name='project', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='variables', to='projects.Project'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /projects/migrations/0007_descriptor_not_task_name.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-18 10:52 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('projects', '0006_add_global_variables'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='variable', 17 | options={'ordering': ('descriptor', 'key')}, 18 | ), 19 | migrations.RenameField( 20 | model_name='variable', 21 | old_name='task_name', 22 | new_name='descriptor', 23 | ), 24 | migrations.AlterUniqueTogether( 25 | name='variable', 26 | unique_together=set([('project', 'descriptor', 'key')]), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /projects/migrations/0008_rename_variables.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-18 11:44 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('projects', '0007_descriptor_not_task_name'), 13 | ] 14 | 15 | operations = [ 16 | migrations.RenameModel('Variable', 'ProjectSetting'), 17 | ] 18 | -------------------------------------------------------------------------------- /projects/migrations/0009_add_task_profiles.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.7 on 2017-07-20 04:11 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [ 12 | ('projects', '0008_rename_variables'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name='projectsetting', 18 | name='project', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='projects.Project'), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /projects/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/projects/migrations/__init__.py -------------------------------------------------------------------------------- /projects/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import Signal 2 | 3 | start_build = Signal() 4 | -------------------------------------------------------------------------------- /projects/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/beeware/beekeeper/1ac2590cb36e5cac2286e3abe971f67f8bd9920c/projects/templatetags/__init__.py -------------------------------------------------------------------------------- /projects/templatetags/build_status.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from django import template 4 | from django.utils.safestring import mark_safe 5 | 6 | from projects.models import Build 7 | 8 | 9 | register = template.Library() 10 | 11 | 12 | @register.simple_tag 13 | def result(value): 14 | if value == Build.RESULT_PENDING: 15 | return mark_safe('') 16 | elif value == Build.RESULT_FAIL: 17 | return mark_safe('') 18 | elif value == Build.RESULT_NON_CRITICAL_FAIL: 19 | return mark_safe('') 20 | elif value == Build.RESULT_PASS: 21 | return mark_safe('') 22 | else: 23 | return mark_safe('') 24 | -------------------------------------------------------------------------------- /projects/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.conf.urls import url, include 3 | from django.contrib import admin 4 | 5 | from projects import views as projects 6 | 7 | 8 | urlpatterns = [ 9 | url(r'^(?P[-\w]+)/(?P[-\w]+)$', 10 | projects.project, name='project'), 11 | url(r'^(?P[-\w]+)/(?P[-\w]+)/shield$', 12 | projects.project_shield, name='project-shield'), 13 | url(r'^(?P[-\w]+)/(?P[-\w]+)/change/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})$', 14 | projects.change, name='change'), 15 | url(r'^(?P[-\w]+)/(?P[-\w]+)/change/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/status$', 16 | projects.change_status, name='change-status'), 17 | url(r'^(?P[-\w]+)/(?P[-\w]+)/change/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/build/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})$', 18 | projects.build, name='build'), 19 | url(r'^(?P[-\w]+)/(?P[-\w]+)/change/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/build/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/status$', 20 | projects.build_status, name='build-status'), 21 | url(r'^(?P[-\w]+)/(?P[-\w]+)/change/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/build/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/code$', 22 | projects.build_code, name='build-code'), 23 | 24 | url(r'^(?P[-\w]+)/(?P[-\w]+)/change/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/build/(?P[-\da-fA-F]{8}-[-\da-fA-F]{4}-4[-\da-fA-F]{3}-[-\da-fA-F]{4}-[-\da-fA-F]{12})/task/', 25 | include('%s.task_urls' % settings.BEEKEEPER_BUILD_APP)) 26 | ] 27 | -------------------------------------------------------------------------------- /projects/views.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import json 3 | 4 | from django.conf import settings 5 | from django.http import Http404, HttpResponse, HttpResponseRedirect 6 | from django.views.decorators.http import etag 7 | from django.shortcuts import render, redirect 8 | from django.views.decorators.cache import never_cache 9 | from django.utils import timezone 10 | 11 | import requests 12 | 13 | from .models import Project, Change, Build 14 | 15 | 16 | def project(request, owner, repo_name): 17 | try: 18 | project = Project.objects.get( 19 | repository__owner__login=owner, 20 | repository__name=repo_name, 21 | ) 22 | except Project.DoesNotExist: 23 | raise Http404 24 | 25 | return render(request, 'projects/project.html', { 26 | 'project': project, 27 | }) 28 | 29 | 30 | def etag_func(request, *args, **kwargs): 31 | return hashlib.sha256(str(timezone.now()).encode('utf-8')).hexdigest() 32 | 33 | 34 | @never_cache 35 | @etag(etag_func=etag_func) 36 | def project_shield(request, owner, repo_name): 37 | try: 38 | project = Project.objects.get( 39 | repository__owner__login=owner, 40 | repository__name=repo_name, 41 | ) 42 | except Project.DoesNotExist: 43 | raise Http404 44 | 45 | branch = request.GET.get('branch', project.repository.master_branch_name) 46 | build = project.current_build(branch) 47 | if build: 48 | if build.result == Build.RESULT_PASS: 49 | status = 'pass' 50 | elif build.result == Build.RESULT_FAIL: 51 | status = 'fail' 52 | elif build.result == Build.RESULT_NON_CRITICAL_FAIL: 53 | status = 'non_critical_fail' 54 | else: 55 | status = 'unknown' 56 | else: 57 | status = 'unknown' 58 | 59 | return render(request, 'projects/shields/%s.svg' % status, {}, 60 | content_type='image/svg+xml;charset=utf-8' 61 | ) 62 | 63 | 64 | def change(request, owner, repo_name, change_pk): 65 | try: 66 | change = Change.objects.get( 67 | project__repository__owner__login=owner, 68 | project__repository__name=repo_name, 69 | pk=change_pk 70 | ) 71 | except Change.DoesNotExist: 72 | raise Http404 73 | 74 | return render(request, 'projects/change.html', { 75 | 'project': change.project, 76 | 'change': change, 77 | }) 78 | 79 | 80 | def change_status(request, owner, repo_name, change_pk): 81 | try: 82 | change = Change.objects.get( 83 | project__repository__owner__login=owner, 84 | project__repository__name=repo_name, 85 | pk=change_pk 86 | ) 87 | except Change.DoesNotExist: 88 | raise Http404 89 | 90 | return HttpResponse(json.dumps({ 91 | 'builds': { 92 | build.display_pk: { 93 | 'url': build.get_absolute_url(), 94 | 'label': build.commit.display_sha, 95 | 'title': build.commit.title, 96 | 'timestamp': build.created.strftime('%-d %b %Y, %H:%M'), 97 | 'status': build.get_status_display(), 98 | 'result': build.result, 99 | } 100 | for build in change.builds.all() 101 | }, 102 | 'complete': change.is_complete 103 | }), content_type="application/json") 104 | 105 | 106 | def build(request, owner, repo_name, change_pk, build_pk): 107 | try: 108 | build = Build.objects.get( 109 | change__project__repository__owner__login=owner, 110 | change__project__repository__name=repo_name, 111 | change__pk=change_pk, 112 | pk=build_pk, 113 | ) 114 | except Build.DoesNotExist: 115 | raise Http404 116 | 117 | if request.method == "POST" and request.user.is_superuser: 118 | if 'resume' in request.POST: 119 | build.resume() 120 | elif 'restart' in request.POST: 121 | build.restart() 122 | elif 'stop' in request.POST: 123 | build.stop() 124 | 125 | return redirect(build.get_absolute_url()) 126 | 127 | return render(request, 'projects/build.html', { 128 | 'project': build.change.project, 129 | 'change': build.change, 130 | 'commit': build.commit, 131 | 'build': build, 132 | }) 133 | 134 | 135 | def build_status(request, owner, repo_name, change_pk, build_pk): 136 | try: 137 | build = Build.objects.get( 138 | change__project__repository__owner__login=owner, 139 | change__project__repository__name=repo_name, 140 | change__pk=change_pk, 141 | pk=build_pk, 142 | ) 143 | except Build.DoesNotExist: 144 | raise Http404 145 | 146 | return HttpResponse(json.dumps({ 147 | 'status': build.full_status_display(), 148 | 'result': build.result, 149 | 'tasks': { 150 | task.slug: { 151 | 'url': task.get_absolute_url(), 152 | 'name': task.name, 153 | 'phase': task.phase, 154 | 'status': task.get_status_display(), 155 | 'result': task.result, 156 | } 157 | for task in build.tasks.all() 158 | }, 159 | 'finished': build.is_finished 160 | }), content_type="application/json") 161 | 162 | 163 | def build_code(request, owner, repo_name, change_pk, build_pk): 164 | try: 165 | build = Build.objects.get( 166 | change__project__repository__owner__login=owner, 167 | change__project__repository__name=repo_name, 168 | change__pk=change_pk, 169 | pk=build_pk, 170 | ) 171 | except Build.DoesNotExist: 172 | raise Http404 173 | 174 | response = requests.get( 175 | 'https://github.com/%s/%s/archive/%s.zip' % ( 176 | owner, 177 | repo_name, 178 | build.commit.sha 179 | ), 180 | auth=(settings.GITHUB_USERNAME, settings.GITHUB_ACCESS_TOKEN), 181 | allow_redirects=False 182 | ) 183 | 184 | return HttpResponseRedirect(response.headers['Location']) 185 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django<1.11.28 2 | dj-database-url==0.4.1 3 | rho-django-user 4 | gunicorn==19.6.0 5 | psycopg2==2.7.5 6 | whitenoise==3.2 7 | redis==2.10.5 8 | celery==4.2.1 9 | django-storages==1.5.2 10 | boto3==1.4.4 11 | sendgrid_django==4.0.4 12 | requests==2.20.0 13 | github3.py==0.9.6 14 | PyYAML==5.1 15 | python-dotenv==0.6.5 16 | -------------------------------------------------------------------------------- /runtime.txt: -------------------------------------------------------------------------------- 1 | python-3.6.6 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import io 3 | import re 4 | from setuptools import setup 5 | 6 | 7 | with io.open('./beekeeper/__init__.py', encoding='utf8') as version_file: 8 | version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file.read(), re.M) 9 | if version_match: 10 | version = version_match.group(1) 11 | else: 12 | raise RuntimeError("Unable to find version string.") 13 | 14 | 15 | with io.open('README.rst', encoding='utf8') as readme: 16 | long_description = readme.read() 17 | 18 | 19 | setup( 20 | name='beekeeper', 21 | version=version, 22 | description='Tools to run a suite of tests for a project inside Docker containers.', 23 | long_description=long_description, 24 | author='Russell Keith-Magee', 25 | author_email='russell@keith-magee.com', 26 | url='http://pybee.org/beekeeper', 27 | keywords=['beekeeper'], 28 | packages=['beekeeper'], 29 | entry_points={ 30 | 'console_scripts': [ 31 | 'beekeeper = beekeeper.__main__:main', 32 | ] 33 | }, 34 | license='New BSD', 35 | classifiers=[ 36 | 'Development Status :: 4 - Beta', 37 | 'Intended Audience :: Developers', 38 | 'License :: OSI Approved :: BSD License', 39 | 'Operating System :: OS Independent', 40 | 'Programming Language :: Python :: 3', 41 | 'Programming Language :: Python :: 3.5', 42 | 'Programming Language :: Python :: 3.6', 43 | 'Programming Language :: Python :: 3.7', 44 | 'Topic :: Software Development', 45 | 'Topic :: Utilities', 46 | ], 47 | test_suite='tests' 48 | ) 49 | -------------------------------------------------------------------------------- /static/css/beekeeper.css: -------------------------------------------------------------------------------- 1 | 2 | /************************************** 3 | * Basic page layout 4 | **************************************/ 5 | 6 | div.container { 7 | margin-top: 5em; 8 | } 9 | 10 | /************************************** 11 | * Forms 12 | **************************************/ 13 | 14 | .form-group label.alert { 15 | background-color: inherit; 16 | border: none; 17 | margin-bottom: 0.5rem; 18 | padding: inherit; 19 | } 20 | 21 | .form-group small.alert { 22 | background-color: inherit; 23 | border: none; 24 | margin-bottom: 0.5rem; 25 | padding: inherit; 26 | } 27 | 28 | .button { 29 | cursor: pointer; 30 | } 31 | 32 | /************************************** 33 | * Login form 34 | **************************************/ 35 | 36 | .form-signin { 37 | max-width: 30em; 38 | padding: 15px; 39 | margin: 0 auto; 40 | } 41 | 42 | .form-signin .form-signin-heading, 43 | .form-signin .checkbox { 44 | margin-bottom: 10px; 45 | } 46 | 47 | .form-signin .checkbox { 48 | font-weight: normal; 49 | } 50 | 51 | .form-signin .form-control { 52 | position: relative; 53 | height: auto; 54 | -webkit-box-sizing: border-box; 55 | box-sizing: border-box; 56 | padding: 10px; 57 | font-size: 16px; 58 | } 59 | 60 | .form-signin .form-control:focus { 61 | z-index: 2; 62 | } 63 | 64 | .form-signin input[type="email"] { 65 | margin-bottom: -1px; 66 | border-bottom-right-radius: 0; 67 | border-bottom-left-radius: 0; 68 | } 69 | 70 | .form-signin input[type="password"] { 71 | margin-bottom: 10px; 72 | border-top-left-radius: 0; 73 | border-top-right-radius: 0; 74 | } 75 | 76 | .form-signin p { 77 | margin-top: 1em; 78 | text-align: center; 79 | } 80 | 81 | /************************************** 82 | * Avatars 83 | **************************************/ 84 | 85 | .avatar img { 86 | width: 2em; 87 | margin-right: 0.5em; 88 | } 89 | 90 | /************************************** 91 | * Exit links 92 | **************************************/ 93 | 94 | .exit { 95 | margin-left: 0.5em; 96 | } 97 | 98 | /************************************** 99 | * Tables 100 | **************************************/ 101 | 102 | .minimal { 103 | width: 0.1em; 104 | text-align: center; 105 | white-space: nowrap; 106 | } 107 | 108 | .avatar { 109 | text-align: left; 110 | } 111 | 112 | 113 | /************************************** 114 | * Control buttons 115 | **************************************/ 116 | 117 | .control { 118 | margin-left: 1em; 119 | } 120 | 121 | /************************************** 122 | * Logs 123 | **************************************/ 124 | 125 | .log pre { 126 | background-color: black; 127 | color: lightgray; 128 | padding: 1em; 129 | } 130 | 131 | .hidden { 132 | display: none; 133 | } 134 | 135 | /************************************** 136 | * Build status 137 | **************************************/ 138 | 139 | .error, .fail { 140 | color: #d9534f; 141 | } 142 | 143 | .warning, .fail.non-critical { 144 | color: #f0ad4e; 145 | } 146 | 147 | .success, .pass { 148 | color: #5cb85c; 149 | } 150 | 151 | .info, .pending { 152 | color: #2aabd2; 153 | } 154 | 155 | th.result, td.result { 156 | width: 1em; 157 | text-align: center; 158 | } 159 | -------------------------------------------------------------------------------- /templates/admin/base_site.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block title %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} 4 | 5 | {% block branding %} 6 |

BeeKeeper

7 | {% endblock %} 8 | 9 | {% block nav-global %}{% endblock %} 10 | -------------------------------------------------------------------------------- /templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static from staticfiles %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block meta %}{% endblock %} 9 | 10 | {% block window_title %}BeeKeeper{% endblock %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {% block extra_head %}{% endblock %} 20 | 21 | 22 | {% block body %} 23 | 24 | 56 | 57 |
58 | {% block content %} 59 | {% endblock %} 60 |
61 | 62 | {% endblock %} 63 | 64 | 65 | 66 | 67 | 68 | 71 | 72 | -------------------------------------------------------------------------------- /templates/home.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block content %} 3 |
4 |

BeeKeeper

5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | {% for project in projects %} 17 | 18 | 23 | 24 | 25 | 26 | 27 | {% endfor %} 28 | 29 |
Project# Open changesBuild Status
19 | 20 | Github avatar for {{ project.repository.owner }}{{ project.repository.full_name }} 21 | 22 | {{ project.changes.active.count }}
30 | 31 | {% if user.is_superuser and new_projects %} 32 |
{% csrf_token %} 33 |

Repositories awaiting approval

34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | {% for project in new_projects %} 44 | 45 | 46 | 51 | 52 | 53 | {% endfor %} 54 | 55 |
Project
47 | 48 | Github avatar for {{ project.repository.owner }}{{ project.repository.full_name }} 49 | 50 |
56 |
57 | 58 | 59 |
60 |
61 | {% endif %} 62 | {% endblock %} 63 | -------------------------------------------------------------------------------- /templates/projects/build.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load build_status %} 3 | {% block content %} 4 |
5 | 11 |
{% csrf_token %} 12 |

Build {{ build.commit.display_sha }} 13 | 14 | 15 | 16 | {% if user.is_superuser %} 17 | 18 | 19 | 20 | {% endif %} 21 |

22 |
23 | 24 |
25 |
Created
26 |
{{ build.created|date:"j M Y, H:i" }}
27 | 28 |
Commit message
29 |
{{ build.commit.message }}
30 | 31 |
Status
32 |
{{ build.full_status_display }}
33 | 34 |
Result
35 |
{% result build.result %}
36 |
37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | {% for task in build.tasks.all %} 49 | 50 | 51 | 52 | 53 | 54 | 55 | {% endfor %} 56 | 57 |
PhaseTaskStatusResult
{{ task.phase }}{{ task.name }}{{ task.get_status_display }}{% result task.result %}
58 | {% endblock %} 59 | 60 | 61 | {% block scripts %} 62 | 63 | {% if not build.is_finished %} 64 | function refresh() { 65 | var xmlhttp=new XMLHttpRequest(); 66 | 67 | document.getElementById('spinner').style.display = 'inline' 68 | document.getElementById('error').style.display = 'none' 69 | 70 | xmlhttp.open("GET", '{{ build.get_status_url }}'); 71 | xmlhttp.onreadystatechange = function() { 72 | try { 73 | document.getElementById('spinner').style.display = 'none' 74 | if (xmlhttp.readyState == XMLHttpRequest.DONE) { 75 | if (xmlhttp.status == 200) { 76 | var response = JSON.parse(xmlhttp.responseText); 77 | 78 | // Update status 79 | var status = document.getElementById('status'); 80 | status.textContent = response['status'] 81 | 82 | // Update result 83 | var result = document.getElementById('result'); 84 | switch (response['result']) { 85 | case 0: 86 | result.innerHTML = '{% result 0 %}' 87 | break; 88 | case 10: 89 | result.innerHTML = '{% result 10 %}' 90 | break; 91 | case 19: 92 | result.innerHTML = '{% result 19 %}' 93 | break; 94 | case 20: 95 | result.innerHTML = '{% result 20 %}' 96 | break; 97 | default: 98 | result.innerHTML = '{% result 99 %}' 99 | break; 100 | } 101 | 102 | for (var slug in response.tasks) { 103 | // Update task status 104 | status = document.getElementById(slug + '-status'); 105 | if (status) { 106 | status.textContent = response['tasks'][slug]['status'] 107 | 108 | // Update result 109 | result = document.getElementById(slug + '-result'); 110 | switch (response['tasks'][slug]['result']) { 111 | case 0: 112 | result.innerHTML = '{% result 0 %}' 113 | break; 114 | case 10: 115 | result.innerHTML = '{% result 10 %}' 116 | break; 117 | case 19: 118 | result.innerHTML = '{% result 19 %}' 119 | break; 120 | case 20: 121 | result.innerHTML = '{% result 20 %}' 122 | break; 123 | default: 124 | result.innerHTML = '{% result 99 %}' 125 | break; 126 | } 127 | } else { 128 | row = document.createElement('tr') 129 | row.scope = "row" 130 | 131 | col = document.createElement('td') 132 | col.className = 'minimal' 133 | col.textContent = response['tasks'][slug]['phase'] 134 | row.appendChild(col) 135 | 136 | col = document.createElement('td') 137 | link = document.createElement('a') 138 | link.href = response['tasks'][slug]['url'] 139 | link.textContent = response['tasks'][slug]['name'] 140 | col.appendChild(link) 141 | row.appendChild(col) 142 | 143 | col = document.createElement('td') 144 | col.id = slug + '-status' 145 | col.className = 'minimal' 146 | col.textContent = response['tasks'][slug]['status'] 147 | row.appendChild(col) 148 | 149 | col = document.createElement('td') 150 | col.id = slug + '-result' 151 | col.className = 'minimal' 152 | switch (response['tasks'][slug]['result']) { 153 | case 0: 154 | col.innerHTML = '{% result 0 %}' 155 | break; 156 | case 10: 157 | col.innerHTML = '{% result 10 %}' 158 | break; 159 | case 19: 160 | col.innerHTML = '{% result 19 %}' 161 | break; 162 | case 20: 163 | col.innerHTML = '{% result 20 %}' 164 | break; 165 | default: 166 | col.innerHTML = '{% result 99 %}' 167 | break; 168 | } 169 | row.appendChild(col) 170 | 171 | tasks = document.getElementById('tasks') 172 | tasks.appendChild(row) 173 | } 174 | } 175 | 176 | if (response['finished']) { 177 | document.getElementById('stop').style.display = 'none'; 178 | document.getElementById('restart').style.display = 'block'; 179 | } else { 180 | window.setTimeout(refresh, 10000); 181 | } 182 | } else { 183 | document.getElementById('error').style.display = 'inline' 184 | console.log('Error: ' + xmlhttp.statusText) 185 | window.setTimeout(refresh(response.nextToken), 30000); 186 | } 187 | } 188 | } catch (e) { 189 | document.getElementById('error').style.display = 'inline' 190 | console.log('Error: ' + e) 191 | window.setTimeout(refresh, 30000) 192 | 193 | document.getElementById('restart').style.display = 'block'; 194 | document.getElementById('resume').style.display = 'block'; 195 | } 196 | } 197 | xmlhttp.send(); 198 | } 199 | 200 | window.setTimeout(refresh, 10000); 201 | {% endif %} 202 | {% endblock %} -------------------------------------------------------------------------------- /templates/projects/change.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load build_status %} 3 | {% block content %} 4 |
5 | 9 |

10 | Github avatar for {{ change.user }}{{ change.title }} - {{ change.description }} 11 | 12 | 13 | 14 |

15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% for build in change.builds.all %} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | {% endfor %} 36 | 37 |
BuildCreatedCommit messageStatusResult
{{ build.commit.display_sha }}{{ build.commit.created|date:"j M Y, H:i" }}{{ build.commit.title }}{{ build.get_status_display }}{% result build.result %}
38 | {% endblock %} 39 | 40 | {% block scripts %} 41 | 42 | {% if not change.is_complete %} 43 | function refresh() { 44 | var xmlhttp=new XMLHttpRequest(); 45 | 46 | document.getElementById('spinner').style.display = 'inline' 47 | document.getElementById('error').style.display = 'none' 48 | 49 | xmlhttp.open("GET", '{{ change.get_status_url }}'); 50 | xmlhttp.onreadystatechange = function() { 51 | try { 52 | document.getElementById('spinner').style.display = 'none' 53 | if (xmlhttp.readyState == XMLHttpRequest.DONE) { 54 | if (xmlhttp.status == 200) { 55 | var response = JSON.parse(xmlhttp.responseText) 56 | var status, result, builds, row, col, link 57 | 58 | for (var slug in response.builds) { 59 | // Update task status 60 | status = document.getElementById(slug + '-status') 61 | if (status) { 62 | status.textContent = response['builds'][slug]['status'] 63 | 64 | // Update result 65 | result = document.getElementById(slug + '-result') 66 | switch (response['builds'][slug]['result']) { 67 | case 0: 68 | result.innerHTML = '{% result 0 %}' 69 | break; 70 | case 10: 71 | result.innerHTML = '{% result 10 %}' 72 | break; 73 | case 19: 74 | result.innerHTML = '{% result 19 %}' 75 | break; 76 | case 20: 77 | result.innerHTML = '{% result 20 %}' 78 | break; 79 | default: 80 | result.innerHTML = '{% result 99 %}' 81 | break; 82 | } 83 | } else { 84 | row = document.createElement('tr') 85 | row.scope = "row" 86 | 87 | col = document.createElement('td') 88 | link = document.createElement('a') 89 | link.href = response['builds'][slug]['url'] 90 | link.textContent = response['builds'][slug]['label'] 91 | col.appendChild(link) 92 | row.appendChild(col) 93 | 94 | col = document.createElement('td') 95 | col.textContent = response['builds'][slug]['timestamp'] 96 | row.appendChild(col) 97 | 98 | col = document.createElement('td') 99 | col.textContent = response['builds'][slug]['title'] 100 | row.appendChild(col) 101 | 102 | col = document.createElement('td') 103 | col.id = slug + '-status' 104 | col.className = 'minimal' 105 | col.textContent = response['builds'][slug]['status'] 106 | row.appendChild(col) 107 | 108 | col = document.createElement('td') 109 | col.id = slug + '-result' 110 | col.className = 'minimal' 111 | switch (response['builds'][slug]['result']) { 112 | case 0: 113 | col.innerHTML = '{% result 0 %}' 114 | break; 115 | case 10: 116 | col.innerHTML = '{% result 10 %}' 117 | break; 118 | case 19: 119 | col.innerHTML = '{% result 19 %}' 120 | break; 121 | case 20: 122 | col.innerHTML = '{% result 20 %}' 123 | break; 124 | default: 125 | col.innerHTML = '{% result 99 %}' 126 | break; 127 | } 128 | row.appendChild(col) 129 | 130 | builds = document.getElementById('builds') 131 | builds.insertBefore(row, builds.getElementsByTagName('tr')[0]) 132 | } 133 | } 134 | 135 | if (!response['complete']) { 136 | window.setTimeout(refresh, 30000) 137 | } 138 | } else { 139 | document.getElementById('error').style.display = 'inline' 140 | console.log('Error: ' + xmlhttp.statusText) 141 | window.setTimeout(refresh, 30000) 142 | } 143 | } 144 | } catch(e) { 145 | document.getElementById('error').style.display = 'inline' 146 | console.log('Error: ' + e) 147 | window.setTimeout(refresh, 30000) 148 | } 149 | } 150 | xmlhttp.send(); 151 | } 152 | 153 | window.setTimeout(refresh, 30000); 154 | 155 | {% endif %} 156 | 157 | {% endblock %} -------------------------------------------------------------------------------- /templates/projects/project.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load build_status %} 3 | {% block content %} 4 |
5 | 8 |

9 | Github avatar for {{ project.repository.owner }}{{ project.repository.name }} 10 | 11 | 12 |

13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | {% for change in project.changes.active %} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {% endfor %} 38 | 39 |
#AuthorDescriptionCreatedUpdatedStatusResult
{{ change.title }}Github avatar for {{ change.user }}{{ change.user }}{{ change.description }}{{ change.created|date:"j M Y, H:i" }}{{ change.updated|date:"j M Y, H:i" }}{{ change.latest_build.get_status_display }}{% result change.latest_build.result %}
40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /templates/projects/shields/fail.svg: -------------------------------------------------------------------------------- 1 | BeeKeeperBeeKeeperfailingfailing -------------------------------------------------------------------------------- /templates/projects/shields/non_critical_fail.svg: -------------------------------------------------------------------------------- 1 | BeeKeeperBeeKeepernon critical failnon critical fail -------------------------------------------------------------------------------- /templates/projects/shields/pass.svg: -------------------------------------------------------------------------------- 1 | BeeKeeperBeeKeeperpassingpassing -------------------------------------------------------------------------------- /templates/projects/shields/unknown.svg: -------------------------------------------------------------------------------- 1 | BeeKeeperBeeKeeperunknownunknown -------------------------------------------------------------------------------- /templates/projects/task.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load build_status %} 3 | {% block content %} 4 |
5 | 11 | 12 |

Build {{ build.commit.display_sha }}: {{ task.name }} 13 | 14 | 15 | 16 |

17 | 18 |
19 |
Status
20 |
{{ task.full_status_display }}
21 | 22 |
Result
23 |
{% result task.result %}
24 |
25 | 26 |
27 |

Log

{{ log }}
28 |
29 | 30 | {% endblock %} 31 | 32 | {% block scripts %} 33 | 34 | function refresh(nextToken) { 35 | return function() { 36 | var nextQuery = nextToken ? '?nextToken=' + nextToken : ''; 37 | var xmlhttp=new XMLHttpRequest(); 38 | 39 | document.getElementById('spinner').style.display = 'inline' 40 | document.getElementById('error').style.display = 'none' 41 | 42 | xmlhttp.open("GET", '{{ task.get_status_url }}' + nextQuery); 43 | xmlhttp.onreadystatechange = function() { 44 | try { 45 | document.getElementById('spinner').style.display = 'none' 46 | if (xmlhttp.readyState == XMLHttpRequest.DONE) { 47 | if (xmlhttp.status == 200) { 48 | var response = JSON.parse(xmlhttp.responseText); 49 | 50 | // Update status 51 | var status = document.getElementById('status'); 52 | status.textContent = response['status'] 53 | 54 | // Update result 55 | var result = document.getElementById('result'); 56 | switch (response['result']) { 57 | case 0: 58 | result.innerHTML = '{% result 0 %}' 59 | break; 60 | case 10: 61 | result.innerHTML = '{% result 10 %}' 62 | break; 63 | case 19: 64 | result.innerHTML = '{% result 19 %}' 65 | break; 66 | case 20: 67 | result.innerHTML = '{% result 20 %}' 68 | break; 69 | default: 70 | result.innerHTML = '{% result 99 %}' 71 | break; 72 | } 73 | 74 | // If the task has actually started, make sure 75 | // the logs are visible, and append any new logs 76 | if (response.started) { 77 | var log = document.getElementById('log') 78 | log.className = 'log'; 79 | 80 | var log_data = document.getElementById('log-data') 81 | var message = document.getElementById('message') 82 | if (response['log'] === null) { 83 | if (!message) { 84 | message = document.createElement('div') 85 | message.id = 'message' 86 | message.className = 'alert alert-warning' 87 | message.role = 'alert' 88 | 89 | log.insertBefore(message, log_data) 90 | } 91 | message.textContent = response['message'] 92 | } else { 93 | if (message) { 94 | message.parentNode.removeChild(message) 95 | } 96 | 97 | log_data.textContent += response['log'] 98 | } 99 | } 100 | 101 | // If the build has finished, remove the spinner. 102 | // Otherwise, queue the next update. 103 | if (response.finished) { 104 | var spinner = document.getElementById('log-spinner'); 105 | spinner.parentNode.removeChild(spinner); 106 | } else { 107 | window.setTimeout(refresh(response.nextToken), 1000); 108 | } 109 | } else { 110 | document.getElementById('error').style.display = 'inline' 111 | console.log('Error: ' + xmlhttp.statusText) 112 | window.setTimeout(refresh(nextToken), 30000); 113 | } 114 | } 115 | } catch (e) { 116 | document.getElementById('error').style.display = 'inline' 117 | console.log('Error: ' + e) 118 | window.setTimeout(refresh, 30000) 119 | } 120 | } 121 | xmlhttp.send(); 122 | } 123 | } 124 | 125 | window.setTimeout(refresh(), 0); 126 | 127 | {% endblock %} -------------------------------------------------------------------------------- /templates/registration/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block body %} 3 |
4 | 29 |
30 | {% endblock %} -------------------------------------------------------------------------------- /wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Use this script to test if a given TCP host/port are available 3 | 4 | # wait-for-it.sh: https://github.com/vishnubob/wait-for-it 5 | 6 | # The MIT License (MIT) 7 | # Copyright (c) 2016 Giles Hall 8 | 9 | # Permission is hereby granted, free of charge, to any person obtaining a copy of 10 | # this software and associated documentation files (the "Software"), to deal in 11 | # the Software without restriction, including without limitation the rights to 12 | # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 13 | # of the Software, and to permit persons to whom the Software is furnished to do 14 | # so, subject to the following conditions: 15 | 16 | # The above copyright notice and this permission notice shall be included in all 17 | # copies or substantial portions of the Software. 18 | 19 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | # SOFTWARE. 26 | 27 | cmdname=$(basename $0) 28 | 29 | echoerr() { if [[ $QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 30 | 31 | usage() 32 | { 33 | cat << USAGE >&2 34 | Usage: 35 | $cmdname host:port [-s] [-t timeout] [-- command args] 36 | -h HOST | --host=HOST Host or IP under test 37 | -p PORT | --port=PORT TCP port under test 38 | Alternatively, you specify the host and port as host:port 39 | -s | --strict Only execute subcommand if the test succeeds 40 | -q | --quiet Don't output any status messages 41 | -t TIMEOUT | --timeout=TIMEOUT 42 | Timeout in seconds, zero for no timeout 43 | -- COMMAND ARGS Execute command with args after the test finishes 44 | USAGE 45 | exit 1 46 | } 47 | 48 | wait_for() 49 | { 50 | if [[ $TIMEOUT -gt 0 ]]; then 51 | echoerr "$cmdname: waiting $TIMEOUT seconds for $HOST:$PORT" 52 | else 53 | echoerr "$cmdname: waiting for $HOST:$PORT without a timeout" 54 | fi 55 | start_ts=$(date +%s) 56 | while : 57 | do 58 | if [[ $ISBUSY -eq 1 ]]; then 59 | nc -z $HOST $PORT 60 | result=$? 61 | else 62 | (echo > /dev/tcp/$HOST/$PORT) >/dev/null 2>&1 63 | result=$? 64 | fi 65 | if [[ $result -eq 0 ]]; then 66 | end_ts=$(date +%s) 67 | echoerr "$cmdname: $HOST:$PORT is available after $((end_ts - start_ts)) seconds" 68 | break 69 | fi 70 | sleep 1 71 | done 72 | return $result 73 | } 74 | 75 | wait_for_wrapper() 76 | { 77 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 78 | if [[ $QUIET -eq 1 ]]; then 79 | timeout $BUSYTIMEFLAG $TIMEOUT $0 --quiet --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 80 | else 81 | timeout $BUSYTIMEFLAG $TIMEOUT $0 --child --host=$HOST --port=$PORT --timeout=$TIMEOUT & 82 | fi 83 | PID=$! 84 | trap "kill -INT -$PID" INT 85 | wait $PID 86 | RESULT=$? 87 | if [[ $RESULT -ne 0 ]]; then 88 | echoerr "$cmdname: timeout occurred after waiting $TIMEOUT seconds for $HOST:$PORT" 89 | fi 90 | return $RESULT 91 | } 92 | 93 | # process arguments 94 | while [[ $# -gt 0 ]] 95 | do 96 | case "$1" in 97 | *:* ) 98 | hostport=(${1//:/ }) 99 | HOST=${hostport[0]} 100 | PORT=${hostport[1]} 101 | shift 1 102 | ;; 103 | --child) 104 | CHILD=1 105 | shift 1 106 | ;; 107 | -q | --quiet) 108 | QUIET=1 109 | shift 1 110 | ;; 111 | -s | --strict) 112 | STRICT=1 113 | shift 1 114 | ;; 115 | -h) 116 | HOST="$2" 117 | if [[ $HOST == "" ]]; then break; fi 118 | shift 2 119 | ;; 120 | --host=*) 121 | HOST="${1#*=}" 122 | shift 1 123 | ;; 124 | -p) 125 | PORT="$2" 126 | if [[ $PORT == "" ]]; then break; fi 127 | shift 2 128 | ;; 129 | --port=*) 130 | PORT="${1#*=}" 131 | shift 1 132 | ;; 133 | -t) 134 | TIMEOUT="$2" 135 | if [[ $TIMEOUT == "" ]]; then break; fi 136 | shift 2 137 | ;; 138 | --timeout=*) 139 | TIMEOUT="${1#*=}" 140 | shift 1 141 | ;; 142 | --) 143 | shift 144 | CLI=("$@") 145 | break 146 | ;; 147 | --help) 148 | usage 149 | ;; 150 | *) 151 | echoerr "Unknown argument: $1" 152 | usage 153 | ;; 154 | esac 155 | done 156 | 157 | if [[ "$HOST" == "" || "$PORT" == "" ]]; then 158 | echoerr "Error: you need to provide a host and port to test." 159 | usage 160 | fi 161 | 162 | TIMEOUT=${TIMEOUT:-15} 163 | STRICT=${STRICT:-0} 164 | CHILD=${CHILD:-0} 165 | QUIET=${QUIET:-0} 166 | 167 | # check to see if timeout is from busybox? 168 | # check to see if timeout is from busybox? 169 | TIMEOUT_PATH=$(realpath $(which timeout)) 170 | if [[ $TIMEOUT_PATH =~ "busybox" ]]; then 171 | ISBUSY=1 172 | BUSYTIMEFLAG="-t" 173 | else 174 | ISBUSY=0 175 | BUSYTIMEFLAG="" 176 | fi 177 | 178 | if [[ $CHILD -gt 0 ]]; then 179 | wait_for 180 | RESULT=$? 181 | exit $RESULT 182 | else 183 | if [[ $TIMEOUT -gt 0 ]]; then 184 | wait_for_wrapper 185 | RESULT=$? 186 | else 187 | wait_for 188 | RESULT=$? 189 | fi 190 | fi 191 | 192 | if [[ $CLI != "" ]]; then 193 | if [[ $RESULT -ne 0 && $STRICT -eq 1 ]]; then 194 | echoerr "$cmdname: strict mode, refusing to execute subprocess" 195 | exit $RESULT 196 | fi 197 | exec "${CLI[@]}" 198 | else 199 | exit $RESULT 200 | fi -------------------------------------------------------------------------------- /worker.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import subprocess 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 7 | try: 8 | with open('.env') as envfile: 9 | for line in envfile: 10 | if line.strip() and not line.startswith('#'): 11 | key, value = line.strip().split('=', 1) 12 | os.environ.setdefault(key.strip(), value.strip()) 13 | except FileNotFoundError: 14 | pass 15 | 16 | subprocess.run([ 17 | 'celery', 18 | '-A', 'config', 19 | 'worker', 20 | '-c', '2', 21 | '--loglevel=INFO' 22 | ]) 23 | --------------------------------------------------------------------------------