#!/usr/bin/env bash : '
@module gitea.md
@require pjeby/license @comment LICENSE
@require bashup/loco mdsh-source "$BASHER_PACKAGES_PATH/bashup/loco/loco.md"
@main loco_mainThis file is the source code and main tests for the generated gitea client. Tests look like this:
# Source the functions in this file and initialize as if the loco command were running:
$ source $TESTDIR/$TESTFILE; set +e
$ gitea.no-op() { :;}
# Ignore/null out all configuration for testing
$ loco_user_config() { :;}
$ loco_site_config() { :;}
$ loco_findproject() { LOCO_PROJECT=/dev/null; }
$ loco_main no-op
# dummy `curl` for testing
$ curl_status=200
$ GITEA_URL=https://example.com/gitea/
$ GITEA_USER=some_user
$ GITEA_API_TOKEN=EXAMPLE_TOKEN
$ read-curl() {
> { printf -v REPLY ' %q' "curl" "$@"; echo "${REPLY# }"; cat; } >&2;
> REPLY=${curl_status%%,*}; curl_status=${curl_status#*,}
> }
> curl() { read-curl "$@"; echo "$REPLY"; }The prefix options work by setting variables and invoking the remainder of the command line, so for testing we'll make a subcommand that dumps out those variables:
$ gitea.dump() {
> declare -p PROJECT_ORG PROJECT_NAME PROJECT_TAG GITEA_CREATE 2>/dev/null |
> sed "s/'//g" || true
> }
$ gitea dump
declare -- PROJECT_NAME="gitea.md"
declare -a GITEA_CREATE=()
# Try some combos and abbreviations:
$ gitea -t 1.2 -r bada/bing --desc foobly --with x y -p -P dump
declare -- PROJECT_ORG="bada"
declare -- PROJECT_NAME="bing"
declare -- PROJECT_TAG="1.2"
declare -a GITEA_CREATE=([0]="description" [1]="foobly" [2]="x" [3]="y" [4]="private=" [5]="false" [6]="private=" [7]="true")--with alters GITEA_CREATE to add the given key-value pair:
GITEA_CREATE=()
gitea.--with() {
local GITEA_CREATE=(${GITEA_CREATE[@]+"${GITEA_CREATE[@]}"} "${@:1:2}")
gitea "${@:3}"
} $ gitea --with foo bar dump
declare -- PROJECT_NAME="gitea.md"
declare -a GITEA_CREATE=([0]="foo" [1]="bar")These options are short for --with description description:
gitea.--description() { gitea --with description "$1" "${@:2}"; }
gitea.--desc() { gitea --description "$@"; }
gitea.-d() { gitea --description "$@"; } $ gitea --description something dump
declare -- PROJECT_NAME="gitea.md"
declare -a GITEA_CREATE=([0]="description" [1]="something")gitea.--public() { gitea --with private= false "$@"; }
gitea.-p() { gitea --public "$@"; } $ gitea --public dump
declare -- PROJECT_NAME="gitea.md"
declare -a GITEA_CREATE=([0]="private=" [1]="false")gitea.--private() { gitea --with private= true "$@"; }
gitea.-P() { gitea --private "$@"; } $ gitea --private dump
declare -- PROJECT_NAME="gitea.md"
declare -a GITEA_CREATE=([0]="private=" [1]="true")Set PROJECT_ORG and PROJECT_NAME from repo:
gitea.--repo() {
split_repo "$1"; local PROJECT_ORG="${REPLY[1]}" PROJECT_NAME="${REPLY[2]}"; gitea "${@:2}"
}
gitea.-r() { gitea --repo "$@"; } $ gitea --repo foo/bar dump
declare -- PROJECT_ORG="foo"
declare -- PROJECT_NAME="bar"
declare -a GITEA_CREATE=()
$ gitea --repo baz dump
declare -- PROJECT_ORG="some_user"
declare -- PROJECT_NAME="baz"
declare -a GITEA_CREATE=()Set PROJECT_TAG from version:
gitea.--tag() { local PROJECT_TAG="$1"; gitea "${@:2}" ; }
gitea.-t() { gitea --tag "$@"; } $ gitea --tag a.b dump
declare -- PROJECT_NAME="gitea.md"
declare -- PROJECT_TAG="a.b"
declare -a GITEA_CREATE=()Return success if the repository exists:
gitea.exists() { split_repo "$1" && auth api 200 404 "repos/$REPLY" ; } $ gitea exists foo/bar </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/foo/bar
[0]
$ curl_status=404 gitea exists foo/bar </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/foo/bar
[1]Delete the repository:
gitea.delete() { split_repo "$1" && auth api 204 "" "/repos/$REPLY" -X DELETE; } $ gitea delete foo/bar </dev/null
curl --silent --write-out \\n%\{http_code\} -X DELETE -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/foo/bar
Failure: HTTP code 200 (expected 204)
[70]
$ curl_status=204 gitea delete foo/bar </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} -X DELETE -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/foo/bar
[0]Add a deployment key key named keytitle to repo. Returns success if the key was successfully added.
gitea.deploy-key() {
split_repo "$1"
jmap title "$2" key "$3" read_only= "${4-true}" | json auth api 201 "" /repos/$REPLY/keys
} $ curl_status=201 gitea deploy-key foo/bar baz spam false
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/foo/bar/keys
{
"title": "baz",
"key": "spam",
"read_only": false
}Create the repository; opts are key-value pairs to pass to the API, such as description and private=. Defaults from ${GITEA_CREATE[@]} are used first, if set. A deploy key is automatically set if $GITEA_DEPLOY_KEY is non-empty; its title will be $GITEA_DEPLOY_KEY_TITLE or "default" if empty or missing:
gitea.new() {
split_repo "$1"; local org="${REPLY[1]}" repo="${REPLY[2]}"
if [[ $org == "$GITEA_USER" ]]; then org=user; else org="org/$org"; fi
jmap name "$repo" ${GITEA_CREATE[@]+"${GITEA_CREATE[@]}"} "${@:2}" |
json api "200|201" "" "$org/repos?token=$GITEA_API_TOKEN"
[[ ! "${GITEA_DEPLOY_KEY-}" ]] ||
gitea deploy-key "$1" "${GITEA_DEPLOY_KEY_TITLE:-default}" \
"$GITEA_DEPLOY_KEY" "${GITEA_DEPLOY_READONLY:-true}"
}# Defaults apply before command line; default API url is /org/ORGNAME/repos
$ export GIT_AUTHOR_NAME="PJ Eby" EMAIL="null@example.com"
$ gitea --private new biz/baz description whatever
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- https://example.com/gitea/api/v1/org/biz/repos\?token=EXAMPLE_TOKEN
{
"name": "baz",
"private": true,
"description": "whatever"
}
# When the repo is the current user, the API url is /user/repos
$ GITEA_CREATE=(private= true)
$ gitea --public -d something new some_user/spam
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- https://example.com/gitea/api/v1/user/repos\?token=EXAMPLE_TOKEN
{
"name": "spam",
"private": false,
"description": "something"
}
# Deployment happens if you provide a GITEA_DEPLOY_KEY
$ GITEA_DEPLOY_KEY=example-key curl_status=201 gitea new foo/bar
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- https://example.com/gitea/api/v1/org/foo/repos\?token=EXAMPLE_TOKEN
{
"name": "bar",
"private": true
}
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/foo/bar/keys
{
"title": "default",
"key": "example-key",
"read_only": true
}
# and it can have a GITEA_DEPLOY_KEY_TITLE and GITEA_DEPLOY_KEY_READONLY
$ GITEA_DEPLOY_KEY=example-key \
> GITEA_DEPLOY_KEY_TITLE=sample-title \
> GITEA_DEPLOY_READONLY=false \
> curl_status=201 \
> gitea new foo/bar
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- https://example.com/gitea/api/v1/org/foo/repos\?token=EXAMPLE_TOKEN
{
"name": "bar",
"private": true
}
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/foo/bar/keys
{
"title": "sample-title",
"key": "example-key",
"read_only": false
}gitea.vendor-merge() { :; }
branch-exists() { git rev-parse --verify "$1" &>/dev/null; }
gitea.vendor() {
[[ ! -d .git ]] || loco_error ".git repo must not exist here";
[[ -n "${PROJECT_ORG-}" ]] || PROJECT_ORG=$GITEA_USER
[[ -n "${PROJECT_NAME-}" ]] || loco_error "PROJECT_NAME not set"
local GITEA_GIT_URL=${GITEA_GIT_URL-$GITEA_URL}
[[ $GITEA_GIT_URL == *: ]] || GITEA_GIT_URL="${GITEA_GIT_URL%/}/";
local GIT_REPO="$GITEA_GIT_URL$PROJECT_ORG/$PROJECT_NAME.git"
if gitea exists "$PROJECT_ORG/$PROJECT_NAME"; then
[[ -n "${PROJECT_TAG-}" ]] || loco_error "PROJECT_TAG not set"
local MESSAGE="Vendor update to $PROJECT_TAG"
git clone --bare -b vendor "$GIT_REPO" .git ||
git clone --bare "$GIT_REPO" .git # handle missing-branch case
else
local MESSAGE="Initial import"
gitea new "$PROJECT_ORG/$PROJECT_NAME" "$@"
git clone --bare "$GIT_REPO" .git
fi
git config --local --bool core.bare false
git config --local user.name "${GITEA_VENDOR_NAME:-Vendor}"
git config --local user.email "${GITEA_VENDOR_EMAIL:-vendor@example.com}"
git add .; git commit -m "$MESSAGE" # commit to master or vendor
branch-exists vendor || git checkout -b vendor # split off vendor branch if needed
git push --all
[[ -z "${PROJECT_TAG-}" ]] || { git tag "vendor-$PROJECT_TAG"; git push --tags; }
git checkout master
gitea vendor-merge
}# Mock a gitea repo w/a file URL
$ GITEA_GIT_URL=$PWD
$ mkdir -p some_user/foo
$ git --git-dir=some_user/foo.git init --bare # fake gitea new
Initialized empty Git repository in /*/gitea.md/some_user/foo.git/ (glob)
# New Repository
$ mkdir foo; cd foo; echo "v1" >f
$ curl_status=404,200 gitea -p -r foo -t 1.1 vendor </dev/null 2>&1|grep -v '^ Author:'
curl --silent --write-out \\n%\{http_code\} -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/some_user/foo
curl --silent --write-out \\n%\{http_code\} -X POST -H Content-Type:\ application/json -d @- https://example.com/gitea/api/v1/user/repos\?token=EXAMPLE_TOKEN
{
"name": "foo",
"private": false
}
Cloning into bare repository '.git'...
warning: You appear to have cloned an empty repository.
done.
[master (root-commit) *] Initial import (glob)
1 file changed, 1 insertion(+)
create mode 100644 f
Switched to a new branch 'vendor'
To /*/some_user/foo.git (glob)
* [new branch] master -> master
* [new branch] vendor -> vendor
To /*/some_user/foo.git (glob)
* [new tag] vendor-1.1 -> vendor-1.1
Switched to branch 'master'
# Existing Repository
$ rm -rf .git
$ echo "v2" >>f
$ gitea -r foo -t 1.2 vendor </dev/null 2>&1|grep -v '^ Author:'
curl --silent --write-out \\n%\{http_code\} -H Authorization:\ token\ EXAMPLE_TOKEN https://example.com/gitea/api/v1/repos/some_user/foo
Cloning into bare repository '.git'...
done.
[vendor *] Vendor update to 1.2 (glob)
1 file changed, 1 insertion(+)
To /*/some_user/foo.git (glob)
*..* vendor -> vendor (glob)
To /*/some_user/foo.git (glob)
* [new tag] vendor-1.2 -> vendor-1.2
Switched to branch 'master'
$ cd ..split_repo splits $1 into an organization in ${REPLY[1]} and a repo in ${REPLY[2]}. The organization defaults to $GITEA_USER if there's no slash in $1. $REPLY (aka ${REPLY[0]}) contains the fully qualified name, with the defaulted organization included:
split_repo() {
[[ "$1" == */* ]] || set -- "$GITEA_USER/$1";
REPLY=("$1" "${1%/*}" "${1##*/}")
} $ split_repo foo/bar; printf '%s\n' "${REPLY[@]}"
foo/bar
foo
bar
$ GITEA_USER=baz; split_repo spam; printf '%s\n' "${REPLY[@]}"
baz/spam
baz
spamThe jmap function takes a series of key-value argument pairs and outputs a JSON-encoded mapping using jq. Keys with a trailing = treat their value as a JSON-encoded value or jq expression; all others are treated as strings for jq to encode:
jmap() {
local filter='{}' opts=(-n) arg=1
while (($#)); do
if [[ $1 == *= ]]; then
filter+=" | .$1 $2"
else
filter+=" | .$1=\$__$arg"
opts+=(--arg "__$arg" "$2")
((arg++))
fi
shift 2
done
jq "${opts[@]}" "$filter"
} $ jmap foo bar baz spam thing= true calc= 21 blue= '.calc*2'
{
"foo": "bar",
"baz": "spam",
"thing": true,
"calc": 21,
"blue": 42
}The main gogs/gitea API actions are done by stacking various "adapters" (json and auth) over an application of curl. These adapters modify the command line arguments passed to curl by adding headers, changing method types, etc. They invoke their arguments, followed by additional arguments:
json() { "$@" -X POST -H "Content-Type: application/json" -d @-; }
auth() { "$@" -H "Authorization: token $GITEA_API_TOKEN"; }# `json` makes it a curl POST with content-type application/json:
$ json curl </dev/null
curl -X POST -H Content-Type:\ application/json -d @-
200
# `auth` adds the current API token:
$ GITEA_API_TOKEN=foo auth curl </dev/null
curl -H Authorization:\ token\ foo
200The actual API invocation is handled by the api function, which takes a list of |-separated "success" statuses and a list of |-separated "normal failure" statuses, followed by an API path and any extra curl options. If the curl status matches one of the given success/failure statuses, success or failure is returned, otherwise an error message is output to stderr and a suitable exit code returned:
api() {
if ! shopt -q extglob; then
# extglob is needed for pattern matching
local r=0; shopt -s extglob; api "$@" || r=$?; shopt -u extglob; return $r
fi
read-curl --silent --write-out '\n%{http_code}' "${@:4}" "${GITEA_URL%/}/api/v1/${3#/}"
local true="@($1)" false="@($2)" code=${REPLY##*$'\n'}; REPLY=${REPLY%$code}
# shellcheck disable=2053 # glob matching is what we want!
if [[ $code == $true ]]; then return 0; elif [[ $2 && $code == $false ]]; then return 1
else case $code in
000) fail "Invalid server response: check GITEA_URL" 78 ;; # EX_PROTOCOL
401) fail "Unauthorized: check GITEA_USER and GITEA_API_TOKEN" 77 ;; # EX_NOPERM
404) fail "Server returned 404 Not Found" 69 ;; # EX_UNAVAILABLE
*) fail "Failure: HTTP code $code (expected $1${2:+ or $2})" 70 ;; # EX_SOFTWARE
esac
fi
}
read-curl() { REPLY=$(curl "$@"); }
fail() { echo "$1" >&2; return "${2-64}"; } $ api 200 "" x extra args </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} extra args https://example.com/gitea/api/v1/x
[0]
$ api "" "400|200" x extra args </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} extra args https://example.com/gitea/api/v1/x
[1]
$ curl_status=000 api 200 "" x extra args </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} extra args https://example.com/gitea/api/v1/x
Invalid server response: check GITEA_URL
[78]
$ curl_status=401 api 200 "" /y </dev/null
curl --silent --write-out \\n%\{http_code\} https://example.com/gitea/api/v1/y
Unauthorized: check GITEA_USER and GITEA_API_TOKEN
[77]
$ curl_status=404 api 200 401 /z </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} https://example.com/gitea/api/v1/z
Server returned 404 Not Found
[69]
$ curl_status=404 api 200 404 /z </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} https://example.com/gitea/api/v1/z
[1]
$ curl_status=501 api 200 401 /z </dev/null; echo [$?]
curl --silent --write-out \\n%\{http_code\} https://example.com/gitea/api/v1/z
Failure: HTTP code 501 (expected 200 or 401)
[70]We override loco's configuration process in a few ways: first, our command name/function prefix is always gitea, and we use /etc/gitea-cli/config file as the site-wide configuration file. We also change loco's "find the project root" functionality so it looks for a .gitearc but doesn't change to that directory. We also default a PROJECT_NAME setting to the basename of the current working directory (which is helpful for gitea vendor.)
loco_preconfig() {
LOCO_SCRIPT=$BASH_SOURCE
LOCO_SITE_CONFIG=/etc/gitea-cli/gitearc
LOCO_USER_CONFIG=$HOME/.config/gitearc
LOCO_NAME=gitea
LOCO_FILE=(.gitearc)
PROJECT_NAME="${PROJECT_NAME-$(basename "$PWD")}"
}
loco_findproject() {
findup "$LOCO_PWD" "${LOCO_FILE[@]}" && LOCO_PROJECT=$REPLY || LOCO_PROJECT=/dev/null
}
loco_findroot() { LOCO_ROOT=$LOCO_PWD; }