#! /bin/sh

# issue.sh: Manage issues on a forgejo instance
#
# For an issue with title "CVE-2017-18189", the markdown for its description
# is in the file issues/CVE-2017-18189.md
# It also has a directory issues/CVE-2017-18189/ with files:
# - title         The issue's real title (the filename may be munged)
# - state         Containing "open" or "closed".
#                 If absent, this is an issue created locally.
# Optional files
# - milestone     The milestone name, if any
# - labels        The labels, if any, one per line
# - assets/       A directory containing its attachments
#                 Files whose names end in ".patch" or ".diff" are downloaded
#                 with CR characters stripped out, so their size and content
#                 may not correspond exactly with the remote content.
# Files recording values assigned by Forgejo
# - number        From 1 upward monotonically
# - id            Unique number in all issues on forgejo
# - username      Who created the issue
# - created_at    When it was created
# - updated_at    When it was last updated on the server
#                 The format of *_at is "2025-02-11T00:41:06Z"
#
# It requires all issues to have different titles because
# that's what it indexes them by, and new issues won't have a number yet.
# As a result it doesn't update them in numerical order; I suppose that
# could be possible by sorting them by number and doing new issues last.
#
# putissues is chatty and its output to stdout is one line for each change.
# Errors go to stderr, so if you need it to be silent except for errors >/dev/null
#
# It can be run from the top directory or in the issues/ directory,
# which make it work both when run as issues/getissues.sh and
# if you're working in issues/ with a directory issues/issues; it will
# download them therein to avoid cluttering the working directory and
# as an easy way of keeping a copy the proper output if you're working on it
# by moving issues/ to some other name.
# The -o option tells it to use a different directory.
#
# The site, owner and repo default to the remote origin of the git
# directory you're in, can be specified as codeberg.org/sox_ng/sox_ng
# or the individual components can be overridden by the -s -o and -r flags.
#
# This is really two programs that share many subroutines:
#
# getissues()
# - erase all issues from the local directory
# - download the issue list, making an .md file and a directory for each
#   containing all the fields listed above
#
# putissues()
# - for each local .md file and its directory:
#   - if there is no number stored in the local issue,
#     it is a new, locally-created one, so create a new remote issue
#     and store locally the number, id, creatied_at and username it got
#   - if it does have a number locally, update the corresponding remote issue
#     where the fields' contents differ
# - if there are local issues whose numbers do not correspond to any remote
#   issues' numbers, that means we are "ahead" of the remote repository,
#   most likely because we are updating the issues of an older migration:
#   - create new empty issues for the whole range of new issue numbers
#     with just the title "dummy"
#   - go through the local issues filling in the remote issues' contents
#   - delete any remote issues that were not filled in
#
# Documentation for the codeberg issues API is at
# https://codeberg.org/api/swagger#/issue
#
#       Martin Guy <martinwguy@gmail.com>, July-November 2024

# TODO:
# - If a URL transfer failed due to bad network connectivity, retry it.
# - When creating new issues from local, fill in creation date and username.
#   You can't set or change the remote creation date, but it your clocks are
#   synchronized it will be the same within a second or two.

# For function-local variables, bash, dash and ksh have "local";
# ksh has "typeset"
test -n "$KSH_VERSION" && alias local=typeset

# dash's built-in "echo" always interprets backslash sequences
# so replace it with the more portable "printf".
# For issue bodies or json, don't call printf which may be an external command
# because that limits the size of the argument to 128K or whatever.
# If you know you don't want a trailing newline, use echo_n

echo() {
    case "$1" in
    -n) shift; printf %s "$*" ;;
    *)  cat << EOF
$*
EOF
	;;
    esac
}

echo_n() {
    printf %s "$*"
}

usage() {
  {
    echo 'Usage: sh issues.sh (get|put) [site/owner/repo] [options]'
    echo 'get      Delete all issues here and download them anew'
    echo 'put      Push local issues to the remote database'
    echo '-c       Use curl instead of wget'
    echo '-w       Use wget instead of curl'
    echo '-s site  Forgejo site to access (e.g. codeberg.org)'
    echo '-o owner Owner of the repo (e.g. martinwguy)'
    echo '-r repo  Repository of the owner to access (e.g. issues)'
    echo '-u user  Which user to access forgejo as'
    echo '-p pass  Password for -u user'
    echo '-d dir   Work with issues in directory dir instead of in issues/'
    echo '-f       Force updating of issues whose id doesn'\''t match'
    echo '         (useful when copying issues between forks or migrations)'
    echo '-n       Dry run: print what would be done but don'\''t update anything'
    echo '-D       Allow deletion of remote issues with no local correspondent'
    echo '         (valid only with putissues)'
    echo '-O       Only fetch open issues (valid only with getissues)'
    echo '-U       Show the URLs that curl is putting to (for debugging)'
    echo '-J       Dump the remote issue list to issues.json (for debugging)'
  } 1>&2
}

# The first argument must be the action
case "$1" in
get|put)	action="$1"; shift ;;
*)		usage; exit 1 ;;
esac

site=; owner=; repo=

# The second argument may be site/owner/repo
case "$1" in
*/*/*)
    site="$( echo "$1" | sed -n 's|^\([^/]*\)/.*|\1|p')"
    owner="$(echo "$1" | sed -n 's|^[^/]*/\([^/]*\)/.*|\1|p')"
    repo="$( echo "$1" | sed -n 's|^[^/]*/[^/]*/\(.*\)$|\1|p')"
    shift ;;
*)

    # If we're in a git repository, set the default site/owner/repo from the origin
    #
    # ssh://[user@]host.xz[:port]/path/to/repo.git/
    # git://host.xz[:port]/path/to/repo.git/
    # http[s]://host.xz[:port]/path/to/repo.git/
    # ftp[s]://host.xz[:port]/path/to/repo.git/
    # [user@]host.xz:path/to/repo.git/
    # ssh://[user@]host.xz[:port]/~[user]/path/to/repo.git/
    # git://host.xz[:port]/~[user]/path/to/repo.git/
    # [user@]host.xz:/~[user]/path/to/repo.git/
    # /path/to/repo.git/
    # file:///path/to/repo.git/

    if git remote 2>&1 > /dev/null
    then

	remotes="$(git remote -v | grep '^origin')"
	case "$action" in
	get) remote="$(echo "$remotes" | grep ' (fetch)$' | awk '{print $2}')" ;;
	put) remote="$(echo "$remotes" | grep ' (push)$'  | awk '{print $2}')" ;;
	esac

	case "$remote" in
	http://*|https://*)
	    site="$(echo "$remote" | \
		    sed -n 's|https*://\([^/]*\)/.*|\1|p')"
	    owner="$(echo "$remote" | \
		    sed -n 's|https*://[^/]*/\([^/]*\)/.*|\1|p')"
	    repo="$(echo "$remote" | \
		    sed -n 's|https*://[^/]*/[^/]*/\(.*\)$|\1|p')"
	    if [ -z "$site" ] || [ -z "$owner" ] || [ -z "$repo" ]
	    then
		echo "I can't decode the git origin '$remote'" 1>&2
		echo "Use site/owner/repo" 1>&2
		site=; owner=; repo=
	    fi
	    ;;
	*)
	    echo "$0: I only understand http:// and https:// git origins" 1>&2
	    exit 1 ;;
	esac
    fi ;;
esac

# Process command line arguments
force=false	# put: update even if ".id" doean't match;
dryrun=false	# put: Say what we'd do but don't do it
allow_delete=false	# put: Allow eletion of remote issues
open_only=false # Only fetch open issues
debug_URLs=false # put: Print curl commands before executing them
transfer=	# wget or curl
issuesdir=issues
username=
password=
dumpissues=false

while [ $# -gt 0 ]
do
    case "$1" in
    -f) force=true; shift ;;
    -n) dryrun=true; shift ;;
    -D) allow_delete=true; shift ;;
    -O) open_only=true; shift;;
    -U) debug_URLs=true; shift ;;
    -J) dumpissues=true; shift ;;
    -c) transfer=curl; shift ;;
    -w) transfer=wget; shift ;;

    -s) if [ $# -lt 2 ]
	then echo "-s what?" 1>&2; exit 1
	else site="$2"; shift 2
	fi
	;;
    -s*) site="$(echo "$1" | sed 's/-s//')"; shift
	;;

    -o) if [ $# -lt 2 ]
	then echo "-o what?" 1>&2; exit 1
	else owner="$2"; shift 2
	fi
	;;
    -o*) owner="$(echo "$1" | sed 's/-o//')"; shift
	;;

    -r) if [ $# -lt 2 ]
	then echo "-r what?" 1>&2; exit 1
	else repo="$2"; shift 2
	fi
	;;
    -r*) repo="$(echo "$1" | sed 's/-r//')"; shift
	;;

    -u) if [ $# -lt 2 ]
	then echo "-u what?" 1>&2; exit 1
	else username="$2"; shift 2
	fi
	;;
    -u*) username="$(echo "$1" | sed 's/-u//')"; shift
	;;

    -p) if [ $# -lt 2 ]
	then echo "-p what?" 1>&2; exit 1
	else password="$2"; shift 2
	fi
	;;
    -p*) password="$(echo "$1" | sed 's/-p//')"; shift
	;;

    -d) if [ $# -lt 2 ]
	then echo "-d what?" 1>&2; exit 1
	else issuesdir="$2"; shift 2
	fi
	;;
    -d*) issuesdir="$(echo "$1" | sed 's/-d//')"; shift
	;;
    *)  echo "Unknown option $1" 1>&2
        usage; exit 1
	;;
    esac
done

# Check arguments' validity

if [ -z "$action" ]
then
   echo "issues.sh: You need to specify 'put' or 'get'"
   exit 1
fi

if $dryrun && [ "$action" != put ]
then
    echo "-n only works with $0 put" 1>&2
    exit 1
fi

if $open_only && [ "$action" != get ]
then
    echo "-O is only valid for $0 get" 1>&2
    exit 1
fi

if $debug_URLs && [ "$action" != put ]
then
    echo "-U only works with $0 put" 1>&2
    exit 1
fi

# Authentication is compulsory when putting
$dryrun || case "$action" in
put) if [ -z "$username" ]
     then echo_n "Username for $site: "
          read -r username
     fi ;;
esac

case "$username" in
*\ *) echo "The username cannot contain a space" 1>&2; exit 1 ;;
esac
case "$password" in
*\ *) echo "The password cannot contain a space" 1>&2; exit 1 ;;
esac
if [ -n "$username" ] && [ -z "$password" ]
then
    stty -echo
    printf "Password: "
    read -r password
    printf "\n"
    stty echo
    case "$password" in
    *\ *) echo "The password cannot contain a space" 1>&2; exit 1 ;;
    esac
fi

# Remove trailing /'s from the issuesdir
case "$issuesdir" in
*/)	issuesdir="$(echo "$issuesdir" | sed 's://*$::')"
esac

# Let them run it in the "issues" directory or from the top-level directory
if [ -d "$issuesdir" ]
then
    cd "$issuesdir" || {
	echo "Can't cd into $issuesdir" 1>&2
	exit 1
    }
fi
# Make sure they are in an "issues" directory
if [ "$(basename "$(pwd)")" != "$issuesdir" ]
then
    mkdir "$issuesdir"
    cd "$issuesdir" || {
	echo "Can't cd into $issuesdir" 1>&2
	exit 1
    }
fi

# Pick the program to use for URL transfers.
# If they didn't specify -w or -c,
# prefer wget for getting as it is slightly faster and
# prefer curl for putting as it's the only one that works at present.
select_transfer()
{
    local have_wget
    local have_curl

    # curl works for getting and putting issues.
    # wget only works for getting and uses less CPU.
    if [ "$transfer" = wget ] && [ "$action" = put ]
    then
	echo "wget only works when getting issues. Use curl." 1>&2
	exit 1
    fi

    # If they didn't specify -c or -w, find a program to use.
    have_curl=false
    curl 2> /dev/null; test $? -eq 2 && have_curl=true
    have_wget=false
    wget 2> /dev/null; test $? -eq 1 && have_wget=true

    # If they asked for a specific program, check it is installed.
    # If they didn't, select one automatically.
    case "$transfer" in
    curl) $have_curl && return
	  echo "curl is not installed" 1>&2; exit 1
	  ;;
    wget) $have_wget && return
	  echo "wget is not installed" 1>&2; exit 1
	  ;;
    *)    case "$action" in
	  get) $have_wget && { transfer=wget; return; }
	       $have_curl && { transfer=curl; return; }
	       echo "You need wget or curl to be able to get issues" 1>&2
	       ;;
	  put) $have_curl && { transfer=curl; return; }
	       echo "You need curl to be able to put issues" 1>&2
	       ;;
	esac ;;
    esac
    exit 1
}

select_transfer

# Make sure jaq (faster) or jq is present
if echo '' | jaq > /dev/null 2>&1
then
    jq="jaq -c"
elif echo '' | jq > /dev/null 2>&1
then
    jq="jq -c"
else
    {
	echo "issues.sh needs jq"
	echo "Debian: apt install jq; Fedora: yum install jq"
	echo "or you should be able to install the latest version with"
	echo "    git clone https://github.com/jqlang/jq"
	echo "    cd jq"
	echo "    git submodule update --init"
	echo "    autoreconf -i && ./configure --with-oniguruma=builtin"
	echo "    make && sudo make install"
	echo "if that doesn't work, try"
	echo "    make clean; make LDFLAGS=-all-static && make install"
	echo "Or jaq from https://github.com/01mf02/jaq -> Releases is twice as fast".
    } 1>&2
    exit 1
fi

# Turn an X.Y.Z version into something numerically comparable
# From https://stackoverflow.com/questions/4023830
version() {
    echo "$@" | awk -F. '{ printf("%d%03d%03d\n", $1,$2,$3) }'
}

api="https://${site}/api/v1"
apirepo="$api/repos/$owner/$repo"

# Fetch a URL and output the result to stdout.
# Usage: geturl method [-r] URL failure_message
#
# method: GET or DELETE
# -r  ("raw") Output exactly the reply: don't eliminate CRs
#             and don't ensure it is newline-terminated.
#	      Used when downloading binary files (attachments)
geturl() {
    local errs
    local raw
    local a
    local url
    local command
    local result
    local status
    local message

    errs=/tmp/issues-geturl-errs$$

    # Process arguments
    method="$1"; shift
    raw=false
    for a
    do
	case "$a" in
	-r) raw=true; shift;;
	-*) echo "$0: internal error: geturl called with $a" 1>&2
	    exit 1;;
	*)  break;;
	esac
    done

    url="$1"; shift
    message="$*"

    case $transfer in
    curl) command="curl --fail --silent --compressed -S -L --location-trusted \
			-H Accept:application/json -X $method" ;;
    wget) command="wget -q -O - --compression=gzip \
			--header Accept:application/json" ;;
    esac

    # I can't figure out how to handle arguments including a space in $command
    # and that makes it impossible to authenticate with an access token
    # or with username/password if the password contains a space.
    # For an access token that would be:
    #   curl -H "authorization: token $token"
    if [ -n "$username" ]
    then
	case $transfer in
	curl) command="$command -u $username:$password" ;;
	wget) command="$command --user=$username --password=$password" ;;
	esac
    fi

    # $result here is a filename into which we put the result
    # because if we do result="$($command)" a final newline is removed.
    result=/tmp/issues-geturl-result$$
    $command "$url" > $result 2> $errs
    status=$?
    if [ $status != 0 ]
    then
	{
	    echo "$method $url exited $status"
	    cat $errs
	    # If we got a JSON reply with an error message in the header, print it
	    if [ -s $result ]
	    then
		echo "$message":
		$jq -r .message < $result
	    fi
	} 1>&2
	rm $errs $result
	return $status
    fi
    if [ "$method" = GET ] && [ ! -s $result ]
    then
	{
	    echo "$method $url returned nothing"
	    cat $errs
	    rm $errs $result
	    return 1
	} 1>&2
    fi

    if $raw
    then
	cat $result
    else
	# Eliminate CR from CRLF and ensure it is newline-terminated
	echo "$(tr -d '\r' < $result)"
    fi
    rm $errs $result

    return 0
}

# puturl: Post a URL and output the result to stdout.
#
# Usage: puturl METHOD [-r] [-f] URL data failure_message [file to attach]
# -a	      Send an attachment: Use Content-Type:multipart/form-data
#	      and the name of the file to attach is in $data
# method: POST or PATCH
puturl() {
    local method
    local url
    local data
    local message
    local attachment
    local errs
    local a
    local command
    local result
    local status

    errs=/tmp/issues-puturl-errs$$

    # Process arguments
    attachment=false
    method="$1"; shift
    for a
    do
	case "$a" in
	-a) attachment=true; shift;;
	-*) echo "$0: internal error: puturl called with $a" 1>&2
	    exit 1;;
	*)  break;;
	esac
    done

    url="$1"; shift
    data="$1"; shift
    message="$1"; shift

    case $transfer in
    curl)
	curl_version="$(curl --version | head -1 | awk '{print $2}')"
	if [ "$(version "$curl_version")" -lt "$(version "7.76.0")" ]
	then fail=--fail
	else fail=--fail-with-body
	fi
	command="curl $fail --silent --show-error --globoff \
		      -L --location-trusted --post301 --post302 \
		      -H Accept:application/json -X $method"
	if $attachment
	then
	      command="$command -H Content-Type:multipart/form-data"
	else
	      command="$command -H Content-Type:application/json"
	fi
	;;
    wget) # Only works for getting
	command="wget -nv -O - --header Accept:application/json"
	;;
    esac
    # I can't figure out how to handle arguments including a space in $command
    # and that makes it impossible to authenticate with an access token
    # or with username/password if the password contains a space.
    # For an access token that would be:
    #   curl -H "authorization: token $token"
    if [ -n "$username" ]
    then
	case $transfer in
	curl) command="$command -u $username:$password" ;;
	wget) command="$command --user=$username --password=$password" ;;
	esac
    fi

    case $transfer in
    curl)
	if $attachment
	then
	    $debug_URLs && \
		 echo "$command -F attachment=@-;filename=\"$(echo "$data" | \
				sed 's/[\"]/\\&/g')\"" "$url" 1>&2
	    # Read data from stdin because @$data messes with {} and []
	    result="$($command -F "attachment=@-;filename=\"$(echo "$data" | \
				sed 's/[\"]/\\&/g')\"" "$url" < "$data" 2> $errs)"
	    status=$?
	else
	    $debug_URLs && \
		 echo "$command --data-binary \"$data\" \"$url\"" 1>&2
	    result="$($command --data-binary "$data" "$url" 2> $errs)"
	    status=$?
	fi
	;;
    wget)
	result="$($command --post-data "$data" "$url" 2> $errs)"
	status=$?
    esac

    test $status != 0 && {
	echo "$message":
	cat $errs
	test -n "$result" && echo "$result" | $jq -r .message
    } 1>&2
    rm $errs
    test $status != 0 && return $status

    # Eliminate CR from CRLF and ensure it is newline-terminated
    echo "$(echo "$result" | tr -d '\r')"

    return 0
}

settings="$(geturl GET "$api/settings/api" "Failed to fetch API settings")" \
	    || exit 1
test -z "$settings" && exit 1

# Encode the value of a shell variable so that jq gets that as the same string
# so if we're handed a"\b it return "a\"\\b"
js_quote() {
    echo "$1" | sed 's|\\|\\\\|g;s|"|\\"|g;s|.*|"&"|'
}

# filename_quote: Make a local filename out of an issue's title:
# Unix: replace / with \
# Windows and MSDOS on FAT32 also ban : \ and ". Tough.
filename_quote() {
    echo "$1" | sed 's|/|\\|g'
}

# ECMA-encode a string, including the start and end quotes.
# See https://ecma-international.org/wp-content/uploads/ECMA-404.pdf
# "Section 9. String" or search "escaped".
#
# -n: No trailing newline
ecma_quote() {
    local minus_n
    # We should quote newline too but that's hard with sed.
    # Sed has hijacked \b as a word-edge pattern matcher.
    minus_n=
    case "$1" in
    -n) minus_n=-n; shift ;;
    esac

    echo "\"$(echo $minus_n "$1" | sed 's/["\\/]/\\&/g
		     s/\x08/\\b/g
		     s/\f/\\f/g
		     s/\r/\\r/g
		     s/\t/\\t/g' | tr '\n' '\r' | sed 's/\r/\\n/g')\""
}

# url_quote: Encode special characters in URLs
# Special characters are !*'();:@&=+$,/?#[]
url_quote() {
    echo "$*" | sed 's/%/%25/g
		     s/!/%21/g
		     s/#/%23/g
		     s/\$/%24/g
		     s/\&/%26/g
		     s/'\''/%27/g
		     s/(/%28/g
		     s/)/%29/g
		     s/\*/%2A/g
		     s/+/%2B/g
		     s/,/%2C/g
		     s:/:%2F:g
		     s/:/%3A/g
		     s/;/%3B/g
		     s/=/%3D/g
		     s/?/%3F/g
		     s/@/%40/g
		     s/\[/%5B/g
		     s/\]/%5D/g'
		     #s/}/%7B/g
		     #s/}/%7D/g
}

# Download all the remote issues into global variable $rissues_json
fetchissues() {
    local limit
    local page
    local issue_page
    local state

    # The API can only return a maximum of 50 items per request so
    # fetch each page until it returns [].
    # If you get "Argument list too long", reduce $limit
    # Limit is usually 50 giving 164k per page.
    limit="$(echo "$settings" | $jq -r '.max_response_items')"
    limit=20
    page=1
    case $open_only in
    true) state=open;;
    false) state=all;;
    esac

    # Fetch the first page
    rissues_json="$(geturl GET "$apirepo/issues?limit=$limit&page=$page&state=$state" \
		      "Failed to fetch the first page of issues")"
    test -z "$rissues_json" && exit 1

    # Append the following pages
    issue_page=""
    until [ "$issue_page" = "[]" ]
    do
	page=$((page + 1))
	issue_page="$(geturl GET \
			     "$apirepo/issues?limit=$limit&page=$page&state=$state" \
			     "Failed to fetch page $page of $limit issues")"

	# if the transfer failed or there are no more issues, stop
	test -z "$issue_page" && issue_page="[]"
	test "[]" = "$issue_page" && continue

	# Append the new page of issues to our list
	rissues_json="$(echo "$rissues_json" | $jq --arg new "$issue_page" '. + ($new | fromjson)')"
    done

    # Forgejo includes pull requests in the issues. Ignore them.
    rissues_json="$(echo "$rissues_json" | \
		    $jq 'del(.[] | select(.pull_request != null))')"

    if $dumpissues
    then
	echo "$rissues_json" | eval "$(echo "$jq" | sed 's/ -c//')" > issues.json
    fi
}

# Write remote issues into the local directory
# - delete all local issues
# - for each remote issues, save interesting fields and fetch attachments
getissues() {
    local a
    local dirname
    local limit
    local page
    local number
    local issue
    local title
    local ftitle
    local id
    local milestone
    local labels
    local assets
    local nassets
    local i
    local name
    local url

    # Remove any previously produced output
    for a in *.md .*.md
    do
	# Guard against issues whose title ends in .md
	test -d "$a" && continue

	# Guard against there being no .md files
	# though they may have created an issue called "*" (!)
	test "$a" = "*.md" && [ ! -f "*.md" ] && continue

	test -f "$a" && rm ./"$a"
	# If we were interrupted, we may have created an .md file
	# but not its corresponding directory so check first.
	dirname="$(echo "$a" | sed 's/\.md$//')"
	test -d "$dirname" && rm -r ./"$dirname"
    done

    echo "$rissues_json" | $jq -r '.[].number' | while read -r number
    do
	# The list of issues doesn't include the attachments (.assets) but
	# we can get that by fetching a single issue by its issue number.
	issue="$(geturl GET -r "$apirepo/issues/$number" \
			"#$number Failed to fetch list of assets")" || break
	if [ -z "$issue" ]
	then
	    continue
	fi

	title="$(echo "$issue" | $jq -r .title)"
	ftitle="$(filename_quote "$title")"

	# Check for duplicate titles
	if [ -f "$ftitle".md ]
	then
	    echo "Ignoring issue $number with the same title as $(cat ./"$ftitle"/number): '$ftitle'"
	    continue
	fi
	# Some issue bodies have all \n and others have all \r\n. Go figure.
	echo "$issue" | $jq -r .body | tr -d '\r' > "$ftitle".md

	# Save metadata: number, id, milestone and labels
	mkdir ./"$ftitle"
	if cd ./"$ftitle"
	then
	    # Forgejo includes pull requests as issues with a non-null
	    # .pull_request field. We don't want them in the issues.
	    if [ "$(echo "$issue" | $jq -r .pull_request)" != null ]
	    then
		cd ..
		continue
	    fi

	    # Items that are always present
	    echo "$title" > title
	    echo "$number" > number
	    id="$(echo "$issue" | $jq -r .id)"; echo "$id" > id
	    echo "$issue" | $jq -r .state > state
	    echo "$issue" | $jq -r .created_at > created_at
	    echo "$issue" | $jq -r .updated_at > updated_at
	    echo "$issue" | $jq -r .user.username > username

	    # Items that may be present
	    milestone="$(echo "$issue" | $jq -r .milestone)"
	    if [ -n "$milestone" ] && [ null != "$milestone" ]
	    then milestone="$(echo "$milestone" | jq -r .title)"
	    fi
	    if [ -n "$milestone" ] && [ null != "$milestone" ]
	    then echo "$milestone" > milestone
	    fi
	    labels="$(echo "$issue" | $jq -r ".labels|.[].name")"
	    test -n "$labels" && echo "$labels" > labels

	    assets="$(echo "$issue" | $jq '.assets')"  # json
	    nassets="$(echo "$assets" | $jq -r length)"
	    test "$nassets" -gt 0 && {
		mkdir assets
		for i in $(seq 0 $(($nassets - 1)) ); do
		    name="$(echo "$assets" | $jq -r ".[$i].name")"
		    url="$(echo "$assets" | $jq -r ".[$i].browser_download_url")"
		    case "$name" in
		    *.patch|*.diff)
			geturl GET "$url" \
			       "Failed to fetch patch '$name' of '$title'" ;;
		    *)
			geturl GET -r "$url" \
			       "Failed to fetch attachment '$name' of '$title'" ;;
		    esac > assets/"$name"
		done
	    }
	    cd ..
	else
	    echo "Can't cd to \"$ftitle\""
	fi
    done
}

# Push all local issues to the remote version
# - Go through all local issues, updating the corresponding remote ones.
#   If the remote one doesn't exist, create a new one and store the new
#   number and id locally
putissues() {
    local ftitle
    local title
    local number
    local id
    local state
    local milestone
    local labels
    local milestone_list
    local label_list
    local rasset
    local rasset_json
    local rassets_json
    local rtitle
    local rissues_json
    local a
    local ok
    local id
    local label
    local name
    local tmp
    local result
    local url
    local updating_body
    local new_issue_nums	# A list of issue numbers that need creating
    local newnumber		# The new issue number we actually got

    new_issue_nums=

    # Fetch the list of valid milestone titles and their ids
    # Each line is "name id".
    milestone_list="$(geturl GET "$apirepo/milestones" \
			     "Failed to fetch list of valid milestones" \
		      | $jq -r '.[] | .title + " " + (.id | tostring)')"

    # Fetch the list of valid label names and their ids
    label_list="$(geturl GET "$apirepo/labels" \
			     "Failed to fetch list of valid labels" \
		  | $jq -r '.[] | .name + " " + (.id | tostring)')"

    for ftitle in *.md
    do
	# If there are no .md files here, they got something wrong.
	# There could be an issue called "*" (!)
	if [ "$ftitle" = "*.md" ] && [ ! -e "*.md" ]
	then
	    echo "There are no issues in `pwd`"
	    exit 1
	fi

	# Guard against issues whose title ends in .md
	test -d ./"$ftitle" && continue

	# and files called ".md"
	if [ "$ftitle" = ".md" ]
	then
	    echo "Ignoring file '.md'"
	    continue
	fi

	# Hereon, $ftitle is the issue's directory name
	ftitle="$(basename ./"$ftitle" .md)"

	# Ignore any pull requests left over from when we used to store them
	if [ -f "$ftitle"/pull_request ]
	then
	    continue
	fi

	# New issues may not have a directory
	mkdir -p ./"$ftitle"

	# Read its metadata

	if [ -f "$ftitle/title" ]
	then
	    title="$(cat ./"$ftitle"/title)"
	else
	    # Probably a new issue
	    title="$ftitle"
	    # Check for invalid characters in the file name.
	    # On Unix they can't include / in the file name and
	    # Windows and MSDOS on FAT32 also ban : \ and ".
	    if echo "$ftitle" | grep -q '/:\\"'
	    then echo "Warning: On Windows, characters /\\:\" are invalid in ${ftitle}.md" 1>&2
	    fi
	    $dryrun || echo "$title" > "$ftitle"/title
	fi
	if [ -f "$ftitle"/number ]
	then number="$(cat ./"$ftitle"/number)"
	else number=
	fi
	if [ -f "$ftitle/id" ]
	then id="$(cat ./"$ftitle"/id)"
	else id=
	fi
	if [ -f "$ftitle/state" ]
	then state="$(cat ./"$ftitle"/state)"
	else state=open
	     $dryrun || echo open > "$ftitle"/state
	fi
	if [ -f "$ftitle/milestone" ]
	then milestone="$(cat ./"$ftitle"/milestone)"
	else milestone=
	fi
	if [ -f "$ftitle/labels" ]
	then labels="$(cat ./"$ftitle"/labels)"
	else labels=
	fi

	# Sanity checking of local issue

	if [ -z "$title" ]
	then
	    echo "$ftitle/title is empty"
	    continue
	fi
	# Make sure there are no newlines in its title
	if [ 1 -ne "$(echo "$title" | wc -l)" ]
	then
	    echo "There's a newline in the title of '$ftitle'" 1>&2
	    continue
	fi

	if [ -n "$number" ] && [ -z "$id" ]
	then
	    $force || {
		echo "#$number '$title': No id. Use -f?"
		continue
	    }
	fi
	if [ -n "$id" ] && [ -z "$number" ]
	then
	    echo "Id \"$id\" with no number in '$ftitle'"
	    continue
	fi

	case "$state" in
	open|closed) : ;;
	*) echo "Invalid state \"$state\" in '$ftitle'"
	   continue ;;
	esac

	# Check that the milestone is valid
	if [ -n "$milestone" ]
	then
	    ok=false
	    for a in $(echo "$milestone_list" | sed 's/ .*//')
	    do
		test "$a" = "$milestone" && ok=true
	    done
	    $ok || {
		echo "#$number '$ftitle': Invalid milestone \"$milestone\""
		continue
	    }
	fi

	# Check that every label is valid
	any_bad_labels=false
	for label in $labels
	do
	    ok=false
	    for a in $(echo "$label_list" | sed 's/ .*//')
	    do
		test "$label" = "$a" && ok=true
	    done
	    $ok || {
		echo "Invalid label '$label' in '$ftitle'"
		echo "Valid labels are: $(echo "$label_list" | sed 's/ .*//')"
		any_bad_labels=true
	    }
	done
	# Is a label repeated?
	labels="$(echo "$labels" | sort)"
	if [ "$labels" != "$(echo "$labels" | uniq)" ]
	then
	    echo "Warning: A label occurs twice in \"$ftitle\"/labels"
	    labels="$(echo "$labels" | uniq)"
	fi
	if $any_bad_labels
	then
	    # Remove the existing issue from the list of remote issues
	    # otherwise we'd delete it.
	    rissues_json="$(echo "$rissues_json" | \
		       $jq "del(.[] | select(.number == $number))")"
	    continue
	fi

#
# 1. New local issues
#

	if [ -z "$number" ]
	then
	    # It's a new issue.

	    # Make sure it doesn't have the same title as an existing issue
	    if [ "" != "$(echo "$rissues_json" | \
		$jq ".[] | select(.title == $(js_quote "$title"))")" ]
	    then
		echo "An issue with title '$title' already exists"
		continue
	    fi

	    data='{'	# }
	    data="$data,\"title\":$(ecma_quote -n "$title")"
	    data="$data,\"body\":$(ecma_quote "$(tr -d '\r' < "$ftitle.md")")"
	    if [ "$state" = closed ]
	    then
		data="$data,\"closed\":true"
	    fi
	    if [ -n "$milestone" ]
	    then
		id="$(echo "$milestone_list" | grep "^$milestone " | sed 's/.* //')"
		data="$data,\"milestone\":$id"
	    fi
	    if [ -n "$labels" ]
	    then
		list=
		for label in $labels
		do
		    id="$(echo "$label_list" | grep "^$label " | sed 's/.* //')"
		    list="$list,$id"
		done
		# There is at least one label so the initial "," is certain
		list="$(echo "$list" | sed 's/^,//')"
		data="$data,\"labels\":[$list]"
	    fi

	    echo "Creating new issue: $title"

	    $dryrun && continue

	    data="$(echo "$data" | sed 's/^{,/{/;s/$/}/')"    # }

	    result="$(puturl POST "$apirepo/issues" "$data" \
			     "Failed to create '$title'")"
	    status=$?
	    if [ $status -ne 0 ]
	    then echo "curl failed with status $status"
	    fi
	    if [ -z "$result" ]
	    then echo "Result is empty"
	    fi

	    if [ $status -ne 0 ] || [ -z "$result" ]
	    then continue
	    fi

	    # Store new metadata locally
	    number="$(echo "$result" | $jq -r .number)"
		echo "$number" > "$ftitle"/number
	    id="$(echo "$result" | $jq -r .id)"
		echo "$id" > "$ftitle"/id
	    echo "$result" | $jq -r .user.login > "$ftitle"/username
	    echo "$result" | $jq -r .created_at > "$ftitle"/created_at
	    echo "$result" | $jq -r .updated_at > "$ftitle"/updated_at

	    # You have to create the issue and then add attachments to it
	    if [ -d "$ftitle"/assets ]
	    then
		test -d "$ftitle"/assets && \
		(cd ./"$ftitle"/assets && find . -maxdepth 1 -type f | \
					  sed 's|^\./||') | \
		while read -r name
		do
		    result="$(puturl POST -a \
		              "$apirepo/issues/$number/assets" \
			      "$ftitle/assets/$name" \
			      "#$number '$title': Failed to attach '$name'")"
		done
	        # That may have updated its updated_at field
		if result="$(geturl GET -r "$apirepo/issues/$number" \
				    "#$number '$title': Failed to refetch")"
		then
		    old="$(cat ./"$ftitle"/updated_at)"
		    new="$(echo "$result" | $jq -r .updated_at > "$ftitle"/updated_at)"
		    if [ "$old" != "$new" ]
		    then
			echo "Updating local last-modified time from $old to $new"
			echo "$result" | $jq -r .updated_at > "$ftitle"/updated_at
		    fi
		fi
	    fi

	    echo "https://$site/$owner/$repo/issues/$number"

	    continue
	fi

#
# 2. Local issues that have a remote correspondent
#

	# We know that the local issue has a number and an id.

	# Select the remote version of the issue
	rissue_json="$(echo "$rissues_json" | \
		       $jq ".[] | select(.number == $number)")"

	if [ -z "$rissue_json" ]
	then
	    # The local issue has a number
	    # but the issue doesn't exist on the remote server.
	    # This probably means we are updating an old copy
	    # of the repository that we pulled from.
	    # We can't create a new issue with a specified number
	    # so remember it and create them all afterwards.
	    new_issue_nums="$new_issue_nums $number"
	    continue
	fi

	# Check that the remote id corresponds.
	$force || if [ "$id" != "$(echo "$rissue_json" | $jq ".id")" ]
	then
	    echo "number/id mismatch in $ftitle"
	    # Remove the existing issue from the list of remote issues
	    # so that we don't delete it from the remote server
	    rissues_json="$(echo "$rissues_json" | $jq "del(.[] | select(.number == $number))")"
	    continue
	fi

	# Make sure older local copies don't overwrite issues that have been
	# updated on the server.

	if [ -f "$ftitle"/updated_at ] && \
	   [ "$(cat ./"$ftitle"/updated_at)" != \
	     "$(echo "$rissue_json" | $jq -r ".updated_at")" ]
	then
	    echo "Remote issue '$ftitle' has been updated since we got it" 1>&2
	    echo "Here: '$(cat ./"$ftitle"/updated_at)' There: '$(echo "$rissue_json" | $jq -r ".updated_at")'" 1>&2
	    continue
	fi

	# Update the remote issue from the local one.

	body="$(tr -d '\r' < "$ftitle".md)"

	data="{"	#}
	rtitle="$(echo "$rissue_json" | $jq -r .title)"
	if [ "$title" != "$rtitle"  ]
	then data="$data,\"title\":$(ecma_quote -n "$title")"
	fi
	rbody="$(echo "$rissue_json" | $jq -r .body | tr -d '\r')"
	if [ "$body" != "$rbody" ]
	then
	     data="$data,\"body\":$(ecma_quote "$body")"
	fi
	if [ -n "$milestone" ]
	then
	    id="$(echo "$milestone_list" | grep "^$milestone " | sed 's/.* //')"
	    rid="$(echo "$rissue_json" | $jq -r .milestone.id)"
	    if [ "$id" != "$rid" ]
	    then data="$data,\"milestone\":$id"
	    fi
	else
	    # There is no milestone here so ensure the remote doesn't have one
	    if [ null != "$(echo "$rissue_json" | $jq -r .milestone)" ]
	    then
		data="$data,\"milestone\":0"
		# This "succeeds" but the remote issue still has its milestone.
		# "milestone":0 doesn't work either.
		# https://stackoverflow.com/questions/79073569
		# https://codeberg.org/forgejo/discussions/issues/230
	    fi
	fi
	rstate="$(echo "$rissue_json" | $jq -r .state)"
	if [ "$state" != "$rstate" ]
	then data="$data,\"state\":\"$state\""
	fi

	# Unfortunately you can't change the created_at date or username
	# when PATCHing, only the updated_at time.

	data="$(echo "$data" | sed 's/^{,/{/;s/$/}/')"    # }

	if [ "{}" != "$data" ]
	then
	    url="$apirepo/issues/$number"

	    # Don't include the body text in the activity report
	    updating_body=false
	    echo "$data" | grep -q '"body":' && updating_body=true
	    a="$(echo "$data" | $jq "del(.body)")"
	    if [ -n "$data" ] && [ "{}" != "$data" ]
	    then
		echo_n "#$number '$title': Updating"
		if [ "{}" != "$a" ]
		then
		    if $updating_body
		    then	# both
			echo " body and '$a'"
		    else	# metadata but not body
			echo " '$a'"
		    fi
		else
		    if $updating_body
		    then	# just the body
			echo " body"
		    else	# Not updating anything. Shouldn't happen.
		        echo " nothing: Internal error"
		    fi
		fi
		$dryrun || \
		result="$(puturl PATCH "$url" "$data" \
				 "#$number '$title': Update failed")"
	    fi
	fi

	# Update the labels

	rlabels="$(echo "$rissue_json" | $jq -r ".labels|.[].name")"
	for label in $labels
	do
	    # Is the same label already on the issue?
	    echo "$rlabels" | grep -q -E "\\<$label\\>" || {
		# Nope. Add it.
		echo "#$number '$title': Adding label '$label'"
		url="$apirepo/issues/$number/labels"
		$dryrun || \
		result="$(puturl POST "$url" "{\"labels\":[\"$label\"]}" \
			         "#$number '$title': Failed to add label '$label'")"
	    }
	    # Remove the processed issue from the list of remote labels
	    rlabels="$(echo "$rlabels" | grep -v "^$label\$")"
	done
	# Remove remote labels that don't have a local equivalent
	for label in $rlabels
	do
	    echo "#$number '$title': Removing label '$label'"
	    # Labels are removed by id, not name.
	    id="$(echo "$label_list" | grep "^$label " | sed 's/.* //')"
	    $dryrun || \
	    result="$(geturl DELETE \
			     "$apirepo/issues/$number/labels/$id"
			     "#$number '$title': Failed to remove label '$label'")"
	done

	# Compare local and remote assets

	# Fetch a list of the remote issue's assets into $rassets_json
	if issue="$(geturl GET "$apirepo/issues/$number" \
		"#$number '$title': Failed to fetch list of assets")"
	then
	    rassets_json="$(echo "$issue" | $jq .assets)"
	else
	    # If we failed to fetch the list of remote assets for this issue
	    # don't update or delete any of them.
	    rassets_json=
	fi

	# Check that local assets are present remotely with the same content
	if [ -n "$rassets_json" ]
	then
	    # The "while read" loop runs in a subshell so
	    # to get a modified "$rassets_json" out of it
	    # we dump it in a temp file.
	    tmp=/tmp/putissues-"$number"-rassets_json$$
	    echo "$rassets_json" > "$tmp"

	    test -d "$ftitle"/assets && \
	    (cd ./"$ftitle"/assets && find . -maxdepth 1 -type f | \
				      sed 's|^\./||') | \
	    while read -r name
	    do
		rasset_json="$(echo "$rassets_json" | \
			       $jq ".[] | select(.name == $(js_quote "$name"))")"
		if [ -z "$rasset_json" ]
		then
		    # There is no remote asset with the same filename
		    echo "#$number '$title': Adding asset '$name'"
		    $dryrun || \
		    result="$(puturl POST -a \
			      "$apirepo/issues/$number/assets" \
			      "$ftitle/assets/$name" \
			      "#$number '$title': Failed to add asset '$name'")"
		else
		    # There is a remote asset with the same filename.
		    # Check that its content is the same.
		    #
		    # We could compare the size first as a quick check
		    # to know it is different without having to download it
		    # but as we strip CRs from patch files, even if the
		    # sizes are different (for a patch file) we'd have to
		    # download it anyway to see if it's the same text.
		    # For a non-patch file, it may be worth doing.

		    url="$(echo "$rasset_json" | $jq -r ".browser_download_url")"
		    # Binary files may contain nuls so dump it in a file
		    rasset=/tmp/putissues_rasset$$
		    case "$name" in
		    *.patch|*.diff)
			geturl GET "$url" \
			       "#$number '$title': Failed to fetch asset '$name'" > $rasset ;;
		    *)
			geturl GET -r "$url" \
			       "#$number '$title': Failed to fetch asset '$name'" > $rasset ;;
		    esac && cmp -s ./"$ftitle/assets/$name" "$rasset" || {
			# geturl succeeded and the files are different

			echo "#$number '$title': Updating asset '$name'"
			# We can only update assets by their id and
			# can't change the contents so we delete it
			# and recreate it.
			echo "$rasset_json" > "asset-$ftitle-$name"
			id="$(echo "$rasset_json" | $jq -r ".id")"
			$dryrun || \
			geturl DELETE "$apirepo/issues/$number/assets/$id" \
			       "#$number '$title': Failed to start replacing asset '$name'" && {
			$dryrun || \
			result="$(puturl POST -a \
				  "$apirepo/issues/$number/assets" \
				  "$ftitle/assets/$name" \
				  "#$number '$title': Failed to replace asset '$name'")"
			}
		    }
		    rm $rasset
		    # Remove the asset from the list of remote assets that
		    # do not have a local equivalent
		    rassets_json="$(echo "$rassets_json" | \
			       $jq "del(.[] | select(.name == $(js_quote "$name")))")"
		    echo "$rassets_json" > "$tmp"
		fi
	    done
	    rassets_json="$(cat "$tmp")"
	    rm "$tmp"

	    # If there is anything left in $rassets_json, they are
	    # remote assets that do not have a local equivalent
	    # so delete them.
	    # Alternative strategy: use [0] like for labels above,
	    echo "$rassets_json" | \
	    $jq -r '.[] | (.id | tostring) + " " + .name' | \
	    while read -r id name
	    do
		echo "#$number '$title': Removing asset '$name'"

		$dryrun || \
		result="$(geturl DELETE "$apirepo/issues/$number/assets/$id" \
				 "#$number '$title': Failed to delete asset '$name'")"
	    done
	fi

	# Remove the existing issue from the list of remote issues
	rissues_json="$(echo "$rissues_json" | $jq "del(.[] | select(.number == $number))")"
    done

    # The only remote issues left in the list are the ones
    # that don't have a local equivalent so delete them.
    while [ "[]" != "$rissues_json" ]
    do
	number="$(echo "$rissues_json" | $jq -r ".[0].number")"
	rtitle="$(echo "$rissues_json" | $jq -r ".[0].title")"

	if $allow_delete
	then
	    echo "#$number '$rtitle': Deleting remote issue"

	    $dryrun || \
	    result="$(geturl DELETE "$apirepo/issues/$number" \
		      "#$number '$rtitle': Failed to delete remote issue")"
	else
	    : "#$number '$rtitle' would have been deleted with -D"
	fi

	# Remove the first item from the list of issues to delete
	rissues_json="$(echo "$rissues_json" | $jq -r "del(.[0])")"
    done

    # That just leaves local issues that have a number but
    # don't exist on the remote server. They should all be greater than
    # the last remote issue number and when we create new ones
    # we should get the issue numbers we want.
    # The only exception is when the last remote issue has been deleted;
    # In that case we get a higher first new issue number
    # than we expected so watch out for that case.
    # Fortunately it's rare because the main use case is updating a
    # migration of a repository, and in that case final deleted issues
    # are not migrated so the first allocated new issue number is
    # highest_existing_issue_number + 1.
    #
    # The easiest strategy seems to be to create all the remote issues
    # we want and then fill in their details as a second pass,
    # deleting any holes in the sequence of new issue numbers,
    # which do by making a list of newly-created issue numbers,
    # removing them from that list as we fill them in,
    # then deleting any left in the list when we are done.
    #
    # - Find the highest issue number that we have to create
    # - Create dummy issues up to that number
    # - Generate a list of all numbers from the first to the last
    #   of the ones to create
    # - go through the list of issues to create,
    #   filling in the issue nunber in question and removing it from the list
    # - remove the dummy issues left in the list

    if [ -z "$new_issue_nums" ]
    then return 0
    fi

    sorted_issue_nums="$(echo $new_issue_nums | sed 's/ /\n/g' | sort -n)"
    highest="$(echo "$sorted_issue_nums" | tail -n 1)"
    lowest="$(echo "$sorted_issue_nums" | head -n 1)"
    new_issue_nums="$(seq "$lowest" "$highest")"

    if [ "$lowest" -eq "$highest" ]
    then echo "Creating new issue ${lowest}..."
    else echo "Creating new issues ${lowest}-${highest}..."
    fi
    $dryrun || \
    for number in $new_issue_nums
    do
	# Create an empty issue
	result="$(puturl POST "$apirepo/issues" "{\"title\":\"dummy\"}" "#$number: Failed to create a new remote issue")"
	test $? -ne 0 && break
	newnumber="$(echo "$result" | $jq .number)"
	if [ "$newnumber" -lt "$number" ]
	then
	    # There was a gap between the last remote issue number
	    # and the first one we have to create, so keep creating dummies
	    # until we get the number we wanted.
	    while [ "$newnumber" -lt "$number" ]
	    do
		geturl DELETE "$apirepo/issues/$newnumber" \
			      "#$number: Failed to delete dummy issue" > /dev/null
		result="$(puturl POST "$apirepo/issues" "{\"title\":\"dummy\"}" "#$number: Failed to create a new remote issue")"
		test $? -ne 0 && break
		newnumber="$(echo "$result" | $jq .number)"
	    done
	elif [ "$newnumber" -gt "$number" ]
	then
	    # The new issue number is higher than we expected.
	    echo "#${number}: Tried to create dummy issue but got #$newnumber instead"
	else
	    : The new issue number was what we expected
	fi
    done
    # Now go through the list of issues to create, filling in their details
    for number in $new_issue_nums
    do
	# Select a local issue by its number
	for ftitle in *.md
	do
	    # Guard against issues whose title ends in .md
	    test -d "$ftitle" && continue

	    # Guard against there being no .md files
	    # though they may have created an issue called "*" (!)
	    test "$a" = "*.md" && [ ! -f "*.md" ] && continue

	    # and files called ".md"
	    if [ "$ftitle" = ".md" ]
	    then echo "Ignoring file '.md'"; continue
	    fi

	    # Hereon, $ftitle is the issue's directory name
	    ftitle="$(basename ./"$ftitle" .md)"

	    test "$(cat ./"$ftitle"/number)" -ne "$number" && continue

	    data="{"	# }
	    title="$(cat ./"$ftitle"/title)"
	    data="$data,\"title\":$(ecma_quote -n "$title")"
	    data="$data,\"body\":$(ecma_quote "$(tr -d '\r' < "$ftitle".md)")"

	    # New issues by default are created open
	    if [ -f "$ftitle"/state ] && [ "$(cat ./"$ftitle"/state)" = closed ]
	    then data="$data,\"state\":\"closed\""
	    fi

	    # You can't set the created_at field or username when PATCHING
	    # an issue, nor when creating it.

	    milestone="$(cat ./"$ftitle"/milestone 2> /dev/null)"
	    if [ -n "$milestone" ]
	    then
		# Find the milestone id from its name
		id="$(echo "$milestone_list" | grep "^$milestone " | sed 's/.* //')"
		if [ -z "$id" ]
		then
		    echo "#$number '$title': Invalid milestone '$milestone'"
		    continue
		fi
		data="$data,\"milestone\":$id"
	    fi

	    # Check that every label is valid
	    for label in $labels
	    do
		ok=false
		for a in $(echo "$label_list" | sed 's/ .*//')
		do
		    test "$label" = "$a" && ok=true
		done
		$ok || {
		    echo "Invalid label \"$label\" in '$ftitle'"
		    echo "Valid labels are: $(echo "$label_list" | sed 's/ .*//')"
		}
	    done
	    # Is a label repeated?
	    labels="$(echo "$labels" | sort)"
	    if [ "$labels" != "$(echo "$labels" | uniq)" ]
	    then
		echo "Warning: A label occurs twice in \"$ftitle\"/labels"
		labels="$(echo "$labels" | uniq)"
	    fi

	    # In the middle of creation of nonexistent issues we'll just
	    # have to ignore the bad labels.

	    # Look up the ids of the labels
	    if [ -n "$labels" ]
	    then
		list=
		for label in $labels
		do
		    id="$(echo "$label_list" | grep "^$label " | sed 's/.* //')"
		    list="$list,$id"
		done
		# There is at least one label so the initial "," is certain
		list="$(echo "$list" | sed 's/^,//')"
		data="$data,\"labels\":[$list]"
	    fi

	    data="$(echo "$data" | sed 's/^{,/{/;s/$/}/')"	# }

	    echo "#$number '$title': Filling in new issue"

	    $dryrun || \
	    result="$(puturl PATCH "$apirepo/issues/$number" "$data" \
			     "#$number '$title': Failed to fill it in")"

	    $dryrun || \
	    if [ -d "$ftitle"/assets ]
	    then
		test -d "$ftitle"/assets && \
		(cd ./"$ftitle"/assets && find . -maxdepth 1 -type f | \
					  sed 's|^\./||') | \
		while read -r name
		do
		    result="$(puturl POST -a \
		              "$apirepo/issues/$number/assets" \
			      "$ftitle/assets/$name" \
			      "#$number '$title': Failed to attach '$name'")"
		done
	    fi

	    # Remove it from the list of issues to delete afterwards.
	    # If something went wrong with the PATCH, do so as well
	    # so it isn't deleted and we can have a second chance at
	    # filling it in when we've fixed whatever was wrong.
	    new_issue_nums="$(echo "$new_issue_nums" | sed "s/\\<$number\\>//")"
	done
	# If we didn't find the local issue by number it's because
	# it's been deleted, so nothing extra to do; the dummy issue will go.
    done
    # Lastly, delete any issues that were created but not filled in:
    # the gaps in the sequence of new local issue numbers.
    $dryrun || \
    for number in $new_issue_nums
    do
	result="$(geturl DELETE "$apirepo/issues/$number" \
			 "#${number}: Failed to delete dummy issue")"
    done
}

fetchissues

case "$action" in
get)	getissues ;;
put)	putissues ;;
esac
