#!/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_main
This 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="[email protected]"
$ 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
spam
The 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
200
The 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; }