├── .github ├── CODEOWNERS └── settings.yml ├── .npmrc ├── LICENSE ├── README.md ├── package-lock.json ├── package.json └── pull.sh /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @npm/cli-team 2 | -------------------------------------------------------------------------------- /.github/settings.yml: -------------------------------------------------------------------------------- 1 | --- 2 | _extends: '.github:npm-cli/settings.yml' 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | access=public 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The ISC License 2 | 3 | Copyright (c) npm, Inc. 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR 15 | IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @npmcli/pull 2 | 3 | This is a script for landing pull requests with the npm CLI team's 4 | annotations. 5 | 6 | ## Dependencies 7 | 8 | This requires `node`, `git`, and `curl` to be installed on your system. 9 | 10 | Also [`gh`](https://github.com/cli/cli) is an optional requirement that will 11 | enable retargetting PRs to a release-branch. 12 | 13 | ## USAGE 14 | 15 | ```bash 16 | npm i @npmcli/pull 17 | 18 | # from the main branch of a repo, land PR-123 19 | pull 123 20 | 21 | # if any git rebasing was required, leaving you in the PR-123 22 | # branch, then run this when you're done, to merge into the 23 | # master or latest branch 24 | pull finish master # or latest, or release-1.2.3, or whatever 25 | ``` 26 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@npmcli/pull", 3 | "version": "1.1.4", 4 | "lockfileVersion": 1 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@npmcli/pull", 3 | "description": "A bash script for landing pull requests", 4 | "bin": { 5 | "pull": "pull.sh" 6 | }, 7 | "files": [ 8 | "pr" 9 | ], 10 | "scripts": { 11 | "postversion": "npm publish", 12 | "postpublish": "git push origin --follow-tags" 13 | }, 14 | "version": "1.1.4", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/npm/pull.git" 18 | }, 19 | "license": "ISC", 20 | "dependencies": {} 21 | } 22 | -------------------------------------------------------------------------------- /pull.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Land a pull request 4 | # Creates a PR-### branch, pulls the commits, opens up an interactive rebase to 5 | # squash, and then annotates the commit with the changelog goobers 6 | # 7 | # Usage: 8 | # pr [=origin] 9 | 10 | get_user_login () { 11 | node -e ' 12 | data = [] 13 | process.stdin.on("end", () => 14 | console.log(JSON.parse(Buffer.concat(data).toString()).user.login)) 15 | process.stdin.on("data", c => data.push(c)) 16 | ' 17 | } 18 | 19 | get_head_repo_ssh_url () { 20 | node -e ' 21 | data = [] 22 | process.stdin.on("end", () => 23 | console.log(JSON.parse(Buffer.concat(data).toString()).head.repo.ssh_url)) 24 | process.stdin.on("data", c => data.push(c)) 25 | ' 26 | } 27 | 28 | get_head_ref () { 29 | node -e ' 30 | data = [] 31 | process.stdin.on("end", () => 32 | console.log(JSON.parse(Buffer.concat(data).toString()).head.ref)) 33 | process.stdin.on("data", c => data.push(c)) 34 | ' 35 | } 36 | 37 | get_reviewers () { 38 | local me=$1 39 | node -e ' 40 | data = [] 41 | process.stdin.on("end", () => { 42 | const reviews = JSON.parse(Buffer.concat(data).toString()) 43 | const approvers = [...new Set(reviews 44 | .filter(a => a.state === "APPROVED") 45 | .map(a => a.user.login) 46 | )] 47 | console.log(approvers.join(", @").trim() || process.argv[1]) 48 | }) 49 | process.stdin.on("data", c => data.push(c)) 50 | ' "$me" 51 | } 52 | 53 | main () { 54 | if [ "$1" = "finish" ]; then 55 | shift 56 | finish "$@" 57 | return $? 58 | fi 59 | 60 | if [ "$1" = "update" ]; then 61 | shift 62 | update 63 | return $? 64 | fi 65 | 66 | local url="$(prurl "$@")" 67 | local num=$(basename $url) 68 | 69 | if ! [[ $num =~ ^[0-9]+$ ]]; then 70 | echo "usage:" >&2 71 | echo "from target branch:" >&2 72 | echo " $0 [=origin]" >&2 73 | echo "from PR branch:" >&2 74 | echo " $0 finish " >&2 75 | echo "from main branch:" >&2 76 | echo " $0 update" >&2 77 | exit 1 78 | fi 79 | 80 | local prpath="${url#git@github.com:}" 81 | local repo=${prpath%/pull/$num} 82 | local prweb="https://github.com/$prpath" 83 | local root="$(prroot "$url")" 84 | local api="https://api.github.com/repos/${repo}/pulls/${num}" 85 | local user=$(curl -s $api | get_user_login) 86 | local ref="$(prref "$url" "$root")" 87 | local curhead="$(git show --no-patch --pretty=%H HEAD)" 88 | local curbranch="$(git rev-parse --abbrev-ref HEAD)" 89 | local cleanlines 90 | IFS=$'\n' cleanlines=($(git status -s -uno)) 91 | if [ ${#cleanlines[@]} -ne 0 ]; then 92 | echo "working dir not clean" >&2 93 | IFS=$'\n' echo "${cleanlines[@]}" >&2 94 | echo "aborting PR merge" >&2 95 | fi 96 | 97 | # ok, ready to rock 98 | branch=PR-$num 99 | if [ "$curbranch" == "$branch" ]; then 100 | echo "already on $branch, you're on your own" >&2 101 | return 1 102 | fi 103 | 104 | me=$(git config github.user) 105 | if [ "$me" == "" ]; then 106 | echo "run 'git config --add github.user '" >&2 107 | return 1 108 | fi 109 | 110 | exists=$(git show --no-patch --pretty=%H $branch 2>/dev/null) 111 | if [ "$exists" == "" ]; then 112 | git fetch origin pull/$num/head:$branch 113 | git checkout $branch 114 | else 115 | git checkout $branch 116 | git pull --rebase origin pull/$num/head 117 | fi 118 | 119 | git rebase -i --autosquash $curbranch # squash and test 120 | 121 | if [ $? -eq 0 ]; then 122 | finish "${curbranch}" 123 | else 124 | echo "resolve conflicts and run: $0 finish "'"'${curbranch}'"' >&2 125 | fi 126 | } 127 | 128 | # add the PR-URL to the last commit, after squashing 129 | finish () { 130 | if [ $# -eq 0 ]; then 131 | echo "Usage: $0 finish (while on a PR-### branch)" >&2 132 | return 1 133 | fi 134 | 135 | local curbranch="$1" 136 | local githead=$(git rev-parse --git-path HEAD) 137 | local ref=$(cat $githead) 138 | local prnum 139 | case $ref in 140 | "ref: refs/heads/PR-"*) 141 | prnum=${ref#ref: refs/heads/PR-} 142 | ;; 143 | *) 144 | echo "not on the PR-## branch any more!" >&2 145 | return 1 146 | ;; 147 | esac 148 | 149 | local me=$(git config github.user || git config user.name) 150 | if [ "$me" == "" ]; then 151 | echo "run 'git config --add github.user '" >&2 152 | return 1 153 | fi 154 | 155 | set -x 156 | 157 | local url="$(prurl "$prnum")" 158 | local num=$prnum 159 | local prpath="${url#git@github.com:}" 160 | local repo=${prpath%/pull/$num} 161 | local prweb="https://github.com/$prpath" 162 | local root="$(prroot "$url")" 163 | 164 | local api="https://api.github.com/repos/${repo}/pulls/${num}" 165 | local user=$(curl -s $api | get_user_login) 166 | 167 | local reviewsApi="https://api.github.com/repos/${repo}/pulls/${num}/reviews" 168 | local reviewers=$(curl -s $reviewsApi | get_reviewers "$me") 169 | 170 | local lastmsg="$(git log -1 --pretty=%B)" 171 | local newmsg="${lastmsg} 172 | 173 | PR-URL: ${prweb} 174 | Credit: @${user} 175 | Close: #${num} 176 | Reviewed-by: @${reviewers} 177 | " 178 | git commit --amend -m "$newmsg" 179 | git checkout $curbranch 180 | git merge PR-${prnum} --ff-only 181 | 182 | set +x 183 | } 184 | 185 | update () { 186 | set -x 187 | local url=$(git show --no-patch HEAD | grep PR-URL | tail -n1 | awk '{print $2}') 188 | local num=$(basename "$url") 189 | 190 | if [ "$num" == "" ]; then 191 | echo "could not find PR number" >&2 192 | return 1 193 | fi 194 | 195 | local prpath="${url#https://github.com/}" 196 | local repo=${prpath%/pull/$num} 197 | local api_endpoint="https://api.github.com/repos/${repo}/pulls/${num}" 198 | 199 | # force-push commit to the user's fork to give it the purple merge 200 | # it's an optional thing so we ignore any errors 201 | set +x 202 | local api=$(curl -s $api_endpoint) 203 | local remote=$(echo $api | get_head_repo_ssh_url 2> /dev/null) 204 | local branch=$(echo $api | get_head_ref 2> /dev/null) 205 | set -x 206 | 207 | if [ "$remote" == "" ] || \ 208 | [ "$branch" == "" ]; then 209 | echo "original remote/branch not found, PR will be marked as closed" >&2 210 | return 0 211 | fi 212 | 213 | local curbranch="$(git branch --show-current)" 214 | 215 | # do an initial push just to make sure it exists, otherwise 216 | # the retargetting will fail. 217 | if should_push "$curbranch" "$defaultbranch"; then 218 | git push origin HEAD^:"$curbranch" 219 | fi 220 | 221 | # retarget the original contributors PR to point to that branch 222 | if hash gh 2>/dev/null; then 223 | local gh_api_endpoint="repos/${repo}/pulls/${num}" 224 | gh api $gh_api_endpoint --field base="$curbranch" 1> /dev/null 225 | else 226 | echo "we need the gh cli in order to retarget PR" >&2 227 | echo "get it now: https://github.com/cli/cli" >&2 228 | echo "You may want to manually retarget this PR in the web interface." >&2 229 | fi 230 | local defaultbranch=$(git config --get init.defaultbranch) 231 | 232 | # pushes amended commit back to contributors PR branch 233 | git push "$remote" "+HEAD:$branch" 234 | 235 | # look up and see if we're in the potential default branch 236 | if ! should_push "$curbranch" "$defaultbranch"; then 237 | echo "looks like we're in the default branch, skipping auto push" >&2 238 | else 239 | # finishes updating our release/working remote branch 240 | local us="$1" 241 | if [ "$us" == "" ]; then 242 | us="origin" 243 | fi 244 | git push $us $curbranch 245 | fi 246 | 247 | set +x 248 | } 249 | 250 | should_push () { 251 | local curbranch=$1 252 | local defaultbranch=$2 253 | [ "$curbranch" != "" ] && \ 254 | [ "$curbranch" != "$defaultbranch" ] && \ 255 | [ "$curbranch" != "master" ] && \ 256 | [ "$curbranch" != "main" ] && \ 257 | [ "$curbranch" != "latest" ] 258 | } 259 | 260 | prurl () { 261 | local url="$1" 262 | if [ "$url" == "" ] && type pbpaste &>/dev/null; then 263 | url="$(pbpaste)" 264 | fi 265 | if [[ "$url" =~ ^[0-9]+$ ]]; then 266 | local us="$2" 267 | if [ "$us" == "" ]; then 268 | us="origin" 269 | fi 270 | local num="$url" 271 | local o="$(git config --get remote.${us}.url)" 272 | url="${o}" 273 | url="${url#(git:\/\/|https:\/\/)}" 274 | url="${url#git@}" 275 | url="${url#github.com[:\/]}" 276 | url="${url%.git}" 277 | url="https://github.com/${url}/pull/$num" 278 | fi 279 | url=${url%/commits} 280 | url=${url%/files} 281 | url="$(echo $url | perl -p -e 's/#issuecomment-[0-9]+$//g')" 282 | 283 | local p='^https:\/\/github.com\/[^\/]+\/[^\/]+\/pull\/[0-9]+$' 284 | if ! [[ "$url" =~ $p ]]; then 285 | echo "Usage:" >&2 286 | echo " $0 " >&2 287 | echo " $0 [=origin]" >&2 288 | type pbpaste &>/dev/null && 289 | echo "(will read url/id from clipboard if not specified)" >&2 290 | exit 1 291 | fi 292 | url="${url/https:\/\/github\.com\//git@github.com:}" 293 | echo "$url" 294 | } 295 | 296 | prroot () { 297 | local url="$1" 298 | echo "${url/\/pull\/+([0-9])/}" 299 | } 300 | 301 | prref () { 302 | local url="$1" 303 | local root="$2" 304 | echo "refs${url:${#root}}/head" 305 | } 306 | 307 | main "$@" 308 | --------------------------------------------------------------------------------