#!/usr/bin/env bash
# (c) 2019-2025 Michał Górny
# SPDX-License-Identifier: GPL-2.0-or-later

VERSION=14

die() {
	echo "${@}" >&2
	exit 1
}

print_usage() {
	echo "Usage: ${0} [<options>] <pr-number|pr-url|patch-url>"
}

print_help() {
	print_usage
	echo
	echo "Merge specified GitHub PR into the repository in current directory."
	echo
	echo "Options:"
	echo "  --am-options OPTIONS"
	echo "                   Pass additional OPTIONS to git am"
	echo "  -b BUG, --bug BUG"
	echo "  -c BUG, --closes BUG"
	echo "                   Reference or close the specific bug, takes either"
	echo "                   a bug number or URL, can be specified multiple times"
	echo "  -e EDITOR, --editor EDITOR"
	echo "                   Override the editor used (default: \${EDITOR})"
	echo "  --no-api         Don't use the GitHub REST API even if a personal"
	echo "                   access token is present at ~/.github-token"
	echo "  --no-gitconfig   Do not query options via git config"
	echo "  -G, --no-gpgsign"
	echo "                   Do not automatically GPG-sign commits"
	echo "  -I, --non-interactive"
	echo "                   Do not interactively ask to merge, just do it"
	echo "  -r REPO, --repository REPO"
	echo "                   GitHub repo to use (default: gentoo/gentoo)"
	echo "  -s, --signoff    Add Signed-off-by to commits (the default)"
	echo "  -S, --no-signoff Disable adding Signed-off-by to commits"
	echo "  -p, --part-of    Add Part-of to commits, linking to the PR (the default)"
	echo "  -P, --no-part-of Disable adding Part-of to commits"
	echo "  --link-to PULL_REQUEST"
	echo "                   Override the pull request the merge is linked to. This"
	echo "                   will override the Part-of trailer, as well as the final"
	echo "                   Closes trailer."
	echo
	echo "Parameters:"
	echo "  <pr-number>      GitHub PR number"
	echo "  <pr-url>         Full URL to the pull request"
	echo "  <patch-url>      URL to a patch file"
	echo
	echo "Some options can be specified via 'git config' as well:"
	echo "  string options: pram.editor, pram.repo"
	echo "  boolean options: pram.gpgsign, pram.interactive, pram.signoff, pram.partof"
}

# add_trailer <file> <line-to-add>
add_trailer() {
	local file=${1}
	local line=${2}

	sed -i -e "1,/^---$/s@^---\$@${line}\n\0@" "${file}" ||
		die "Appending trailer via sed failed"
}

main() {
	# make sure files are sorted ascending
	local -x LC_COLLATE=C

	local am_options=()
	local api=
	local bug=()
	local closes=()
	local editor=
	local pr=
	local repo=
	local signoff=def
	local interactive=def
	local gitconfig=1
	local gpgsign=def
	local partof=def
	local link_to=

	if [[ -r ~/.github-token ]]; then
		api=1
	fi

	while [[ ${#} -gt 0 ]]; do
		case ${1} in
			-h|--help)
				print_help
				exit 0
				;;
			-V|--version)
				echo "pram ${VERSION}"
				exit 0
				;;

			--am-options)
				[[ ${#} -gt 1 ]] || die "${0}: missing argument to ${1}"
				am_options+=( "${2}" )
				shift
				;;
			--am-options=*)
				am_options+=( "${1#*=}" )
				;;
			-b|--bug)
				[[ ${#} -gt 1 ]] || die "${0}: missing argument to ${1}"
				bug+=( "${2}" )
				shift
				;;
			-b*)
				bug+=( "${1#-b}" )
				;;
			--bug=*)
				bug+=( "${1#*=}" )
				;;
			-c|--closes)
				[[ ${#} -gt 1 ]] || die "${0}: missing argument to ${1}"
				closes+=( "${2}" )
				shift
				;;
			-c*)
				closes+=( "${1#-c}" )
				;;
			--closes=*)
				closes+=( "${1#*=}" )
				;;
			-e|--editor)
				[[ ${#} -gt 1 ]] || die "${0}: missing argument to ${1}"
				editor=${2}
				shift
				;;
			-e*)
				editor=${1#-e}
				;;
			--editor=*)
				editor=${1#*=}
				;;
			--no-api)
				api=
				;;
			--no-gitconfig)
				gitconfig=
				;;
			-G|--no-gpgsign)
				gpgsign=
				;;
			-I|--non-interactive)
				interactive=
				;;
			-r|--repository)
				[[ ${#} -gt 1 ]] || die "${0}: missing argument to ${1}"
				repo=${2}
				shift
				;;
			-r*)
				repo=${1#-r}
				;;
			--repository=*)
				repo=${1#*=}
				;;
			-s|--signoff)
				signoff=1
				;;
			-S|--no-signoff)
				signoff=
				;;
			-p|--part-of)
				partof=1
				;;
			-P|--no-part-of)
				partof=
				;;
			--link-to)
				[[ -z ${link_to} ]] || die "${0}: cannot specify multiple ${1}"
				[[ ${#} -gt 1 ]] || die "${0}: missing argument to ${1}"
				link_to=${2}
				shift
				;;
			--link-to=*)
				[[ -z ${link_to} ]] || die "${0}: cannot specify multiple ${1%%=*}"
				link_to=${1#*=}
				;;
			-*)
				print_usage >&2
				exit 1
				;;
			*)
				if [[ -n ${pr} ]]; then
					echo "${0}: only a single PR/patch can be specified" >&2
					print_usage >&2
					exit 1
				fi
				pr=${1}
				;;
		esac

		shift
	done

	if [[ -z ${pr} ]]; then
		print_usage >&2
		exit 1
	fi

	if [[ $(git rev-parse --is-inside-work-tree) != true ]]; then
		echo "pram needs to be run inside the git checkout!" >&2
		exit 1
	fi

	if [[ ${gitconfig} ]]; then
		local opt

		# string options
		[[ -z ${repo} ]] && repo=$(git config --get pram.repo)
		[[ -z ${editor} ]] && editor=$(git config --get pram.editor)

		# boolean options
		if [[ ${signoff} == def ]]; then
			opt=$(git config --type bool --get pram.signoff)
			[[ ${opt} == true ]] && signoff=1
			[[ ${opt} == false ]] && signoff=
		fi
		if [[ ${interactive} == def ]]; then
			opt=$(git config --type bool --get pram.interactive)
			[[ ${opt} == true ]] && interactive=1
			[[ ${opt} == false ]] && interactive=
		fi
		if [[ ${gpgsign} == def ]]; then
			opt=$(git config --type bool --get pram.gpgsign)
			[[ ${opt} == true ]] && gpgsign=1
			[[ ${opt} == false ]] && gpgsign=
		fi
		if [[ ${partof} == def ]]; then
			opt=$(git config --type bool --get pram.partof)
			[[ ${opt} == true ]] && partof=1
			[[ ${opt} == false ]] && partof=
		fi
	fi

	# set defaults
	: "${repo:=gentoo/gentoo}"
	: "${editor:=${EDITOR:-vim}}"

	tempdir=$(mktemp -d) || die "Unable to create a temporary directory"
	trap 'rm -r -f "${tempdir}"' EXIT

	local to_close
	case ${pr} in
		*://github.com/*/*/pull/*/commits/*)
			# Simplify for easier use via the api
			pr=${pr/pull\/*\/commits/commit}
			pr=${pr%.patch}.patch
			to_close=
			;;
		*://github.com/*/*/pull/*)
			# GitHub URL
			to_close=${pr%.patch}
			pr=${to_close}.patch
			;;
		*://github.com/*/*/commit/*|*://github.com/*/*/compare/*)
			# GitHub branch/commit diff
			pr=${pr%.patch}.patch
			to_close=
			;;
		*://*.bugs.gentoo.org/attachment.cgi?*)
			# Gentoo Bugzilla attachment
			# (get bug no from domain name)
			to_close=${pr%%.bugs*}
			to_close="https://bugs.gentoo.org/${to_close#*://}"
			;;
		*://*)
			# arbitrary URL
			to_close=
			;;
		[0-9]*)
			# a number?
			to_close="https://github.com/${repo}/pull/${pr}"
			pr=${to_close}.patch
			;;
		*)
			# a local file maybe?
			to_close=
			[[ -f ${pr} ]] ||
				die "Parameter neither an URL, number or file: ${pr}"
			cp "${pr}" "${tempdir}"/all.patch || die "Copying patch failed"
			pr=
			;;
	esac

	if [[ -z ${to_close} && -z ${link_to} ]]; then
		# only add partof for local files and arbitrary URLs if explicitly asked for
		if [[ ${partof} == def ]]; then
			partof=
		elif [[ ${partof} ]]; then
			die "could not determine a PR from supplied patch, please specify with --link-to"
		fi
	fi

	if [[ -n ${pr} ]]; then
		if [[ -n ${api} && (
			${pr} == *://github.com/*/commit/* ||
			${pr} == *://github.com/*/compare/* ||
			${pr} == *://github.com/*/pull/* )
		]]; then
			# Modify possible patch links to be applicable to the GitHub REST API
			# https://docs.github.com/en/rest?apiVersion=2022-11-28
			pr=${pr/github.com/api.github.com\/repos}
			pr=${pr/\/commit\//\/commits\/}
			pr=${pr/\/pull\//\/pulls\/}
			pr=${pr%%.patch}
			wget --header="Authorization: Bearer $(< ~/.github-token)" \
				--header="Accept: application/vnd.github.patch" \
				--header="X-GitHub-Api-Version: 2022-11-28" \
				-O "${tempdir}/all.patch" "${pr}" || die "Fetching patch failed"
		else
			wget -O "${tempdir}/all.patch" "${pr}" || die "Fetching patch failed"
		fi
	fi
	git mailsplit --keep-cr -o"${tempdir}" "${tempdir}/all.patch" >/dev/null ||
		die "Splitting patches failed"

	if [[ ${partof} && -n ${link_to} ]]; then
		case ${link_to} in
			*://*)
				# already fine
				to_close=${link_to}
				;;
			[0-9]*)
				to_close="https://github.com/${repo}/pull/${link_to}"
				;;
			*)
				die "Unknown format for linked pull request: ${link_to}"
				;;
		esac

	fi

	local patches=( "${tempdir}"/[0-9]* )
	if [[ ${signoff} || ${partof} ]]; then
		local f
		for f in "${patches[@]}"; do
			if [[ ${signoff} ]]; then
				if ! grep -q '^Signed-off-by:' "${f}"; then
					die "Commit no. ${f##*/} was not signed off by the author!"
				fi
			fi

			if [[ ${partof} ]]; then
				add_trailer "${f}" "Part-of: ${to_close}"
			fi
		done
	fi

	# append bug references
	local b
	for b in "${bug[@]}"; do
		[[ ${b} != *://* ]] && b=https://bugs.gentoo.org/${b}
		add_trailer "${patches[-1]}" "Bug: ${b}"
	done
	for b in "${closes[@]}"; do
		[[ ${b} != *://* ]] && b=https://bugs.gentoo.org/${b}
		add_trailer "${patches[-1]}" "Closes: ${b}"
	done
	# append Closes: to the final commit if missing
	if [[ -n ${to_close} ]]; then
		if ! grep -q "^Closes: ${to_close}" "${patches[-1]}"; then
			add_trailer "${patches[-1]}" "Closes: ${to_close}"
		fi
	fi

	# concatenate the patches back
	cat "${patches[@]}" > "${tempdir}/all.patch" ||
		die "Concatenating patches failed"
	rm "${patches[@]}" || die "Split patch cleanup failed"

	${editor} "${tempdir}/all.patch" || die "Starting editor failed"

	if [[ ! -s ${tempdir}/all.patch ]]; then
		echo "Patch is empty now, nothing to do." >&2
		return 0
	fi

	if [[ ${interactive} ]]; then
		while :; do
			local answer
			read -p "Do you want to merge this? (Y/n/q) " answer
			case ${answer,,} in
				y|"")
					break
					;;
				q|n)
					return 0
					;;
				*)
					echo "Unknown answer."
					;;
			esac
		done
	fi
	git am --keep-cr -3 ${signoff:+-s} ${gpgsign:+-S} "${am_options[@]}" \
			"${tempdir}/all.patch" || die "git am failed"
}

main "${@}"
