├── .nojekyll ├── api-requirements.txt ├── .gitignore ├── LICENSE.txt ├── README.md ├── app.py ├── main.js └── index.html /.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api-requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi >= 0.98.0 2 | uvicorn[standard] 3 | florapi @ https://github.com/ichard26/florapi/archive/main.zip 4 | httpx 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | 4 | # Distribution / packaging 5 | .Python 6 | build/ 7 | develop-eggs/ 8 | dist/ 9 | downloads/ 10 | eggs/ 11 | .eggs/ 12 | lib/ 13 | lib64/ 14 | parts/ 15 | sdist/ 16 | var/ 17 | wheels/ 18 | share/python-wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | MANIFEST 23 | 24 | # PyInstaller 25 | # Usually these files are written by a python script from a template 26 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 27 | *.manifest 28 | *.spec 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .nox/ 38 | .coverage 39 | .coverage.* 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | *.cover 44 | *.py,cover 45 | .hypothesis/ 46 | .pytest_cache/ 47 | cover/ 48 | 49 | # Sphinx documentation 50 | docs/_build/ 51 | docs/build/ 52 | docs/html/ 53 | 54 | # Environments 55 | .env 56 | .venv 57 | env/ 58 | venv/ 59 | ENV/ 60 | 61 | # mypy 62 | .mypy_cache/ 63 | .dmypy.json 64 | dmypy.json 65 | 66 | # Editor configuration 67 | .vscode/ 68 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Richard Si 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next PR Number 2 | 3 | Next PR Number is a web tool that allows you to quickly and easily know what number 4 | your PR will be assigned before opening it. Forget guessing the number or checking 5 | afterhand. 6 | 7 | **Features:** 8 | 9 | - **Sharable URLs that autofill the repository** - Want your project's contributors to 10 | use Next PR Number? Give them a link that autofills and queries your repository for 11 | them for a seamless experience. [Here's an example.][example] 12 | - **Lightweight** - Next PR Number is built with pure HTML, JS, CSS. There's no 13 | dependencies that slow down page load (except for analytics provided by a self-hosted 14 | Plausible instance) 15 | 16 | In all seriousness, this exists to make changelog entry enforcement easier 17 | and a smoother experience for everyone. When I maintained [black] and [bandersnatch], 18 | writing a release changelog takes time, time that could've been better spent elsewhere. 19 | So a policy was introduced where (substantial) PRs must have an 20 | changelog entry... and the PR number. While that reduced the administrative workload, 21 | it merely pushed the workload onto the contributors, who must now get the PR number 22 | somehow. 23 | 24 | This website was created to give contributors a fast and painless way to get that PR 25 | number so they can focus on writing good code. 26 | 27 | **Note: Next PR Number only supports public GitHub repositories** 28 | 29 | ## Privacy statement 30 | 31 | Next PR Number to subject to the [privacy statement on my personal website][privacy]. 32 | 33 | ## Contributing 34 | 35 | Contributions of all sorts are welcomed, even feedback if it's constructive! Opening 36 | an issue to check with me before working on a changeset is highly recommended. 37 | 38 | In terms of technical tips, I recommend that you use Python's built-in webserver to test 39 | locally while iterating on your changes: 40 | 41 | ```console 42 | example-user@example-desktop:~$ cd next-pr-number 43 | example-user@example-desktop:~/next-pr-number$ python -m http.server 4000 44 | Serving HTTP on 0.0.0.0 port 4000 (http://0.0.0.0:4000/) ... 45 | ``` 46 | 47 | Please note that this project to also my excuse to learn web development. So I'm sure 48 | I'm doing a ton of things wrong in this project :) I'm open to feedback in this regard 49 | too. 50 | 51 | Finally, don't forget to add yourself to the AUTHORS list below. You made a contribution 52 | and you deserve the thanks! 53 | 54 | ### SQLite3 Schema 55 | 56 | ```sql 57 | CREATE TABLE "queries" ( 58 | "datetime" TEXT PRIMARY KEY NOT NULL, 59 | "owner" TEXT NOT NULL, 60 | "name" TEXT NOT NULL, 61 | "result" INTEGER NOT NULL 62 | ); 63 | ``` 64 | 65 | ## Authors 66 | 67 | Glued together by Richard Si ([@ichard26](https://github.com/ichard26)). 68 | 69 | - Jaap Roes 70 | - Marc Mueller ([@cdce8p](https://github.com/cdce8p)) 71 | - *your name here perhaps?* 72 | 73 | [bandersnatch]: https://github.com/pypa/bandersnatch 74 | [black]: https://github.com/psf/black 75 | [example]: https://ichard26.github.io/next-pr-number/?owner=ichard26&name=next-pr-number 76 | [privacy]: https://ichard26.github.io/privacy/ 77 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from contextlib import asynccontextmanager 2 | from functools import partial 3 | from pathlib import Path 4 | 5 | import httpx 6 | from fastapi import FastAPI, HTTPException 7 | from fastapi.middleware.cors import CORSMiddleware 8 | from florapi import utc_now 9 | from florapi.configuration import Options 10 | from florapi.middleware import ProxyHeadersMiddleware, TimedLogMiddleware 11 | from florapi.security import RateLimiter 12 | from florapi.sqlite import open_sqlite_connection, register_adaptors 13 | 14 | THIS_DIR = Path(__file__).parent 15 | GRAPHQL_API = "https://api.github.com/graphql" 16 | # This query was originally written by Jakub Kuczys (GitHub: @Jackenmen) who graciously 17 | # gave me permission to use his work freely here. 18 | GRAPHQL_QUERY = """ 19 | query getLastIssueNumber { 20 | repository(owner: "$owner", name: "$name") { 21 | discussions(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { 22 | nodes { 23 | number 24 | } 25 | } 26 | issues(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { 27 | nodes { 28 | number 29 | } 30 | } 31 | pullRequests(orderBy: {field: CREATED_AT, direction: DESC}, first: 1) { 32 | nodes { 33 | number 34 | } 35 | } 36 | } 37 | } 38 | """ 39 | opt = Options() 40 | GITHUB_TOKEN = opt("github-token", type=str) 41 | DATABASE_PATH = opt("database", default=THIS_DIR / "db.sqlite3", type=Path) 42 | opt.report_errors() 43 | 44 | register_adaptors() 45 | open_sqlite_connection = partial(open_sqlite_connection, DATABASE_PATH) 46 | 47 | 48 | @asynccontextmanager 49 | async def lifespan(app: FastAPI): 50 | yield 51 | await http.aclose() 52 | 53 | 54 | app = FastAPI(lifespan=lifespan) 55 | app.add_middleware( 56 | CORSMiddleware, 57 | allow_origins=["https://ichard26.github.io"], 58 | allow_methods=["GET"], 59 | ) 60 | app.add_middleware(ProxyHeadersMiddleware) 61 | app.add_middleware(TimedLogMiddleware, sqlite_factory=open_sqlite_connection) 62 | http = httpx.AsyncClient() 63 | 64 | 65 | @app.get("/") 66 | async def get_next_number(owner: str, name: str) -> int: 67 | db = open_sqlite_connection() 68 | limiter = RateLimiter("api", {RateLimiter.HOUR: 25, RateLimiter.DAY: 100}, db) 69 | if limiter.update_and_check("query"): 70 | raise HTTPException(429, "API rate limit exceeded") 71 | 72 | query = GRAPHQL_QUERY.replace("$owner", owner).replace("$name", name) 73 | response = await http.post( 74 | GRAPHQL_API, json={"query": query}, headers={"Authorization": f"Bearer {GITHUB_TOKEN}"} 75 | ) 76 | repository_data = response.json()["data"]["repository"] 77 | if repository_data is None: 78 | raise HTTPException(404, "repository not found") 79 | 80 | # These four lines were originally written by Jakub Kuczys (GitHub: @Jackenmen) who 81 | # graciously gave me permission to use his work freely here. 82 | current_number = max( 83 | next(iter(data["nodes"]), {"number": 0})["number"] 84 | for data in repository_data.values() 85 | ) 86 | db.insert("queries", { 87 | "datetime": utc_now(), 88 | "owner": owner, 89 | "name": name, 90 | "result": current_number + 1 91 | }) 92 | db.commit() 93 | return current_number + 1 94 | -------------------------------------------------------------------------------- /main.js: -------------------------------------------------------------------------------- 1 | const defaultTitle = "Next PR Number"; 2 | const repoInput = document.querySelector(".repo-input"); 3 | const getButton = document.querySelector(".get-button"); 4 | const workingStatusText = document.querySelector(".working-status-text"); 5 | const resultText = document.querySelector(".result-text"); 6 | const validRepoRegex = /^\s*([-\w]+)\s*\/\s*([-.\w]+)\s*$|^https:\/\/github\.com\/([-\w]+)\/([-.\w]+)(?:\/.*)?$/ 7 | let working; 8 | 9 | const API_ENDPOINT = "https://internal.floralily.dev/next-pr-number-api"; 10 | 11 | function sanitizeString(string) { 12 | const map = { 13 | '&': '&', 14 | '<': '<', 15 | '>': '>', 16 | '"': '"', 17 | "'": ''', 18 | "/": '/', 19 | }; 20 | const reg = /[&<>"'/]/ig; 21 | return string.replace(reg, (match) => (map[match])); 22 | } 23 | 24 | class HTTPError extends Error { 25 | constructor(code, message) { 26 | super(message); 27 | this.name = "HTTPError"; 28 | this.status = code; 29 | } 30 | } 31 | 32 | async function getNextNumber(owner, name) { 33 | const response = await fetch(`${API_ENDPOINT}/?owner=${owner}&name=${name}`); 34 | const data = await response.json(); 35 | if (!response.ok) { 36 | console.error("HTTPError", response); 37 | throw new HTTPError(response.status, data.detail); 38 | } 39 | plausible("Next PR Number: Fetch", {props: {repository: `${owner}/${name}`}}); 40 | return data; 41 | } 42 | 43 | function checkInputValidity() { 44 | if (!validRepoRegex.test(repoInput.value)) { 45 | let msg; 46 | if (!repoInput.value.length) { 47 | msg = "Please enter a repo. e.g. github/docs"; 48 | } else { 49 | msg = "Please match the {owner}/{name} format. e.g. github/docs" 50 | } 51 | repoInput.setCustomValidity(msg); 52 | repoInput.reportValidity(); 53 | return false; 54 | } 55 | return true; 56 | } 57 | 58 | function setWorkingStatus() { 59 | working = true; 60 | let workingStep = 1; 61 | repoInput.readOnly = true; 62 | getButton.disabled = true; 63 | function showWorkingDots() { 64 | if (working) { 65 | workingStatusText.textContent = `working${" .".repeat(workingStep)}` 66 | if (workingStep === 3) { 67 | workingStep = 1; 68 | } else { 69 | workingStep++; 70 | } 71 | setTimeout(showWorkingDots, 150); 72 | } 73 | } 74 | showWorkingDots(); 75 | } 76 | 77 | function setFinishedStatus(errored, details) { 78 | working = false; 79 | workingStatusText.textContent = errored ? "ERROR!!!" : "done!"; 80 | if (errored) { 81 | workingStatusText.classList.add("error"); 82 | resultText.classList.add("error"); 83 | } 84 | resultText.innerHTML = details; 85 | repoInput.readOnly = false; 86 | getButton.disabled = false; 87 | } 88 | 89 | function updateQueryParamsAndTitle(owner, name) { 90 | const url = new URL(window.location.href); 91 | const searchParams = url.searchParams; 92 | searchParams.set("owner", owner); 93 | searchParams.set("name", name); 94 | window.history.replaceState(null, null, url); 95 | document.title = owner.concat("/", name, " | ", defaultTitle); 96 | } 97 | 98 | function removeQueryParamsAndTitle() { 99 | const url = new URL(window.location.href); 100 | const searchParams = url.searchParams; 101 | for (let key of Array.from(searchParams.keys())) { 102 | searchParams.delete(key) 103 | } 104 | window.history.replaceState(null, null, url) 105 | document.title = defaultTitle; 106 | } 107 | 108 | function resetOutputStatus() { 109 | workingStatusText.innerHTML = ""; 110 | workingStatusText.classList.remove("error"); 111 | resultText.innerHTML = ""; 112 | resultText.classList.remove("error"); 113 | removeQueryParamsAndTitle(); 114 | } 115 | 116 | async function onSubmit() { 117 | if (!checkInputValidity()) { 118 | return; 119 | } 120 | const match = validRepoRegex.exec(repoInput.value); 121 | const [owner, name] = match[1] ? match.slice(1, 3) : match.slice(3, 5); 122 | resetOutputStatus(); 123 | setWorkingStatus(); 124 | let resultString; 125 | try { 126 | const nextNumber = await getNextNumber(owner, name); 127 | resultString = `${nextNumber.toString().bold()} will be the next number assigned.` 128 | setFinishedStatus(false, resultString); 129 | updateQueryParamsAndTitle(owner, name); 130 | } 131 | catch (err) { 132 | if (err.name === "HTTPError") { 133 | if (err.status === 404) { 134 | resultString = "That repository doesn't exist."; 135 | } else if (err.status === 403 && err.message.toLowerCase().includes("api rate limit exceeded")) { 136 | resultString = "API rate limit exceeded. Please wait and try again later." 137 | } else { 138 | resultString = `unexpected error: ${sanitizeString(err.toString())}`; 139 | } 140 | } else { 141 | resultString = `unexpected error: ${sanitizeString(err.toString())}`; 142 | } 143 | setFinishedStatus(true, resultString); 144 | } 145 | } 146 | 147 | repoInput.addEventListener("keydown", (event) => { 148 | repoInput.setCustomValidity(""); 149 | if (event.key === "Enter") { 150 | onSubmit(); 151 | } 152 | }); 153 | getButton.addEventListener("click", onSubmit); 154 | 155 | function maybeUseRepoFromURL() { 156 | const params = new URLSearchParams(document.location.search.substring(1)); 157 | let owner = params.get("owner"), name = params.get("name"); 158 | if (owner === null || name === null) { 159 | return; 160 | } 161 | repoInput.blur(); 162 | document.querySelector("#tip-admonition").remove(); 163 | repoInput.value = `${owner}/${name}`; 164 | getButton.click(); 165 | } 166 | 167 | window.addEventListener("DOMContentLoaded", maybeUseRepoFromURL); 168 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Next PR Number - Forget guessing or checking afterhand 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 198 | 199 | 200 | 201 |
202 |

Next PR Number

203 |

Get the number the next pull request will be assigned with for a public GitHub repository.

204 |
205 | 206 | 207 |
208 |
209 |

210 | 211 |
212 |
213 | 227 |
228 |

Tip!

229 |

You can use URL query parameters to prefill the repository. For example: 230 | 231 | ichard26.github.io/next-pr-number/?owner=ichard26&name=next-pr-number 232 | 233 |

234 |
235 | 236 | 237 | 238 | --------------------------------------------------------------------------------