Skip to content

Commit ea1211d

Browse files
committed
first working version
1 parent f9e4822 commit ea1211d

File tree

10 files changed

+345
-2
lines changed

10 files changed

+345
-2
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,5 @@ ENV/
9999

100100
# mypy
101101
.mypy_cache/
102+
103+
.gitcredentials

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2017 Robert Hafner
3+
Copyright (c) 2017 Robert Hafner <[email protected]>
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,51 @@
1-
# GitByCommittee
1+
# GitConsensus
2+
3+
This simple project allows github projects to be automated. It uses "reaction" as a voting mechanism to automatically
4+
merge (or close) pull requests.
5+
6+
## Consensus Rules
7+
8+
The file `.gitconsensus.yaml` needs to be placed in the repository to be managed. Any rule set to `false` or ommitted
9+
will be skipped.
10+
11+
```yaml
12+
# minimum number of votes
13+
quorum: 5
14+
15+
# Required percentage of "yes" votes
16+
threshold: 0.65
17+
18+
# Only process votes by contributors
19+
contributors_only: false
20+
21+
# Number of days after last commit before issue can be merged
22+
mergedelay: 3
23+
24+
# Number of days after last commit before issue is autoclosed
25+
timeout: 3
26+
```
27+
28+
## Authentication
29+
30+
```shell
31+
gitconsensus auth
32+
```
33+
34+
You will be asked for your username, password, and 2fa token (if configured). This will be used to get an authentication
35+
token from Github that will be used in place of your username and password (which are never saved).
36+
37+
## Merge
38+
39+
Merge all pull requests that meet consensus rules.
40+
41+
```shell
42+
gitconsensus auth USERNAME REPOSITORY
43+
```
44+
45+
## Merge
46+
47+
Merge all pull requests that have passed the "timeout" date (if it is set).
48+
49+
```shell
50+
gitconsensus close USERNAME REPOSITORY
51+
```

bin/gitconsensus

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
3+
# Get real directory in case of symlink
4+
if [[ -L "${BASH_SOURCE[0]}" ]]
5+
then
6+
DIR="$( cd "$( dirname $( readlink "${BASH_SOURCE[0]}" ) )" && pwd )"
7+
else
8+
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
9+
fi
10+
11+
ENV="$DIR/../env/bin/activate"
12+
if [ ! -f $ENV ]; then
13+
echo 'Virtual Environment Not Installed- Run `make`'
14+
exit -1
15+
fi
16+
source $ENV
17+
18+
SCRIPT="$DIR/../gitconsensus/gitconsensus"
19+
$SCRIPT "$@"

gitconsensus/__init__.py

Whitespace-only changes.

gitconsensus/config.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import os
2+
import yaml
3+
4+
settings = False
5+
cwd = os.getcwd()
6+
path = "%s/%s" % (os.getcwd(), '/.gitconsensus.yaml')
7+
8+
9+
def getSettings():
10+
global settings
11+
return settings
12+
13+
14+
def reloadSettings():
15+
global settings
16+
if os.path.isfile(path):
17+
with open(path, 'r') as f:
18+
settings = yaml.load(f)
19+
return settings
20+
21+
22+
def getGitToken():
23+
token = id = ''
24+
with open("%s/%s" % (os.getcwd(), '/.gitcredentials'), 'r') as fd:
25+
return {
26+
"id": fd.readline().strip(),
27+
"token": fd.readline().strip()
28+
}
29+
return False
30+
31+
reloadSettings()

gitconsensus/gitconsensus

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
#!/usr/bin/env python
2+
import click
3+
import github3
4+
import os
5+
import random
6+
from repository import Repository
7+
import string
8+
9+
@click.group()
10+
@click.pass_context
11+
def cli(ctx):
12+
if ctx.parent:
13+
print(ctx.parent.get_help())
14+
15+
16+
@cli.command(short_help="obtain an authorization token")
17+
def auth():
18+
username = click.prompt('Username')
19+
password = click.prompt('Password', hide_input=True)
20+
def twofacallback(*args):
21+
return click.prompt('2fa Code')
22+
23+
hostid = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(8))
24+
note = 'gitconsensus - %s' % (hostid,)
25+
note_url = 'https://github.com/tedivm/GitConsensus'
26+
scopes = ['repo']
27+
auth = github3.authorize(username, password, scopes, note, note_url, two_factor_callback=twofacallback)
28+
29+
with open("%s/%s" % (os.getcwd(), '/.gitcredentials'), 'w') as fd:
30+
fd.write(str(auth.id) + '\n')
31+
fd.write(auth.token + '\n')
32+
33+
34+
@cli.command(short_help="list open pull requests and their status")
35+
@click.argument('username')
36+
@click.argument('repository_name')
37+
def list(username, repository_name):
38+
repo = Repository(username, repository_name)
39+
requests = repo.getPullRequests()
40+
for request in requests:
41+
print("PR#%s: %s" % (request.number, request.validate()))
42+
43+
44+
@cli.command(short_help="merge open pull requests that validate")
45+
@click.argument('username')
46+
@click.argument('repository_name')
47+
def merge(username, repository_name):
48+
repo = Repository(username, repository_name)
49+
requests = repo.getPullRequests()
50+
for request in requests:
51+
if request.validate():
52+
print("Merging PR#%s" % (request.number,))
53+
request.merge()
54+
55+
56+
@cli.command(short_help="close older unmerged opened pull requests")
57+
@click.argument('username')
58+
@click.argument('repository_name')
59+
def close(username, repository_name):
60+
repo = Repository(username, repository_name)
61+
requests = repo.getPullRequests()
62+
for request in requests:
63+
if request.shouldClose():
64+
print("Closing PR#%s" % (request.number,))
65+
request.pr.close()
66+
67+
68+
69+
if __name__ == '__main__':
70+
cli()

gitconsensus/repository.py

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import config
2+
import datetime
3+
import github3
4+
import json
5+
import requests
6+
import yaml
7+
8+
def githubApiRequest(url):
9+
auth = config.getGitToken()
10+
headers = {
11+
'Accept': 'application/vnd.github.squirrel-girl-preview',
12+
'user-agent': 'gitconsensus',
13+
'Authorization': "token %s" % (auth['token'],)
14+
}
15+
return requests.get(url, headers=headers)
16+
17+
18+
class Repository:
19+
20+
def __init__(self, user, repository):
21+
self.user = user
22+
self.name = repository
23+
auth = config.getGitToken()
24+
self.client = github3.login(token=auth['token'])
25+
self.client.set_user_agent('gitconsensus')
26+
self.repository = self.client.repository(self.user, self.name)
27+
consensusurl = "https://raw.githubusercontent.com/%s/%s/master/.gitconsensus.yaml" % (self.user, self.name)
28+
res = githubApiRequest(consensusurl)
29+
self.rules = False
30+
if res.status_code == 200:
31+
self.rules = yaml.load(res.text)
32+
33+
def getPullRequests(self):
34+
prs = self.repository.iter_pulls(state="open")
35+
retpr = []
36+
for pr in prs:
37+
newpr = PullRequest(self, pr.number)
38+
retpr.append(newpr)
39+
return retpr
40+
41+
42+
class PullRequest:
43+
44+
def __init__(self, repository, number):
45+
self.repository = repository
46+
self.number = number
47+
self.pr = self.repository.client.pull_request(self.repository.user, self.repository.name, number)
48+
49+
# https://api.github.com/repos/OWNER/REPO/issues/1/reactions
50+
reacturl = "https://api.github.com/repos/%s/%s/issues/%s/reactions" % (self.repository.user, self.repository.name, self.number)
51+
res = githubApiRequest(reacturl)
52+
reactions = json.loads(res.text)
53+
54+
self.yes = []
55+
self.no = []
56+
self.users = []
57+
for reaction in reactions:
58+
content = reaction['content']
59+
user = reaction['user']
60+
if content == '+1':
61+
self.yes.append(user['login'])
62+
elif content == '-1':
63+
self.no.append(user['login'])
64+
else:
65+
continue
66+
67+
if user['login'] not in self.users:
68+
self.users.append(user['login'])
69+
70+
def daysSinceLastCommit(self):
71+
commits = self.pr.iter_commits()
72+
73+
for commit in commits:
74+
commit_date_string = commit._json_data['commit']['author']['date']
75+
76+
# 2017-08-19T23:29:31Z
77+
commit_date = datetime.datetime.strptime(commit_date_string, '%Y-%m-%dT%H:%M:%SZ')
78+
now = datetime.datetime.now()
79+
delta = commit_date - now
80+
return delta.days
81+
82+
def validate(self):
83+
if self.repository.rules == False:
84+
return False
85+
consenttest = Consensus(self.repository.rules)
86+
return consenttest.validate(self)
87+
88+
def shouldClose(self):
89+
if 'timeout' in self.repository.rules:
90+
if self.repository.rules['timeout'] < self.daysSinceLastCommit():
91+
return True
92+
return False
93+
94+
def merge(self):
95+
self.pr.merge('Consensus Merge')
96+
97+
98+
99+
class Consensus:
100+
def __init__(self, rules):
101+
self.rules = rules
102+
103+
def validate(self, pr):
104+
if not self.isMergeable(pr):
105+
return False
106+
if not self.hasQuorum(pr):
107+
return False
108+
if not self.hasVotes(pr):
109+
return False
110+
if not self.hasAged(pr):
111+
return False
112+
return False
113+
114+
def isMergeable(self, pr):
115+
if not pr.mergeable:
116+
return False
117+
return True
118+
119+
def hasQuorum(self, pr):
120+
if 'quorum' in self.rules:
121+
if len(pr.users) < self.rules['quorum']:
122+
return False
123+
return True
124+
125+
def hasVotes(self, pr):
126+
if 'threshold' in self.rules:
127+
ratio = len(pr.yes) / (len(pr.yes) + len(pr.no))
128+
if ratio < self.rules['threshold']:
129+
return False
130+
return True
131+
132+
def hasAged(self, pr):
133+
if 'mergedelay' in self.rules:
134+
days = pr.daysSinceLastCommit()
135+
if days < self.rules['mergdelay']:
136+
return False
137+
return True

makefile

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
2+
SHELL:=/bin/bash
3+
ROOT_DIR:=$(shell dirname $(realpath $(lastword $(MAKEFILE_LIST))))
4+
5+
.PHONY: all fresh dependencies install fulluninstall uninstall removedeps
6+
7+
all: dependencies
8+
9+
fresh: fulluninstall dependencies
10+
11+
fulluninstall: uninstall cleancode
12+
13+
install:
14+
ln -s -f $(ROOT_DIR)/bin/gitconsensus /usr/local/bin/gitconsensus
15+
16+
dependencies:
17+
if [ ! -d $(ROOT_DIR)/env ]; then virtualenv $(ROOT_DIR)/env; fi
18+
source $(ROOT_DIR)/env/bin/activate; yes w | pip install -r $(ROOT_DIR)/requirements.txt
19+
20+
uninstall:
21+
if [ -L /usr/local/bin/gitconsensus ]; then \
22+
rm /usr/local/bin/gitconsensus; \
23+
fi;
24+
25+
cleancode:
26+
rm -rf $(ROOT_DIR)/*.pyc
27+
# Remove existing environment
28+
if [ -d $(ROOT_DIR)/env ]; then \
29+
rm -rf $(ROOT_DIR)/env; \
30+
fi;

requirements.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
click==0.5
2+
github3.py==0.9.6
3+
PyYAML==3.12
4+
requests==2.18.4

0 commit comments

Comments
 (0)