diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..8394c08
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2024 sideshowbarker
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..c4c2ff0
--- /dev/null
+++ b/README.md
@@ -0,0 +1,145 @@
+## git-gloss ✨ make git logs show PR/issue/reviewer links
+
+### How to use git-gloss
+
+You can download and run `git-gloss` within a directory having a GitHub repo clone by doing this:
+
+```
+curl -fsSLO https://sideshowbarker.github.io/git-gloss/git-gloss && bash ./git-gloss
+```
+
+That will add [git notes](https://web.archive.org/web/20180121193320/http://git-scm.com/blog/2010/08/25/notes.html) locally for all commits in the local commit history with an associated GitHub pull request.
+
+#### How long does it take?
+
+`git-gloss` can process at most about 22 commits per minute — or about 1300 commits per hour.
+
+So, the first time you run it in a repo with many commits, it’ll take a long time — hours, or even a day or more.
+
+For example, if your repo has somewhere around 1000 commits, it’ll take about 45 minutes to finish. If your repo has somewhere around 10,000 commits, it’ll take more than 7 hours. And so on.
+
+You can stop `git-gloss` at any time with Ctrl-C. After stopping it, when you run it again, it will start off wherever it left off. So for example, if you have a repo with somewhere around 10,000 commits, and you stopped `git-gloss` after it was running for about an hour, then it will run for about another hour before it finishes.
+
+#### How to fix errors
+
+`git-gloss` doesn’t yet have error handling for the case where a call to the GitHub API fails. So you can keep a log of its output, and review it after `git-gloss` finishes. You can create a log by invoking `git-gloss` like this:
+
+```
+git-gloss 2>&1 | tee $(mktemp -p .)
+```
+
+That creates a log file in the current directory with a name such as `tmp.gndYHHPKgA` — using the `mktemp` and `tee` utilities, which are standard in any Linux/Unix environment (including the macOS Terminal/shell environment).
+
+After `git-log` finishes (or even as it’s running), you can review the log for error output that looks like this:
+
+```
+1320/56162 https://github.com/LadybirdBrowser/ladybird/commit/67c727177e
+jq: parse error: Invalid numeric literal at line 1, column 314
+jq: parse error: Invalid numeric literal at line 1, column 314
+jq: error (at :1): Cannot index object with number
+jq: parse error: Invalid numeric literal at line 1, column 315
+```
+
+For each case like that which you see in the log output, you’ll need to complete the following steps:
+
+1. Remove any note which `git-gloss` may have added for the given commit:
+
+ ```
+ git notes remove 67c727177e
+ ```
+
+2. (Re)add a note for the given commit, by running `git-gloss` with the commit hash specified:
+
+ ```
+ git-gloss 67c727177e
+ ```
+
+#### How to share the notes
+
+Once `git-gloss` finishes running, here’s how you can share the notes with everyone in your GitHub project:
+
+1. Push the notes back to your project remote at GitHub by running this command:
+
+ ```
+ git push origin 'refs/notes/*'
+ ```
+
+2. Others in your project can then fetch the notes from GitHub by running this command:
+
+ ```
+ git fetch origin 'refs/notes/*:refs/notes/*'
+ ```
+
+ Alternatively, rather than running the above command manually, others in the project can update their git configuration by runing the following command;
+
+ ```
+ git config --add remote.origin.fetch 'refs/notes/*:refs/notes/*'
+ ```
+
+ That will cause all notes to be fetched from the remote every time they use `git fetch` or `git pull`.
+
+3. Run `git-gloss` again to add notes for any new commits made after the last time you ran `git-gloss`.
+
+4. Keep your project’s notes up to date by repeating steps 1 to 3 at a regular cadence (e.g., once day or so).
+
+### Dependencies
+
+* `git` – any version with support for the `--no-separator` option for the `git notes` command
+
+* `jq` – https://jqlang.github.io/jq/ (JSON processor)
+
+* `gh` – https://cli.github.com/ (GitHub CLI)
+
+* `grep` – on macOS in particular, a grep program other than the Apple-provided one is recommended (for instance, GNU grep or [ripgrep](https://github.com/BurntSushi/ripgrep#installation)); example:
+
+ ```
+ brew install ripgrep
+ brew install grep
+ ```
+
+### Environment variables
+
+You can affect the `git-gloss` behavior using the following environment variables:
+
+* `GIT`
+
+ You can use this to specify a path to a different `git` binary — for instance, in the case where you have multiple different `git` versions on your system; example:
+
+ ```
+ export GIT=/opt/homebrew/bin/git
+ ```
+
+* `GREP`
+
+ You can use this to specify a path to any grep-compatible binary on your system; for instance, to avoid using the Apple-provided `grep` on macOS; example:
+
+ ```
+ export GREP=/opt/homebrew/bin/rg
+ ```
+
+* `OTHER_REPO`
+
+ You can use this to specify an `[owner]/[repo]` repo other than the current repo; e.g., a repo the current repo shares part of its commit history with (because the current repo was created from an older repo); example:
+
+ ```
+ export OTHER_REPO=SerenityOS/serenity
+ ```
+
+ If you specify an `OTHER_REPO` value, then if `git-gloss` can’t find any pull request for a particular commit in the current repo, it will then look for a pull request in the repo you specified in the `OTHER_REPO` value.
+
+* `OTHER_OTHER_REPO`
+
+ You can use this in addition to `OTHER_REPO` — if you have a third repo with a shared history; example:
+
+ ```
+ export OTHER_OTHER_REPO=LadybirdBrowser/ancient-history
+ ```
+
+Rather than exporting each of those environment variables to your shell, you can instead specify them all at the same time in the invocation you use for running `git-gloss` — like this:
+
+```
+OTHER_OTHER_REPO=LadybirdBrowser/ancient-history OTHER_REPO=SerenityOS/serenity \
+ GREP=rg GIT=/opt/homebrew/bin/git ./git-gloss
+```
+
+## Make a “git gloss” command
diff --git a/git-gloss b/git-gloss
new file mode 100755
index 0000000..cd3b8c9
--- /dev/null
+++ b/git-gloss
@@ -0,0 +1,157 @@
+#!/bin/bash
+
+GIT=${GIT:-git}
+GREP=${GREP:-grep}
+
+remoteURLproperty=remote.origin.url
+if [ -n "$(GIT config --get remote.upstream.url)" ]; then
+ remoteURLproperty=remote.upstream.url
+fi
+
+repoURL=$(GIT config --get $remoteURLproperty | sed -r \
+ 's/.*(\@|\/\/)(.*)(\:|\/)([^:\/]*)\/([^\/]*)\.git/https:\/\/\2\/\4\/\5/')
+
+startRepo=$(echo "$repoURL" | rev | cut -d '/' -f-1 | rev)
+startOwner=$(echo "$repoURL" | rev | cut -d '/' -f2 | rev)
+if [[ $repoURL == *"github"* && -z "$startRepo" ]]; then
+ echo
+ echo -n -e \
+ "Error: This tool must be run from within a clone " >&2
+ echo -e "of a GitHub repo. Stopping." >&2
+ exit 1;
+fi
+
+otherRepo=$(echo "$OTHER_REPO" | rev | cut -d '/' -f-1 | rev)
+otherOwner=$(echo "$OTHER_REPO" | rev | cut -d '/' -f2 | rev)
+otherOtherRepo=$(echo "$OTHER_OTHER_REPO" | rev | cut -d '/' -f-1 | rev)
+otherOtherOwner=$(echo "$OTHER_OTHER_REPO" | rev | cut -d '/' -f2 | rev)
+
+if ! [[ -x "$(command -v jq)" ]]; then
+ echo
+ echo -e \
+ "Error: You must have jq installed in order to use this tool." >&2
+ exit 1;
+fi
+allCommits=$(mktemp)
+commitsWithNotes=$(mktemp)
+commitsWithoutNotes=$(mktemp)
+$GIT log --pretty=format:"%H" > "$allCommits"
+$GIT notes list | xargs | tr " " "\n" > "$commitsWithNotes"
+if [[ -s "$commitsWithNotes" ]]; then
+ $GREP -Fxv -f "$commitsWithNotes" "$allCommits" > "$commitsWithoutNotes"
+else
+ commitsWithoutNotes=$allCommits
+fi
+totalCommitsCount=$(wc -l < "$commitsWithoutNotes" | xargs)
+if [ "$totalCommitsCount" -eq "0" ]; then
+ commitsWithoutNotes=$allCommits
+fi
+echo "Found $totalCommitsCount commits to process."
+echo
+currentCommitNumber=0
+
+addNote () {
+ sleep .1
+ ((currentCommitNumber++))
+ printf "%${#totalCommitsCount}d" "$currentCommitNumber"
+ echo -n "/$totalCommitsCount "
+ shortCommitHash=$(git rev-parse --short "$1")
+ echo "https://github.com/$owner/$repo/commit/$shortCommitHash"
+ commit=$(gh api "/repos/$owner/$repo/commits/$1" 2>&1)
+ pullRequest=$(gh api "/repos/$owner/$repo/commits/$1/pulls" 2>&1)
+ if [[ "$pullRequest" == "[]"
+ || $(echo "$pullRequest" | jq ".status" 2>/dev/null) == '"422"' ]]; then
+ if [[ -n "$otherRepo" ]]; then
+ otherCommit=$(gh api "/repos/$otherOwner/$otherRepo/commits/$1" 2>&1)
+ if [[ $(echo "$otherCommit" | jq ".status" 2>/dev/null) != '"422"' ]]; then
+ pullRequest=$(gh api "/repos/$otherOwner/$otherRepo/commits/$1/pulls" 2>&1)
+ owner="$otherOwner"; repo="$otherRepo"
+ elif [[ -n "$otherOtherRepo" ]]; then
+ otherOtherCommit=$(gh api "/repos/$otherOtherOwner/$otherOtherRepo/commits/$1" 2>&1)
+ if [[ $(echo "$otherOtherCommit" | jq ".status" 2>/dev/null) != '"422"' ]]; then
+ pullRequest=$(gh api "/repos/$otherOtherOwner/$otherOtherRepo/commits/$1/pulls" 2>&1)
+ owner="$otherOtherOwner"; repo="$otherOtherRepo"
+ fi
+ fi
+ fi
+ fi
+ author=$(echo "$commit" | jq ".author.login" | tr -d '"')
+ authorEmail=$(echo "$commit" | jq ".commit.author.email" | tr -d '"')
+ authorFirstCommit=$(git log --format="%H" --no-use-mailmap --author="$authorEmail" | tail -1)
+ if [[ "$authorFirstCommit" == "$1" ]]; then
+ $GIT notes append --no-separator -m "Author: https://github.com/$author 🔰" "$1"
+ else
+ $GIT notes append --no-separator -m "Author: https://github.com/$author" "$1"
+ fi
+ $GIT notes append --no-separator -m "Commit: https://github.com/$owner/$repo/commit/$shortCommitHash" "$1"
+ if [[ "$pullRequest" == "[]"
+ || $(echo "$pullRequest" | jq ".status" 2>/dev/null) == '"422"' ]]; then
+ return 0
+ fi
+ prNumber=$(echo "$pullRequest" | jq '.[0].number')
+ if [[ -n "$prNumber" && "$prNumber" != "null" ]]; then
+ $GIT notes append --no-separator -m "Pull-request: https://github.com/$owner/$repo/pull/$prNumber" "$1"
+ # shellcheck disable=SC2016
+ issues=$(gh api graphql \
+ -F owner="$owner" -F repo="$repo" -F pr="$prNumber" -f query='
+ query ($owner: String!, $repo: String!, $pr: Int!) {
+ repository(owner: $owner, name: $repo) {
+ pullRequest(number: $pr) {
+ closingIssuesReferences(first: 100) {
+ nodes {
+ number
+ }
+ }
+ }
+ }
+ }' --jq '.data.repository.pullRequest.closingIssuesReferences.nodes[].number')
+ if [[ -n "$issues" ]]; then
+ for issue in $issues; do
+ $GIT notes append --no-separator -m "Issue: https://github.com/$owner/$repo/issues/$issue" "$1"
+ done;
+ fi
+ reviewerDataFile=$(mktemp)
+ reviews=$(gh api "/repos/$owner/$repo/pulls/$prNumber/reviews" 2>/dev/null | jq "sort_by(.state)")
+ if [[ -n "$reviews" && $(echo "$reviews" | jq ".status" 2>/dev/null) != '"404"' ]]; then
+ echo "$reviews" | jq -c -r '.[]' | while read -r review; do
+ reviewer=$(echo "$review" | jq -r ".user.login")
+ state=$(echo "$review" | jq -r ".state")
+ association=$(echo "$review" | jq -r ".author_association")
+ case "$association" in
+ OWNER | MEMBER | CONTRIBUTOR | COLLABORATOR | OUTSIDE_COLLABORATOR)
+ if [[ -z $($GREP "$reviewer" "$reviewerDataFile") \
+ && "$reviewer" != "$author"
+ && "github-actions[bot]" != "$reviewer" ]]; then
+ if [[ "$state" == "APPROVED" ]]; then
+ echo "Reviewed-by: https://github.com/$reviewer ✅" >> "$reviewerDataFile";
+ else
+ echo "Reviewed-by: https://github.com/$reviewer" >> "$reviewerDataFile";
+ fi
+ fi
+ esac
+ done
+ reviewerData=$(sort "$reviewerDataFile" | uniq)
+ echo "$reviewerData" > "$reviewerDataFile"
+ [[ -s "$reviewerDataFile" ]] && while read -r line; do
+ [[ -n "$line" ]] && $GIT notes append --no-separator -m "$line" "$1"
+ done < "$reviewerDataFile"
+ fi
+ fi
+}
+if [[ -z "$*" ]]; then
+ while read -r line; do
+ owner=$startOwner
+ repo=$startRepo
+ if [[ -z $($GIT notes show "$line" 2>/dev/null) ]]; then
+ addNote "$line"
+ fi
+ done < "$commitsWithoutNotes"
+else
+ for sha in "$@"; do
+ owner=$startOwner
+ repo=$startRepo
+ if [[ -z $($GIT notes show "$sha" 2>/dev/null) ]]; then
+ addNote "$sha"
+ fi
+ done
+fi