├── .gitignore ├── requirements.txt ├── env.sample ├── assets └── sample_plot.png ├── utils.py ├── LICENSE ├── main.py ├── reviews.py ├── insights.py └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | bokeh -------------------------------------------------------------------------------- /env.sample: -------------------------------------------------------------------------------- 1 | WEBHOOK_SECRET=secure_webhook_secret -------------------------------------------------------------------------------- /assets/sample_plot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aavshr/grt/HEAD/assets/sample_plot.png -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import hmac 3 | 4 | WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET") 5 | 6 | # caclulate hmac digest of payload with webhook secret token 7 | def calc_signature(payload): 8 | digest = hmac.new( 9 | key=WEBHOOK_SECRET.encode("utf-8"), msg=payload, digestmod="sha1" 10 | ).hexdigest() 11 | return f"sha1={digest}" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Aavash Shrestha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request, HTTPException 2 | from fastapi.responses import HTMLResponse 3 | 4 | import utils 5 | from reviews import rev_req_store 6 | from insights import Chart 7 | 8 | # FastAPI app 9 | app = FastAPI() 10 | 11 | # chart 12 | chart = Chart() 13 | 14 | ## cache generated charts 15 | CACHE_MAX_AGE = 300 16 | 17 | 18 | @app.post("/webhook_events") 19 | async def webhook_handler(request: Request): 20 | # verify webhook signature 21 | raw = await request.body() 22 | signature = request.headers.get("X-Hub-Signature") 23 | if signature != utils.calc_signature(raw): 24 | raise HTTPException(status_code=401, detail="Unauthorized") 25 | 26 | # handle events 27 | payload = await request.json() 28 | event_type = request.headers.get("X-Github-Event") 29 | 30 | # reviews requested or removed 31 | if event_type == "pull_request": 32 | action = payload.get("action") 33 | if action == "review_requested": 34 | rev_req_store.store(payload) 35 | elif action == "review_request_removed": 36 | rev_req_store.delete(payload) 37 | return "ok" 38 | 39 | # review submitted 40 | if event_type == "pull_request_review" and payload.get("action") == "submitted": 41 | rev_req_store.mark_complete(payload) 42 | return "ok" 43 | 44 | # ignore other events 45 | return "ok" 46 | 47 | 48 | # get average turnaround insights 49 | # last: for last 'x', 'x' is only one of 'week' or 'month' currently 50 | # period: 'period to calculate average of, currently 'day' or 'week' 51 | # plot: whether to generate a plot or not, returns json if plot is False 52 | @app.get("/turnarounds/") 53 | def get_turnarounds(last: str = "week", period: str = "day", plot: bool = True): 54 | try: 55 | if not plot: 56 | return chart.get_json(last, period) 57 | 58 | html_chart = chart.get_chart(last, period) 59 | return HTMLResponse( 60 | content=html_chart, headers={"Cache-Control": f"max-age={CACHE_MAX_AGE}"} 61 | ) 62 | except ValueError: 63 | raise HTTPException(status_code=400, detail="Bad duration or period") 64 | 65 | 66 | @app.get("/") 67 | def index(): 68 | return "ok" 69 | -------------------------------------------------------------------------------- /reviews.py: -------------------------------------------------------------------------------- 1 | from dateutil.parser import isoparse 2 | from datetime import datetime, timezone 3 | from deta import Deta 4 | 5 | """ schema 6 | { 7 | key: str, // randomly_generated 8 | reviewer: str, // reviewer 9 | pull_request: int, // pull request number 10 | requested_at : int, // posix timestamp of request 11 | submitted_at : int, // posix timestamp of review submission 12 | submitted: bool, // if the review has been submitted 13 | crt: int // code review turnaround 14 | } 15 | """ 16 | 17 | # manages storing, fetching and updating review requests information 18 | class ReviewRequestStore: 19 | def __init__(self): 20 | self.db = Deta().Base("code_reviews") 21 | 22 | # get review req from pull request number and reviewer 23 | def __get_review_req(self, pr_num: int, reviewer: str): 24 | # generator 25 | review_reqs_gen = next( 26 | self.db.fetch( 27 | {"submitted": False, "pull_request": pr_num, "reviewer": reviewer} 28 | ) 29 | ) 30 | 31 | review_reqs = [] 32 | for r in review_reqs_gen: 33 | review_reqs.append(r) 34 | 35 | # there should be only one corresponding unsubmitted review request 36 | if len(review_reqs) == 0: 37 | raise Exception("No corresponding review request found") 38 | 39 | if len(review_reqs) > 1: 40 | raise Exception( 41 | "Found multiple imcomplete reviews for same pull request and reviewer" 42 | ) 43 | 44 | return review_reqs[0] 45 | 46 | # store review request 47 | def store(self, payload: dict): 48 | # POSIX timestamp 49 | current_time = int(datetime.now(timezone.utc).timestamp()) 50 | item = { 51 | "reviewer": payload["requested_reviewer"]["login"], 52 | "pull_request": payload["pull_request"]["number"], 53 | "requested_at": current_time, 54 | "submitted": False, 55 | } 56 | 57 | self.db.put(item) 58 | 59 | # mark review request complete 60 | def mark_complete(self, payload: dict): 61 | submission_time = int(isoparse(payload["review"]["submitted_at"]).timestamp()) 62 | 63 | pr_num = payload["pull_request"]["number"] 64 | reviewer = payload["review"]["user"]["login"] 65 | review_req = self.__get_review_req(pr_num, reviewer) 66 | 67 | # updates to the review request 68 | updates = { 69 | "submitted": True, 70 | "submitted_at": submission_time, 71 | "crt": submission_time - review_req["requested_at"], 72 | } 73 | 74 | self.db.update(updates, review_req["key"]) 75 | return 76 | 77 | # delete review request 78 | def delete(self, payload: dict): 79 | pr_num = payload["pull_request"]["number"] 80 | reviewer = payload["requested_reviewer"]["login"] 81 | 82 | review_req = self.__get_review_req(pr_num, reviewer) 83 | self.db.delete(review_req["key"]) 84 | 85 | # get review requests created since date 86 | def get(self, created_since: str): 87 | since = int(isoparse(created_since).timestamp()) 88 | 89 | review_reqs_since_gen = next( 90 | self.db.fetch({"requested_at?gte": since, "submitted": True}) 91 | ) 92 | 93 | review_reqs_since = [] 94 | for req in review_reqs_since_gen: 95 | review_reqs_since.append(req) 96 | 97 | return review_reqs_since 98 | 99 | 100 | rev_req_store = ReviewRequestStore() 101 | -------------------------------------------------------------------------------- /insights.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from dateutil.parser import isoparse 3 | from statistics import mean 4 | from math import isnan, nan 5 | 6 | from bokeh.plotting import figure 7 | from bokeh.resources import CDN 8 | from bokeh.embed import file_html 9 | from bokeh.models import HoverTool 10 | 11 | from reviews import rev_req_store 12 | 13 | 14 | class Chart: 15 | def __init__(self): 16 | # maps durations to number of days 17 | self.__durations = { 18 | "week": 7, # number of days 19 | "month": 30, # number of days 20 | } 21 | 22 | # maps periods to number of seconds 23 | self.__periods = { 24 | "day": 60 * 60 * 24, 25 | "week": 60 * 60 * 24 * 7, 26 | } 27 | 28 | # get submitted reviews bucketed by preiods based on duration 29 | def __get_insights(self, duration: str, period: str): 30 | if not self.__durations[duration] or not self.__periods[period]: 31 | raise ValueError("bad duration or period") 32 | 33 | since = self.__get_since(self.__durations[duration]) 34 | submitted_reviews = rev_req_store.get(since) 35 | return self.__bucket_submissions(since, period, submitted_reviews) 36 | 37 | # convert duration into iso 8601 date format 38 | def __get_since(self, days: int): 39 | since = datetime.now() - timedelta(days=days) 40 | return since.isoformat() 41 | 42 | # bucket submitted reviews based on submission timestamp since date averaged by period 43 | def __bucket_submissions(self, since: str, period: str, submitted_reviews: list): 44 | now_posix = int(datetime.now().timestamp()) 45 | since_posix = int(isoparse(since).timestamp()) 46 | 47 | buckets = {} 48 | average_buckets = {} 49 | separators = [] 50 | 51 | # separators are calculated based on period 52 | # for eg. if period is "day", separators are distanced by 86400 seconds 53 | start = since_posix + self.__periods[period] 54 | for start in range(since_posix, now_posix + 1, self.__periods[period]): 55 | buckets[start] = [] 56 | separators.append(start) 57 | 58 | # fill the buckets 59 | for rev in submitted_reviews: 60 | for separator in separators: 61 | # the separaotrs are sorted in increasing order 62 | # so a simple comparision suffices here 63 | if separator > rev["requested_at"]: 64 | buckets[separator].append(rev["crt"]) 65 | break 66 | 67 | # compute average for each bucket 68 | for separator in buckets: 69 | date = datetime.fromtimestamp(separator) 70 | crts = buckets[separator] 71 | average_buckets[date] = nan # nan here to denote missing data for the chart 72 | if len(crts) != 0: 73 | average_buckets[date] = round(mean(buckets[separator]) / 60, 2) 74 | 75 | return average_buckets 76 | 77 | # generate html chart with bokeh 78 | def __generate_chart(self, buckets: dict): 79 | p = figure( 80 | title="Average code review turnarounds", 81 | x_axis_type="datetime", 82 | x_axis_label="date", 83 | y_axis_label="average turnaround (mins)", 84 | plot_height=800, 85 | plot_width=800, 86 | ) 87 | x = list(buckets.keys()) 88 | y = list(buckets.values()) 89 | p.scatter(x, y, color="red") 90 | p.line(x, y, color="red", legend_label="moving average code review turnaround") 91 | return file_html(p, CDN, "Average code review turnarounds") 92 | 93 | # get html chart 94 | def get_chart(self, duration: str, period: str): 95 | buckets = self.__get_insights(duration, period) 96 | return self.__generate_chart(buckets) 97 | 98 | # get json of average values 99 | def get_json(self, duration: str, period: str): 100 | buckets = self.__get_insights(duration, period) 101 | for date in buckets: 102 | if isnan(buckets[date]): 103 | buckets[date] = 0 104 | return buckets 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GRT 2 | 3 | - [Introduction](#Github-Code-Review-Turnaround) 4 | - [API](#API) 5 | - [Deployment](#Deployment) 6 | 7 | ## Github Code Review Turnaround 8 | 9 | Track and view code review turnaround for your github repository. Code review turnaround is the time taken between the code review assignment and completion. This metric helps to determine if code reviews are being blockers for a team and if any optimization in the code reivew process is required. 10 | 11 | GRT uses github webhooks to maintain a database of code review requests and submissions. It offers an api to generate an html report or return a json response of the average turnarounds. 12 | 13 | ![sample_plot](assets/sample_plot.png) 14 | 15 | ```json 16 | { 17 | "2020-09-18T09:00:22": 0, 18 | "2020-09-19T09:00:22": 454.88, 19 | "2020-09-20T09:00:22": 1315.75, 20 | "2020-09-21T09:00:22": 87.13, 21 | "2020-09-22T09:00:22": 178.05, 22 | "2020-09-23T09:00:22": 95.83, 23 | "2020-09-24T09:00:22": 40.7, 24 | "2020-09-25T09:00:22": 0 25 | } 26 | ``` 27 | 28 | ## API 29 | 30 | The average turnarounds can be fetched with a simple GET request. The root endpoint is different based on the [deployment](#Deployment). 31 | 32 | ``` 33 | GET /turnarounds/?last={last}&period={period}&plot={plot} 34 | ``` 35 | 36 | For eg. `GET /turnarounds/?last=week&period=day&plot=true` provides you an html chart of the turnarounds of the last week, averaged over a day 37 | 38 | ### Query Params 39 | 40 | - `last:str` : the duration since the request to get the average turnarounds. Only `week` or `month` supported for now. Defaults to `week`. 41 | - `period:str` : the period to calculate the average over. Only `day` or `week` supported for now. Defaults to `day`. 42 | - `plot:bool` : whether to view a plot or not. Defaults to `true`. 43 | 44 | ### Responses 45 | 46 | - If `plot` is `true` returns an html chart of the average turnarounds. 47 | 48 | - If `plot` is `false` returns a json response with dates as keys and corresponding average turnarounds in minutes. A value of `0` denotes no code reviews were completed in that period. 49 | 50 | ### Client Errors 51 | 52 | - `400 Bad Request` - if `last` or `period` are bad values 53 | 54 | ## Deployment 55 | 56 | *GRT* is deployed on [Deta micros](https://docs.deta.sh/docs/micros/about). It uses [Deta Base](https://docs.deta.sh/docs/base/about) for storing information about the reviews. 57 | 58 | The following instructions are valid only for deploying to a Deta Micro. If you need to deploy to a different cloud platform or need a different database, please refer to the corresponding platform's deployment process or database setup. 59 | 60 | ### Clone the repository 61 | 62 | - Clone the repository 63 | 64 | ```shell 65 | $ git clone https://github.com/aavshr/grt 66 | ``` 67 | 68 | ### Deploy on Deta 69 | 70 | You will need to have the [Deta CLI](https://docs.deta.sh/docs/cli/install) installed. 71 | 72 | - Change the directory to the cloned directory and enter 73 | 74 | ```shell 75 | $ deta new 76 | ``` 77 | 78 | You should see the output that the application has been created and the dependencies have been installed. 79 | 80 | - After installing the app, enter 81 | 82 | ```shell 83 | $ deta details 84 | ``` 85 | 86 | You should see details about your application in your ouptut. The `endpoint` shown will be needed later to add as the webhook url on github. 87 | 88 | - Lastly disable auth by entering 89 | 90 | ```shell 91 | $ deta auth disable 92 | ``` 93 | 94 | We will use a webhook secret to verify that the events are coming from github on our webhook endpoint. 95 | 96 | ### Set up the webhook 97 | 98 | - Go to the `Webhooks` under `Settings` for your repository and click on `Add Webhook`. 99 | 100 | - In the `Payload URL` copy the endpoint from output of `deta details` and use the following url as the webhook endpoint. 101 | 102 | ``` 103 | {your_endpoint}/webhook_events 104 | ``` 105 | 106 | - Change the `Content type` to `application/json` 107 | 108 | - Generate a long secure random string (there are services online that do this) and use that as the *Webhook Secret*. Keep hold of this secret as you will need it to [set up the app's environment](#Set-up-the-environment) later. 109 | 110 | - Select `Let me select individual events` when selecting the events to trigger the webhook. Select the following events: 111 | - `Pull requests` : To know when a code review is requested 112 | - `Pull requests reviews` : To know when a code review has been submitted or deleted 113 | 114 | - Click on `Add Webhook` to add the webhook. 115 | 116 | ### Set up the environment 117 | 118 | The webhook secret used in setting up the webhook is provided through an environment variable `WEBHOOK_SECRET`. 119 | 120 | - Create a `.env` file in the app's root directory and add your secret in the file. **Make sure not to expose this file publicly**. 121 | 122 | ``` 123 | WEBHOOK_SECRET=your_webhook_secret 124 | ``` 125 | 126 | - Update the enviroment variables of your app 127 | 128 | ```shell 129 | $ deta update -e .env 130 | ``` 131 | 132 | You should see that the enviornment variables have been sucessfully updated. 133 | 134 | The application should now keep track of code review turnarounds and you can use the api to fetch them. --------------------------------------------------------------------------------