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..b0d2e2e --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +## git-gloss ✨ + +Annotate git log output with GitHub PR/Issue/Reviewer metadata + +### How to run + +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. + +It can process 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 an hour 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. + +### Sharing the notes + +Once `git-gloss` finishes running, here’s how you can share the notes with everyone in your project: + +1. Push the notes back to your project remote by running this command: + + ``` + git push origin 'refs/notes/*' + ``` + +2. Others in your project can then fetch the notes from the remote by running this command: + + ``` + git fetch origin 'refs/notes/*:refs/notes/*' + ``` + +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)); for 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. + +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_REPO=SerenityOS/serenity GREP=rg GIT=/opt/homebrew/bin/git ./git-gloss +``` diff --git a/git-gloss b/git-gloss new file mode 100755 index 0000000..52ebfcc --- /dev/null +++ b/git-gloss @@ -0,0 +1,125 @@ +#!/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/') + +repo=$(echo "$repoURL" | rev | cut -d '/' -f-1 | rev) +owner=$(echo "$repoURL" | rev | cut -d '/' -f2 | rev) +if [[ $repoURL == *"github"* && -z "$repo" ]]; 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 + +if ! [[ -x "$(command -v jq)" ]]; then + echo + echo -ne \ + "Error: You must have jq installed in order to use this tool. " >&2 + echo -e \ + " https://jqlang.github.io/jq/download/" >&2 + exit 1; +fi + +if ! [[ -x "$(command -v gh)" ]]; then + echo + echo -ne \ + "Error: You must have the GitHub CLI installed in order to use " + echo -e \ + "this tool. https://cli.github.com/" >&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 +count=$(wc -l < "$commitsWithoutNotes" | xargs) +if [ "$count" -eq "0" ]; then + commitsWithoutNotes=$allCommits +fi +echo "Found $count commits to process." + +addNotes () { + pullRequest=$(gh api "/repos/$owner/$repo/commits/$1/pulls") + prNumber=$(echo "$pullRequest" | jq '.[0].number') + prAuthor=$(echo "$pullRequest" | jq '.[0].user.login' | tr -d '"') + if [[ -n "$prNumber" && "$prNumber" != "null" ]]; then + commit=$(gh api "/repos/$owner/$repo/commits/$1") + authorEmail=$(echo "$commit" | jq ".commit.author.email" | tr -d '"') + $GIT notes append --no-separator -m "PR: https://github.com/$owner/$repo/pull/$prNumber" "$1" + 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/$prAuthor 🔰" "$1" + else + $GIT notes append --no-separator -m "Author: https://github.com/$prAuthor" "$1" + fi + #sleep .5 + # 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" != "$prAuthor" + && "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 +} +while read -r line; do + echo "https://github.com/$owner/$repo/commit/$line" + if [[ -z $($GIT notes show "$line" 2>/dev/null) ]]; then + addNotes "$line" + fi +done < "$commitsWithoutNotes"