01 / apply it
yardr pack apply ./gitlabFrom a clone of yardr-packs: the pack's directory. yardr pack check ./gitlab shows what it would change first. It goes into the workspace itself, and into no repo · depot.
yardr pack apply https://yardr.dev/packs/gitlab/From this site, as the files below.
02 / the files
Every file, as it ships.
# GitLab as a pack of kind source: the integration, imported into the
# yard and into no depot. What GitLab is: how its webhooks are verified
# and read (issues, merge requests, comments, pipelines), how the yard
# speaks in those threads (replies, opens an issue, closes and reopens an
# issue or a merge request), how it reads a merge request and its CI back,
# and the reads an agent may ask for through `yardr call`.
#
# yardr pack apply /Users/mckean/projects/repos/yardr-packs/gitlab
#
# The source is named gitlab: a pack is named after the service it
# talks to.
name: gitlab
kind: source
setup: SETUP.md
sources:
gitlab:
declaration: gitlab.yaml
# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and a
# terminal pack apply ignores them.
presets:
read:
about: Read a project, a merge request, pipelines and issues, and who the token is; change nothing
grants:
gitlab: [read]
#!/bin/bash
# mr: a merge request live, and its CI: the pr and ci commands of the
# declaration's events key. One JSON object on stdin, one on stdout:
#
# pr in {repo, number, url} out {head, base, state}
# ci in {repo, number, sha} out {head, base, state, required, checks}
#
# The daemon reads pr for the head of a merge request that no event has
# told, and ci as the state of a train's CI; the train's brief carries the
# same snapshot. GITLAB_TOKEN (a personal access token, sent in a
# PRIVATE-TOKEN header) and YARDR_SOURCE_BASE, the declaration's base, are in
# the environment. Needs curl and jq. It only reads: every request is a GET.
#
# state is GitLab's, but for open: GitLab says opened, and the core asks
# whether the state is open. closed, merged and locked stay as they are.
#
# ci. GitLab keeps no list of the checks a branch requires. Its gate is the
# head pipeline of the merge request: where the project requires pipelines to
# succeed, the merge request cannot be merged until that one pipeline has.
# So the pipeline is one check, named pipeline, which is required when the
# project requires it; the jobs of the pipeline (the latest attempt of each)
# are checks beside it, with the names they have in GitLab. Checks speak the
# tongue the core judges: completed or not, and a completed one concluded
# success, neutral, skipped, failure or cancelled. A job that failed where
# GitLab lets it fail is neutral, a canceled one is cancelled (the core reads
# that as not yet run), and a skipped pipeline passes only where the project
# lets a skipped pipeline merge. The checks are those of the head pipeline,
# and only when the merge request's head is the commit asked about and the
# pipeline ran on it: a merged results pipeline runs on a commit GitLab made
# of the merge request and its target, and is the head pipeline all the same.
# For any other commit there are no checks, and the core waits.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
base=${YARDR_SOURCE_BASE%/}
# The token goes to curl as a config line on its stdin, written by the shell
# itself: an argument can be read by every process on the machine while
# curl runs. A token that holds a character a GitLab token never has (a
# quote, a backslash, a line break) would end the config line early, so it
# is refused, without saying what it held.
case ${GITLAB_TOKEN-} in
'' | *[!A-Za-z0-9_.~-]*)
echo "GITLAB_TOKEN is not set, or holds a character a GitLab token has none of" >&2
exit 1
;;
esac
# scrub <text>: the text with the token cut out of it, in case an answer
# repeats what it was sent.
scrub() { printf '%s' "${1//"$GITLAB_TOKEN"/<token>}"; }
# api <url>: the answer's body of a GET, or the failure on stderr.
api() {
local u code
u=$1
code=$(printf 'header = "PRIVATE-TOKEN: %s"\n' "$GITLAB_TOKEN" \
| curl -sS -o "$tmp/out" -w '%{http_code}' -K - -X GET "$u") || {
echo "could not reach $u (curl exit $?)" >&2
return 1
}
if [ "${code:0:1}" != 2 ]; then
# GitLab says why in message (a string, or an object of lists), or in
# error and error_description for a token it refuses.
local why
why=$(jq -r '(.message // .error_description // .error // "no answer") | if type == "string" then . else tojson end | .[0:300]' "$tmp/out" 2>/dev/null || head -c 200 "$tmp/out")
echo "$u: HTTP $code: $(scrub "$why")" >&2
return 1
fi
cat "$tmp/out"
}
# pages <url with ?> <file>: every page of a list endpoint as one array in
# the file. GitLab lists 20 to a page; ask for 100 and stop on a short page,
# since gitlab.com may leave out the headers that count the pages.
pages() {
local u page n
u=$1
page=1
: >"$tmp/pages"
while :; do
api "$u&per_page=100&page=$page" >"$tmp/page" || return 1
n=$(jq 'length' "$tmp/page") || return 1
cat "$tmp/page" >>"$tmp/pages"
printf '\n' >>"$tmp/pages"
[ "$n" -lt 100 ] && break
page=$((page + 1))
done
jq -c -s 'add // []' "$tmp/pages" >"$2"
}
# A project as {repo} gives it: a namespace (which may nest) and a project,
# each segment as GitLab writes a path. Nothing that would reach another
# endpoint, and no two dots in a row.
valid_project() { [[ $1 =~ ^[A-Za-z0-9_.][A-Za-z0-9_.-]*(/[A-Za-z0-9_.][A-Za-z0-9_.-]*)+$ ]] && [[ $1 != *..* ]]; }
# standing is jq's state of the merge request, in the core's word for open.
standing='def standing: if . == "opened" then "open" else . end;'
# The merge request: by {repo, number}, else by the address of a merge
# request in {url}: https://<site>/<project path>/-/merge_requests/<n>, with
# or without the -/ GitLab writes now.
repo=$(arg repo)
n=$(arg number)
if [ -z "$repo" ] || [ -z "$n" ]; then
url=$(arg url)
if [[ $url =~ ^https?://[^/]+/(([A-Za-z0-9_.][A-Za-z0-9_.-]*/)+[A-Za-z0-9_.][A-Za-z0-9_.-]*)/(-/)?merge_requests/([0-9]+)(/.*)?$ ]]; then
repo=${BASH_REMATCH[1]}
n=${BASH_REMATCH[4]}
else
echo "the merge request is named by neither {repo, number} nor {url}" >&2
exit 1
fi
fi
valid_project "$repo" || {
echo "{repo} $repo: want group/project" >&2
exit 1
}
[[ $n =~ ^[1-9][0-9]*$ ]] || {
echo "{number} $n: want the merge request's number" >&2
exit 1
}
pid=${repo//\//%2F}
# read_mr: the merge request as GitLab answers it, in mr, held to the head and
# the base the core asks for.
read_mr() {
mr=$(api "$base/projects/$pid/merge_requests/$n") || exit 1
printf '%s' "$mr" >"$tmp/mr.json"
[ "$(jq -r '(.sha // "") != "" and (.target_branch // "") != ""' <<<"$mr")" = true ] || {
echo "the answer holds no head or base" >&2
exit 1
}
}
case "${1-}" in
pr)
read_mr
jq -c "$standing"'{head: .sha, base: .target_branch, state: ((.state // "") | standing)}' <<<"$mr"
;;
ci)
sha=$(arg sha)
[[ $sha =~ ^[0-9a-fA-F]+$ ]] || {
echo "{sha} $sha: want the commit's SHA" >&2
exit 1
}
read_mr
# What the project says of the gate: whether merging waits for a pipeline
# that succeeded, and whether a skipped pipeline counts as one.
api "$base/projects/$pid" >"$tmp/project.json" || exit 1
# The head pipeline is the gate, and its jobs the checks beside it; they
# are asked for only where it is the pipeline of the commit asked about.
# The pipeline may live in another project than the merge request: a
# fork's runs in the fork, and GitLab names that project in the pipeline.
ours=$(jq -r --arg sha "$sha" --arg n "$n" '
(.sha // "" | ascii_downcase) as $head | (.head_pipeline // null) as $hp
| ($hp | type) == "object" and $head == ($sha | ascii_downcase)
and ((($hp.sha // "" | ascii_downcase) == $head) or ($hp.ref // "") == "refs/merge-requests/\($n)/merge")' <<<"$mr")
echo '[]' >"$tmp/jobs.json"
if [ "$ours" = true ]; then
hp_id=$(jq -r '.head_pipeline.id // "" | tostring' <<<"$mr")
hp_project=$(jq -r '.head_pipeline.project_id // "" | tostring' <<<"$mr")
[[ $hp_id =~ ^[1-9][0-9]*$ ]] || {
echo "the answer's head pipeline has no number" >&2
exit 1
}
# A pipeline that names no project of its own is the merge request's.
if [[ $hp_project =~ ^[1-9][0-9]*$ ]]; then hp_at=$hp_project; else hp_at=$pid; fi
# The latest attempt of each job, which is what GitLab lists unless asked
# for the retried ones too.
pages "$base/projects/$hp_at/pipelines/$hp_id/jobs?include_retried=false" "$tmp/jobs.json" || exit 1
fi
# The answers go in as files: a merge request with a long description, or a
# pipeline of a hundred jobs, is more than a single argument may hold.
jq -n -c --slurpfile mr "$tmp/mr.json" --slurpfile project "$tmp/project.json" --slurpfile jobs "$tmp/jobs.json" --arg ours "$ours" "$standing"'
$mr[0] as $mr | $project[0] as $project | $jobs[0] as $jobs |
# A check in the tongue the core judges. status is what GitLab says of
# the job or pipeline until it has ended, and completed after.
def check($name; $url; $status; $allow_failure; $skipped_passes):
{name: $name}
+ (if $status == "success" then {status: "completed", conclusion: "success"}
elif $status == "failed" then {status: "completed", conclusion: (if $allow_failure then "neutral" else "failure" end)}
elif $status == "canceled" then {status: "completed", conclusion: "cancelled"}
elif $status == "skipped" then (if $skipped_passes then {status: "completed", conclusion: "skipped"} else {status: "skipped", conclusion: ""} end)
else {status: $status, conclusion: ""} end)
+ {url: $url};
($mr.head_pipeline // {}) as $hp
| {head: $mr.sha, base: $mr.target_branch, state: (($mr.state // "") | standing),
required: (if ($project.only_allow_merge_if_pipeline_succeeds // false) == true then ["pipeline"] else [] end),
checks: (if $ours != "true" then []
else [check("pipeline"; ($hp.web_url // ""); ($hp.status // ""); false; ($project.allow_merge_on_skipped_pipeline // false) == true)]
+ [$jobs[] | check((.name // ""); (.web_url // ""); (.status // ""); (.allow_failure // false) == true; true)]
end)}'
;;
*)
echo "unknown: ${1-}" >&2
exit 2
;;
esac
#!/bin/sh
# A verified GitLab webhook to the yard's envelope: stdin
# {"headers":{…},"body":{…}}, stdout the envelope. Needs jq.
#
# Issue, merge request and comment (note) deliveries are read: the issue or
# merge request is the thread, its description or the comment the text.
# A pipeline is read as a run of CI: its head, its result and, when it ran
# for a merge request, that merge request as the thread. Any other delivery
# (a push, a work item that is no issue) is recorded with its type, action,
# repo and sender and no thread, so nobody is mentioned by it: GitLab
# disables a webhook after four failed answers in a row, so an event this
# does not read is never refused. A body that is no JSON object is refused:
# a non-zero exit is answered 400, and the delivery reaches no
# subscription. Nothing of the body is ever printed to stderr.
#
# The thread of an issue is <project path>#<number> and of a merge request
# <project path>!<number>, GitLab's own references: the two are numbered
# apart there, and the thread command tells them apart by the character.
# The thread's URL is built from the project's web_url, the same for an
# issue's own events and its comments' (a comment's payload carries the
# comment's URL, not the issue's). The sender is carried lowercased, as
# actor.id and as fields.sender, which hear matches by identity.
exec jq -c '
def str($v): if ($v | type) == "string" then $v elif $v == null then "" else "\($v)" end;
# trunc is hooks.truncate: whitespace collapsed, at most 500 runes. The jq
# POSIX space class includes the Unicode spaces split by Go strings.Fields.
def trunc: (gsub("^[[:space:]]+|[[:space:]]+$"; "") | gsub("[[:space:]]+"; " ")) | if length > 500 then .[0:499] + "…" else . end;
def ordered: reduce .[] as $x ([]; if index($x) then . else . + [$x] end);
# mentions are the lowercase usernames a body mentions, once each, in
# order. A username is letters, digits, _, - and . (GitLab: it starts with
# none of - and ends with none of .). A group mention (@group/subgroup)
# is left out: a group is not a crew member. An @name in code is text, not
# a mention, and an email address (a@b) is none.
def mentions($md):
((($md // "") | gsub("(?s)```.*?(```|$)|`[^`\n]*`"; " "))
| [scan("(?:^|[^A-Za-z0-9_@./-])@([A-Za-z0-9_.][A-Za-z0-9_.-]*[A-Za-z0-9_-]|[A-Za-z0-9_])(/?)")
| select(.[1] != "/") | .[0] | ascii_downcase | select(length <= 255)]
| ordered);
def header($in; $name): [$in.headers // {} | to_entries[] | select(.key | ascii_downcase == $name) | .value[]] | .[0] // "";
def field($o; $k; $v): if ($v // "") == "" then $o else $o + {($k): [$v]} end;
def labels($l): [$l[]? | .title | select(. != null and . != "")];
# The username of the first assignee: the assignees list, else the single
# assignee GitLab still sends (deprecated), else the one the item of a
# comment carries (a merge request does; an issue gives ids alone).
def assignee_of($p; $item): ((($p.assignees // [])[0] // $p.assignee // ($item // {}).assignee // {}).username // "") | ascii_downcase;
. as $in
| (.body | if . == null then {} elif type == "object" then . else error("gitlab payload is not JSON") end) as $p
# The kind is what the body says it is. A body with none falls back to the
# event named in the header ("Merge Request Hook" is merge_request).
| (str($p.object_kind) | if . != "" then . else
(header($in; "x-gitlab-event") | ascii_downcase | sub(" hook$"; "") | gsub("[^a-z0-9]+"; "_") | sub("^_"; "") | sub("_$"; ""))
end | if . == "" then error("gitlab delivery has no object_kind and no X-Gitlab-Event") else . end) as $kind
| ($p.user // {}) as $user
| (str($user.username // $p.user_username)) as $named
| ($named | ascii_downcase) as $login
| ($p.project // {}) as $proj
| str($proj.path_with_namespace) as $repo
| str($proj.web_url) as $web
| ($p.object_attributes // {}) as $o
| ({} | field(.; "repo"; $repo) | field(.; "sender"; $login)) as $base
| {source: "gitlab", type: $kind, action: str($o.action),
actor: (if $named == "" then {} else {id: $login, name: $named} end), thread: {}, mentions: []}
| if $kind == "issue" or $kind == "merge_request" or $kind == "note" then
# What the delivery is about, and what it says.
(if $kind == "note" then
(str($o.noteable_type)) as $nt
| (if $nt == "Issue" then "issue" elif $nt == "MergeRequest" then "merge_request" elif $nt == "Commit" then "commit" elif $nt == "Snippet" then "snippet" else ($nt | ascii_downcase) end) as $on
| {on: $on, item: (if $on == "issue" then $p.issue elif $on == "merge_request" then $p.merge_request else null end), said: $o}
else {on: $kind, item: $o, said: null} end) as $t
| ($t.item // null) as $item
| ($t.said // null) as $said
| (if $said != null then str($said.note) else str($item.description) end) as $text
| (if $item == null then 0 else ($item.iid // 0) end) as $iid
| ($base | field(.; "noteable"; (if $said == null then "" else $t.on end))) as $f
| (if $item == null or $iid <= 0 or ($t.on != "issue" and $t.on != "merge_request") then . + {text: ($text | trunc), mentions: mentions($text), fields: $f}
else
(if $t.on == "issue" then "#" else "!" end) as $sep
| (if $t.on == "issue" then "issues" else "merge_requests" end) as $dir
| ($repo + $sep + ($iid | tostring)) as $thread
| (if $web != "" then $web + "/-/" + $dir + "/" + ($iid | tostring)
else (str($item.url) | sub("#.*$"; "")) end) as $url
| ($f | field(.; "number"; ($iid | tostring))
| field(.; "state"; str($item.state))
| (if $t.on == "merge_request" then
field(.; "head_branch"; str($item.source_branch))
| field(.; "head_sha"; str($item.last_commit.id))
| field(.; "head_repo"; str($item.source.path_with_namespace))
| field(.; "base"; str($item.target_branch))
else . end)
| (labels($p.labels // $item.labels) as $ls
| if ($ls | length) == 0 then . else . + {label: $ls} end)) as $f2
# An edit re-sends the comment whole, so its mentions are counted
# against the last one seen. The description is re-sent whole with
# every action, so its mentions are counted against the last one
# seen as well.
| . + {thread: {id: $thread, title: str($item.title), url: $url},
url: (if $said != null then str($said.url) else $url end),
assignee: assignee_of($p; $item),
text: ($text | trunc), mentions: mentions($text), fields: $f2}
| (if $said != null and ($said.id // 0) != 0 then
. + {message: ($said.id | tostring), entity: ("note-" + ($said.id | tostring))}
else . + {entity: $thread} end)
end)
elif $kind == "pipeline" then
# A pipeline is a run of CI, whatever became of it (events.runs).
# GitLab sends one at each change of its status. conclusion is the word
# of the tongue the core judges (the checks of the ci command speak it
# too) once the pipeline has ended, and none while it runs.
str($o.status) as $status
| (({success: "success", failed: "failure", canceled: "cancelled", skipped: "skipped"} | .[$status]) // "") as $conclusion
| str($o.ref) as $ref
# A merged results or merge train pipeline runs on a commit GitLab made
# of the merge request and its target, which is not the head: it has no
# head_sha, so the core leaves it alone as a run it cannot place.
| ($ref | test("^refs/merge-requests/[0-9]+/(merge|train)$")) as $made
| (if ($p.merge_request | type) == "object" and (($p.merge_request.iid // 0) | type) == "number" and $p.merge_request.iid > 0 then $p.merge_request else null end) as $mr
# The merge request belongs to the target project, and the pipeline of a
# fork may run in the fork: the thread is read from the URL of the merge
# request itself (its site and project path), not from the project the
# pipeline ran in.
| (if $mr == null then null
else ([str($mr.url) | sub("#.*$"; "") | capture("^(?<site>https?://[^/]+)/(?<path>.+?)/(?:-/)?merge_requests/[0-9]+(?:/.*)?$")] | .[0]) end) as $at
| (if $mr != null then str($mr.source_branch) elif $o.tag == true or $made then "" else $ref end) as $branch
# Where the head is: in the project the pipeline ran in, unless a merge
# request from a fork was run in the target project, and the payload does
# not give the path of the fork.
| (if $mr == null or $mr.source_project_id == $mr.target_project_id or ($proj.id != null and $proj.id == $mr.source_project_id) then $repo else "" end) as $head_repo
# A pipeline may run in the source fork, but its merge request repo is the
# target project. Keep those identities separate so the core can match
# the merge request by repo and number, then check the source in head_repo.
| (($base | (if $at == null then . else . + {repo: [$at.path]} end))
| field(.; "number"; (if $mr == null then "" else ($mr.iid | tostring) end))
| field(.; "conclusion"; $conclusion)
| field(.; "head_branch"; $branch)
| field(.; "head_sha"; (if $made then "" else str($o.sha) end))
| field(.; "head_repo"; $head_repo)
| field(.; "base"; (if $mr == null then "" else str($mr.target_branch) end))) as $f
| . + {url: (if str($o.url) != "" then str($o.url)
elif $web != "" and ($o.id // 0) != 0 then $web + "/-/pipelines/" + ($o.id | tostring)
else "" end),
text: (["pipeline", $status] + (if $branch == "" then [] else ["on", $branch] end) | map(select(. != "")) | join(" ") | trunc),
fields: $f}
| (if $at == null then .
else . + {thread: {id: ($at.path + "!" + ($mr.iid | tostring)), title: str($mr.title),
url: ($at.site + "/" + $at.path + "/-/merge_requests/" + ($mr.iid | tostring))}} end)
else . + {fields: $base}
end
'
#!/bin/bash
# thread: what the yard does in a GitLab thread: reply, posted, read, open,
# opened, state. One JSON object on stdin, one on stdout, as the thread key
# of the declaration says. GITLAB_TOKEN (a personal access token, sent in a
# PRIVATE-TOKEN header) and YARDR_SOURCE_BASE, the declaration's base, are
# in the environment. Needs curl and jq.
#
# A thread is GitLab's own reference to an issue or a merge request:
# group/project#12 is the issue, group/project!12 the merge request, and
# the project's path may nest to any depth. The two are numbered apart in
# GitLab and sit under different paths of its API (issues, merge_requests),
# so the character tells this script which, and everything else is shared:
# a merge request's thread is its notes, as an issue's are its comments.
set -euo pipefail
in=$(cat)
arg() { jq -r --arg k "$1" '.[$k] // ""' <<<"$in"; }
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
base=${YARDR_SOURCE_BASE%/}
# The address people read GitLab at, for a URL in an answer: the API's base
# without its /api/v4. A base that is no /api/v4 of a site (a stand-in's)
# has none, and an answer then carries no URL.
web=${base%/api/v4}
if [ "$web" = "$base" ]; then web=; fi
# The token goes to curl as a config line on its stdin, written by the
# shell itself: an argument can be read by every process on the machine
# while curl runs. A token that holds a character a GitLab token never has
# (a quote, a backslash, a line break) would end the config line early, so
# it is refused, without saying what it held.
case ${GITLAB_TOKEN-} in
'' | *[!A-Za-z0-9_.~-]*)
echo "GITLAB_TOKEN is not set, or holds a character a GitLab token has none of" >&2
exit 1
;;
esac
# scrub <text>: the text with the token cut out of it, in case an answer
# repeats what it was sent. Bash does it in process: the token is never an
# argument.
scrub() { printf '%s' "${1//"$GITLAB_TOKEN"/<token>}"; }
# api <method> <url> [data]: the answer's body, or the failure on stderr.
# The data goes to curl as a file, not an argument: a long reply would not
# fit one.
api() {
local m u code
m=$1
u=$2
if [ $# -ge 3 ]; then
printf '%s' "$3" >"$tmp/req"
code=$(printf 'header = "PRIVATE-TOKEN: %s"\n' "$GITLAB_TOKEN" \
| curl -sS -o "$tmp/out" -w '%{http_code}' -K - -X "$m" "$u" \
-H 'Content-Type: application/json' --data-binary @"$tmp/req") || {
echo "could not reach $u (curl exit $?)" >&2
return 1
}
else
code=$(printf 'header = "PRIVATE-TOKEN: %s"\n' "$GITLAB_TOKEN" \
| curl -sS -o "$tmp/out" -w '%{http_code}' -K - -X "$m" "$u") || {
echo "could not reach $u (curl exit $?)" >&2
return 1
}
fi
if [ "${code:0:1}" != 2 ]; then
# GitLab says why in message (a string, or an object of lists), or in
# error and error_description for a token it refuses.
local why
why=$(jq -r '(.message // .error_description // .error // "no answer") | if type == "string" then . else tojson end | .[0:300]' "$tmp/out" 2>/dev/null || head -c 200 "$tmp/out")
echo "$u: HTTP $code: $(scrub "$why")" >&2
return 1
fi
cat "$tmp/out"
}
# pages <url with ?>: every page of a list endpoint as one array, in the
# order the url asks for. GitLab lists 20 to a page; ask for 100 and stop
# on a short page, since gitlab.com may leave out the headers that count
# the pages.
pages() {
local u page n
u=$1
page=1
: >"$tmp/pages"
while :; do
api GET "$u&per_page=100&page=$page" >"$tmp/page" || return 1
n=$(jq 'length' "$tmp/page") || return 1
cat "$tmp/page" >>"$tmp/pages"
[ "$n" -lt 100 ] && break
page=$((page + 1))
done
jq -c -s 'add // []' "$tmp/pages"
}
# A project as --in gives it, or as a thread names it: a namespace (which
# may nest) and a project, each segment as GitLab writes a path. Nothing
# that would reach another endpoint, and no two dots in a row.
valid_project() { [[ $1 =~ ^[A-Za-z0-9_.][A-Za-z0-9_.-]*(/[A-Za-z0-9_.][A-Za-z0-9_.-]*)+$ ]] && [[ $1 != *..* ]]; }
# A username as GitLab writes one, for the assignee.
valid_user() { [[ $1 =~ ^[A-Za-z0-9_.][A-Za-z0-9_.-]*$ ]] && [[ $1 != *..* ]]; }
# split <thread>: the project, the kind and the number a thread names, or
# why it is no thread. pid is the project as the API takes it in a path,
# its slashes encoded.
split() {
# valid_project matches too, and BASH_REMATCH is the last match's: keep
# this one's parts first.
if [[ $1 =~ ^(.+)([#!])([1-9][0-9]*)$ ]]; then
local p=${BASH_REMATCH[1]} sep=${BASH_REMATCH[2]} num=${BASH_REMATCH[3]}
if valid_project "$p"; then
project=$p
n=$num
if [ "$sep" = '!' ]; then kind=merge_requests; else kind=issues; fi
pid=${project//\//%2F}
return 0
fi
fi
echo "thread $1: want group/project#number for an issue or group/project!number for a merge request" >&2
return 1
}
# literal <text>: the text as GitLab will keep it. When GitLab saves a note
# or an issue's description it runs a line that starts with a slash as a
# quick action (/close, /assign, /approve and the rest) and drops the line
# from the text, so what an agent quoted could close, assign or approve as
# the yard. A leading space on such a line renders the same and runs
# nothing. GitLab takes the carriage returns out itself, and a line that
# one hid the slash on would start with it then, so they go first. The
# trailing newlines go with the substitution; GitLab strips them anyway.
literal() {
local s
s=$(printf '%s' "$1" | tr -d '\r' | sed -e 's|^/| /|')
printf '%s' "$s"
}
# noteurl is the jq function that makes a note's URL: GitLab's answers
# carry none, but the address of the thread and the id make it. It reads
# $web, $project, $kind and $n, which each program is given as --arg.
noteurl='def noteurl($id): if ($id | tostring) == "" or $web == "" then "" else "\($web)/\($project)/-/\($kind)/\($n)#note_\($id)" end;'
case "$1" in
reply) # GitLab takes no client-chosen id, so the key goes into the body
# as a marker that renders as nothing. A note on an issue or merge
# request is not threaded, so the parent is left out: every reply is
# top-level.
thread=$(arg thread)
body=$(arg body)
key=$(arg key)
split "$thread" || exit 1
body=$(literal "$body")
[ -n "$key" ] && body="$body
<!-- yardr-reply $key -->"
note=$(printf '%s' "$body" | jq -Rsc '{body: .}')
api POST "$base/projects/$pid/$kind/$n/notes" "$note" |
jq -c --arg web "$web" --arg project "$project" --arg kind "$kind" --arg n "$n" \
"$noteurl"'((.id // "") | tostring) as $id | {id: $id} + (noteurl($id) as $u | if $u == "" then {} else {url: $u} end)' ;;
posted) # {} for none: a retry asks before it posts, so nothing is posted twice
thread=$(arg thread)
key=$(arg key)
split "$thread" || exit 1
pages "$base/projects/$pid/$kind/$n/notes?sort=asc&order_by=created_at" |
jq -c --arg m "<!-- yardr-reply $key -->" --arg web "$web" --arg project "$project" --arg kind "$kind" --arg n "$n" \
"$noteurl"'[.[] | select((.system // false) | not) | select((.body // "") | contains($m))][0]
| if . == null then {}
else ((.id // "") | tostring) as $id | {id: $id} + (noteurl($id) as $u | if $u == "" then {} else {url: $u} end) end' ;;
read) # every page, oldest first. A system note ("closed", "changed the
# description") is GitLab's record of what happened, not a comment.
thread=$(arg thread)
split "$thread" || exit 1
pages "$base/projects/$pid/$kind/$n/notes?sort=asc&order_by=created_at" |
jq -c --arg web "$web" --arg project "$project" --arg kind "$kind" --arg n "$n" \
"$noteurl"'{messages: [.[] | select((.system // false) | not) | ((.id // "") | tostring) as $id | {id: $id, parent: "",
author: {id: ((.author.username // "") | ascii_downcase), name: (.author.username // "")},
at: (.created_at // "0001-01-01T00:00:00Z"), body: (.body // ""), url: noteurl($id)}]}' ;;
open) # GitLab takes no client-chosen id, so the key goes into the
# description as a marker. The thread is named by the project's path as
# GitLab spells it (the answer's references.full), which may differ in
# case from what was typed. An assignee is a username, which GitLab
# takes as an id, so it is looked up first.
into=$(arg in)
title=$(arg title)
body=$(arg body)
key=$(arg key)
assignee=$(arg assignee)
valid_project "$into" || {
echo "--in $into: want group/project" >&2
exit 1
}
if [ -n "$assignee" ] && ! valid_user "$assignee"; then
echo "assignee $assignee: want a GitLab username" >&2
exit 1
fi
body=$(literal "$body")
if [ -n "$key" ]; then
marker="<!-- yardr-thread $key -->"
if [ -z "${body//[[:space:]]/}" ]; then
body=$marker
else
body="$body
$marker"
fi
fi
data=$(printf '%s' "$body" | jq -Rsc --arg t "$title" '{title: $t, description: .}')
if [ -n "$assignee" ]; then
# The user is looked up by name; a name GitLab does not know, or knows
# as another, assigns nobody, so it is no issue to open.
uid=$(api GET "$base/users?username=$assignee" |
jq -r --arg a "$assignee" '[.[] | select((.username // "" | ascii_downcase) == ($a | ascii_downcase))][0].id // empty') || exit 1
[ -n "$uid" ] || {
echo "assignee $assignee: GitLab knows no such user" >&2
exit 1
}
data=$(jq -c --argjson u "$uid" '. + {assignee_id: $u}' <<<"$data")
fi
api POST "$base/projects/${into//\//%2F}/issues" "$data" |
jq -c --arg in "$into" --arg a "$assignee" '
if (.iid // 0) <= 0 then error("the answer names no issue") else . end
| if $a != "" and ([.assignees[]?.username? | ascii_downcase] | index($a | ascii_downcase)) == null
then error("issue was created, but GitLab did not assign \($a); the account may not be allowed to assign in this project")
else . end
| ((.references.full // "") | split("#")[0]) as $full
| (if $full != "" then $full else $in end) as $project
| {id: "\($project)#\(.iid)", title: (.title // ""), url: (.web_url // "")}' ;;
opened) # posted's sibling for an open whose answer was lost: the key is
# carried in the description's marker. Newest first, bounded by since
# where the attempt was put on record (a minute earlier, for clocks that
# disagree); issues the bound leaves out cannot be it.
into=$(arg in)
key=$(arg key)
since=$(arg since)
valid_project "$into" || {
echo "--in $into: want group/project" >&2
exit 1
}
q="state=all&order_by=created_at&sort=desc"
if [[ $since =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]]; then
since=$(TZ=UTC jq -n -r --arg s "$since" \
'try ((($s | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime) - 60 | strftime("%Y-%m-%dT%H:%M:%SZ"))) catch $s')
q="$q&created_after=$since"
fi
pages "$base/projects/${into//\//%2F}/issues?$q" |
jq -c --arg m "<!-- yardr-thread $key -->" --arg in "$into" '
[.[] | select((.description // "") | contains($m))][0]
| if . == null then {}
else ((.references.full // "") | split("#")[0]) as $full
| (if $full != "" then $full else $in end) as $project
| {id: "\($project)#\(.iid)", title: (.title // ""), url: (.web_url // "")}
end' ;;
state) # started is open, done is closed, for an issue and for a merge
# request. A merge request that is merged has said all that done can
# say, and is not reopened. A label:<name> adds the label, to a merge
# request too, and nothing is ever removed.
thread=$(arg thread)
state=$(arg state)
forward=$(arg forward)
split "$thread" || exit 1
# The word first: nothing is asked of GitLab for one it has no status for.
case "$state" in
label:*)
want=label
name=${state#label:}
[ -n "$name" ] || {
echo "\"$state\" is no label: want label:<name>" >&2
exit 1
}
# The API takes labels as one comma-separated string.
case "$name" in
*,*)
echo "\"$name\" is no label GitLab can add by name: it has a comma" >&2
exit 1
;;
esac
;;
started) want=opened ;;
done) want=closed ;;
review)
echo "an issue or merge request has no review status: the states it has are started and done" >&2
exit 1
;;
*)
echo "\"$state\" is no state here: want started, done" >&2
exit 1
;;
esac
item=$(api GET "$base/projects/$pid/$kind/$n") || exit 1
# now is GitLab's word for where it stands: opened, closed, and for a
# merge request merged, or locked while it is being merged.
now=$(jq -r '.state // ""' <<<"$item")
case "$now" in
opened | closed | merged | locked) ;;
*)
echo "the answer names no state for $thread" >&2
exit 1
;;
esac
if [ "$want" = label ]; then
# A label has no order to stand in: it is added wherever the issue
# or merge request stands, unless it is there.
has=$(jq -r --arg l "$name" '[.labels[]? | select(type == "string")] | map(select(ascii_downcase == ($l | ascii_downcase))) | .[0] // ""' <<<"$item")
if [ -n "$has" ]; then
jq -n -c --arg s "labeled $has" '{status: $s, moved: false}'
exit 0
fi
api PUT "$base/projects/$pid/$kind/$n" "$(jq -n -c --arg l "$name" '{add_labels: $l}')" >/dev/null || exit 1
jq -n -c --arg s "labeled $name" '{status: $s, moved: true}'
exit 0
fi
# standing: opened stands at started; closed, merged and locked have all
# that done can say.
if [ "$now" = opened ]; then standing=started; else standing="done"; fi
if [ "$forward" = true ]; then
# A stage map's move, which never moves backwards: an opened one
# stands at started, which is all that can be said of it, and a closed
# or merged one is never reopened.
if [ "$state" = started ] || [ "$standing" = "done" ]; then
jq -n -c --arg s "$now" '{status: $s, moved: false}'
exit 0
fi
elif [ "$now" = "$want" ] || { [ "$want" = closed ] && { [ "$now" = merged ] || [ "$now" = locked ]; }; }; then
# thread state moves exactly: only a thread already in the status
# stays. A merge request that is merged, or locked for merging, is past
# closed.
jq -n -c --arg s "$now" '{status: $s, moved: false}'
exit 0
elif [ "$want" = opened ] && { [ "$now" = merged ] || [ "$now" = locked ]; }; then
echo "$thread is $now, and a merge request that is $now is not reopened" >&2
exit 1
fi
if [ "$want" = closed ]; then event=close; else event=reopen; fi
moved=$(api PUT "$base/projects/$pid/$kind/$n" "$(jq -n -c --arg e "$event" '{state_event: $e}')") || exit 1
# An answer that says it is not where it was sent is no move.
[ "$(jq -r '.state // ""' <<<"$moved")" = "$want" ] || {
echo "GitLab answered, but $thread is not $want" >&2
exit 1
}
jq -n -c --arg s "$want" --arg was "$now" '{status: $s, moved: true, was: $was}' ;;
*) echo "unknown: $1" >&2; exit 2 ;;
esac
# GitLab as a declared source, in the shape of yardr's GitHub: how the core
# verifies a GitLab delivery, and the command that turns a verified body
# into the envelope; what the yard does in a GitLab thread, by one command
# per thing; how it reads a merge request and its CI back; GitLab's issue
# and merge request URLs as a bead's link; and what an agent may ask of it.
#
# Two slices made it. The first is hearing and speaking, issues and merge
# requests alike. The second is reading a merge request and its pipelines
# back (the `events` key) and the calls an agent makes (`calls`, and the
# pack's presets). What is not here: git hosting for a depot whose origin is
# on gitlab.com (a `git` credential helper, as GitHub's), and group
# webhooks.
#
# Checked against GitLab's own docs on 2 November 2026 (docs.gitlab.com,
# master): user/project/integrations/webhooks.md and webhook_events.md for
# the headers and the issue, merge request, comment and pipeline payloads;
# api/notes.md, api/issues.md, api/merge_requests.md and api/rest/_index.md
# for what the thread commands send; api/merge_requests.md, api/pipelines.md,
# api/jobs.md, api/projects.md and api/issues.md for what the merge request
# reads and the calls send; user/profile/personal_access_tokens.md and
# security/tokens/access_token_scopes.md for the credential.
#
# A command is a list, run without a shell. A path that does not start
# with / is a file beside this one, which `yardr source add` copies into
# the yard.
name: gitlab
about: GitLab issues and merge requests. Hears its webhooks and speaks in its threads.
# The name of the variable, never the value. A personal access token with
# the scope api: a token that may only read (read_api) cannot reply, open
# or close anything. A command gets it in its environment under this name,
# and gets no other variable of the yard. hook.normalize is handed no
# credential at all. GitLab takes a personal access token in a
# PRIVATE-TOKEN header.
credential:
env: GITLAB_TOKEN
header: "PRIVATE-TOKEN: {credential}"
# ---- what it sends us ----------------------------------------------------
hook:
# Adapter.Verify stays data, checked by the core: the secret is handed to
# no command, and an unverified body reaches none. GitLab's webhook has
# a secret token, which it sends as it is in X-Gitlab-Token, so the
# header is compared as it arrives. The delivery is told by
# Idempotency-Key, which GitLab keeps the same across a delivery's
# retries (GitLab 17.4 on; before, no id is sent and a retry is told by
# its body).
verify:
scheme: secret
signature: { header: X-Gitlab-Token }
delivery: { header: Idempotency-Key }
# Per hook, not per source: GitLab sends an event's body to every webhook
# of the project, and each hook records its own.
bodies: unique-per-hook
# GitLab's deliveries follow activity, not a rhythm: an idle hour after
# the last comment is no break, so the quiet check does not time them.
untimed: true
# Adapter.Normalize. stdin {"headers":{…},"body":{…}}, stdout the
# envelope. The core sets source and delivery itself, and derives the
# envelope's train from its head_branch, which the command leaves out.
normalize: [gitlab/normalize]
# The keys its events' fields may carry: a subscription's --where may
# name them beside type, action, team, project, repo and label. sender is
# the lowercased username of whoever caused the event, which hear below
# matches by identity; noteable is what a comment is on (issue,
# merge_request, commit or snippet); conclusion is how a pipeline ended,
# in the words the core judges CI by (success, failure, cancelled or
# skipped), and is none while it runs.
keys: [number, state, noteable, conclusion, head_branch, head_sha, head_repo, base, train, sender]
# ---- what it sends about merge requests and CI ----------------------------
# Which envelopes are CI runs, which tell the merge request's head commit,
# and the commands that read the merge request and its CI back. The daemon
# judges a run against the head the last head_from event carried, or the
# head the pr command reads live, and reads CI as state for a train whose
# hook's table has an event=ci line for it; a train's brief carries the ci
# command's snapshot. Both run as a thread command does, with the
# credential under GITLAB_TOKEN. gitlab/mr is the script, with the two as
# its commands. The shapes are in docs/integrations.md.
#
# A pipeline is GitLab's run of CI, and the merge request's own events tell
# its head (last_commit). The known gap below holds here as for `says` lines:
# an event for merge request !7 may find the bead that tracks issue #7.
events:
runs: [pipeline] # the types that are CI runs, whatever became of them
head_from: [merge_request] # the types that tell the merge request's head
pr: [gitlab/mr, pr] # in {repo, number, url} out {head, base, state}
ci: [gitlab/mr, ci] # in {repo, number, sha} out {head, base, state, required, checks}
# ---- who is heard ---------------------------------------------------------
# A delivery is kept when its sender is a user of the yard, matched by
# identity: fields.sender holds the sender's username, lowercased, which
# normalize also sets as actor.id. The core adds fields.speaker for a
# kept delivery (the yard user's name, or outside where the hook's
# --unknown word allows an unmapped sender); speaker is the derived
# filter key for this hear, and stands in no hook.keys above, which
# names only what normalize may set.
hear:
field: sender
by: identity
# ---- what the yard does in its threads ------------------------------------
# One command per thing, each handed its object on stdin and the
# credential under GITLAB_TOKEN, with YARDR_SOURCE_BASE from base below.
# gitlab/thread is the script: reply posts a note on the issue or merge
# request with the key in a marker comment, posted finds what a key left
# ({} for none), read lists the thread's notes oldest first, open starts an
# issue in the project in, opened finds what an open left ({} for none:
# posted's sibling for open), and state moves to what the word means:
# exactly, or never backwards with forward (a stage map's move). A thread
# is GitLab's own reference to it: group/project#12 is an issue and
# group/project!12 a merge request, and the one script serves both, told
# apart by that character. The shapes are in docs/integrations.md.
thread:
reply: [gitlab/thread, reply] # in {thread, parent, body, key} out {id, url}
posted: [gitlab/thread, posted] # in {thread, key} out {id, url} or {}
read: [gitlab/thread, read] # in {thread} out {messages: [...]}
place: project # what open's in names
open: [gitlab/thread, open] # in {in, title, body, key, assignee?} out {id, title, url}
assignee: key # the assignee open takes: a username
opened: [gitlab/thread, opened] # in {in, key, since?} out {id, title, url} or {}
state: [gitlab/thread, state] # in {thread, state, forward?} out {status, moved, was?}
states: [started, done] # the words it has a status for: an issue or merge request is open or closed
labels: true # and a label:<name> adds the label, to any thread
# ---- who a crew member is in it -------------------------------------------
# GitLab usernames are the same in any case, and its deliveries carry them
# as they are written: normalize lowercases them, and a key is kept
# lowercase however it was typed.
identity:
fold: lower
# ---- its issues and merge requests as a bead's link -----------------------
# The host its URLs are on, what reads an issue from a URL's path, and over
# the groups: the whole project path (its namespace may nest to any depth)
# and the number. The issue's one fixed form puts the /-/ GitLab writes now
# before issues; the old form without it reads too. A merge request is the
# second shape, over the same groups in the same order. Only gitlab.com is
# named: a self-managed instance has a host of its own, which the file
# cannot list (SETUP.md says what to change).
#
# GitLab numbers its issues and its merge requests apart: issue #7 and
# merge request !7 are two things. The core reads the two shapes as one
# numbered thread, so it takes either for the other (README.md, "A known
# gap").
link:
host: gitlab.com
issue: '^/((?:[A-Za-z0-9_.][A-Za-z0-9_.-]*/)+[A-Za-z0-9_.][A-Za-z0-9_.-]*)/(?:-/)?issues/([1-9][0-9]*)(?:/.*)?$'
fixed: "https://gitlab.com/{1}/-/issues/{2}"
thread: "{1}#{2}"
fold: lower
pull:
issue: '^/((?:[A-Za-z0-9_.][A-Za-z0-9_.-]*/)+[A-Za-z0-9_.][A-Za-z0-9_.-]*)/(?:-/)?merge_requests/([1-9][0-9]*)(?:/.*)?$'
fixed: "https://gitlab.com/{1}/-/merge_requests/{2}"
thread: "{1}!{2}"
repos: '(?:[A-Za-z0-9_.][A-Za-z0-9_.-]*/)+[A-Za-z0-9_.][A-Za-z0-9_.-]*'
# ---- what an agent may ask of it, through `yardr call` -------------------
# Reads of GitLab's REST API as the account of the credential, and nothing
# written: each is a GET, granted to no one until the operator grants it
# (SETUP.md; the pack's `read` preset names them all). The credential
# needs the scope read_api for them, and has api for the thread commands.
#
# project is a project as GitLab names it in a path: group/project, with
# subgroups to any depth, or its number. It is one segment of the request's
# path, so the slashes go as %2F. The pattern lets no two dots stand
# together: the core refuses a segment that is only "..", and not one that
# holds it among escaped slashes. iid is the number of an issue or merge
# request in its project, its internal id. GitLab numbers issues and merge
# requests apart, so the two calls are told apart by name, not by the number.
#
# The lists ask for 20 of the newest by default, and for any page after
# (GitLab answers at most 100 to a page). The answers keep the fields an
# agent reads and leave out the many a call returns that it does not; a
# list leaves out descriptions, which the call for one has.
#
# It names no check call on purpose: `yardr source check` looks at the
# credential and at the thread commands above, and sends nothing.
#
# base is the REST API every thread command and every call sends to, as
# YARDR_SOURCE_BASE.
base: https://gitlab.com/api/v4
calls:
user:
kind: read
about: Who the credential acts as.
request: { method: GET, path: /user }
answer: { select: [username, name] }
project:
kind: read
about: Read a project, its default branch and merge settings.
params:
project: { type: string, pattern: '^(?:[0-9]+|(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*)+)$', max: 255, log: true }
request: { method: GET, path: "/projects/{project}" }
answer: { select: [id, name, path_with_namespace, description, default_branch, visibility, web_url, archived, created_at, last_activity_at, open_issues_count, issues_enabled, merge_requests_enabled, only_allow_merge_if_pipeline_succeeds, allow_merge_on_skipped_pipeline, only_allow_merge_if_all_discussions_are_resolved, merge_method, topics] }
merge_request:
kind: read
about: Read a merge request by its number, with its branches, head commit, head pipeline and merge status.
params:
project: { type: string, pattern: '^(?:[0-9]+|(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*)+)$', max: 255, log: true }
iid: { type: string, pattern: "^[1-9][0-9]{0,9}$", log: true }
request: { method: GET, path: "/projects/{project}/merge_requests/{iid}" }
# The description is read whole, up to what a call may raise its bound to.
answer: { select: [iid, title, description, state, draft, source_branch, target_branch, source_project_id, target_project_id, sha, author, assignees, reviewers, labels, milestone, created_at, updated_at, merged_at, closed_at, detailed_merge_status, has_conflicts, blocking_discussions_resolved, user_notes_count, merge_when_pipeline_succeeds, squash, head_pipeline, references, web_url], max: 262144 }
pipelines:
kind: read
about: List a project's pipelines, newest first, of a branch or a commit or in a status.
params:
project: { type: string, pattern: '^(?:[0-9]+|(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*)+)$', max: 255, log: true }
ref: { type: string, max: 255, optional: true }
sha: { type: string, pattern: "^[0-9a-fA-F]{7,64}$", optional: true }
status: { type: enum, values: [created, waiting_for_resource, preparing, waiting_for_callback, pending, running, success, failed, canceling, canceled, skipped, manual, scheduled], optional: true }
limit: { type: int, default: 20 }
page: { type: int, default: 1 }
request:
method: GET
path: "/projects/{project}/pipelines"
query: { ref: "{ref}", sha: "{sha}", status: "{status}", order_by: id, sort: desc, per_page: "{limit}", page: "{page}" }
answer: { select: [id, iid, project_id, status, source, ref, sha, name, web_url, created_at, updated_at], max: 262144 }
issues:
kind: read
about: List a project's issues, newest first, by state, labels, author or words in the title and description.
params:
project: { type: string, pattern: '^(?:[0-9]+|(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*)+)$', max: 255, log: true }
state: { type: enum, values: [opened, closed, all], default: opened }
labels: { type: string, max: 255, optional: true } # comma-separated: the issue has all of them
author: { type: string, pattern: "^[A-Za-z0-9_.-]{1,255}$", optional: true }
search: { type: string, max: 255, optional: true }
limit: { type: int, default: 20 }
page: { type: int, default: 1 }
request:
method: GET
path: "/projects/{project}/issues"
query: { state: "{state}", labels: "{labels}", author_username: "{author}", search: "{search}", order_by: created_at, sort: desc, per_page: "{limit}", page: "{page}" }
answer: { select: [iid, title, state, labels, author, assignees, milestone, created_at, updated_at, closed_at, due_date, confidential, issue_type, user_notes_count, merge_requests_count, web_url, references], max: 262144 }
issue:
kind: read
about: Read an issue by its number, with its description.
params:
project: { type: string, pattern: '^(?:[0-9]+|(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\.[A-Za-z0-9_-])*)+)$', max: 255, log: true }
iid: { type: string, pattern: "^[1-9][0-9]{0,9}$", log: true }
request: { method: GET, path: "/projects/{project}/issues/{iid}" }
answer: { select: [iid, title, description, state, labels, author, assignees, milestone, created_at, updated_at, closed_at, due_date, confidential, issue_type, user_notes_count, merge_requests_count, web_url, references], max: 262144 }
# GitLab
GitLab issues and merge requests as the declared source `gitlab`. A project
webhook delivers issues, merge requests, comments and pipelines to the yard,
and the yard answers in those threads: it replies, opens an issue, and closes
or reopens an issue or a merge request. It also reads a merge request and its
CI back, and an agent can ask GitLab questions through declared reads.
Nothing here is compiled into yardr; apply it with:
```sh
yardr pack apply /Users/mckean/projects/repos/yardr-packs/gitlab
```
from main of this repository. SETUP.md is the operator's steps.
This is the second slice. The first heard GitLab and spoke in its threads.
This one reads a merge request and its pipelines back (the `events` key) and
declares the calls an agent makes (`calls`, and the `read` preset). Running
the pack against a real GitLab is the next one.
## How it hangs together
The hook in `gitlab.yaml` compares the secret token GitLab sends in
`X-Gitlab-Token` as it arrives, tells a retry by `Idempotency-Key`, and
drops a body it has seen before. The dedupe is per hook, because GitLab
sends an event's body to every webhook of the project.
`gitlab/normalize` turns each verified body into the envelope.
The sender is matched by identity, `hear: {field: sender, by: identity}`. A
delivery is kept when its sender is a user of the yard, and the hook's
`--unknown` word says what an unmapped sender gets. Usernames are the same
in any case, so the identity folds to lowercase, as GitHub's logins do.
`gitlab/thread` is the yard's side. One script serves issues and merge
requests. A thread is GitLab's own reference to one, `group/project#12` for
an issue and `group/project!12` for a merge request, and the character says
which. The project path may nest to any depth.
`gitlab/mr` reads a merge request back. Its `pr` command answers the head,
base and state of a merge request, and its `ci` command the state of its CI.
The declaration names them as `events.pr` and `events.ci`. Both only send
GETs.
## What it hears
| GitLab sends | `type` | `action` | thread | text |
|---|---|---|---|---|
| Issue Hook | `issue` | `open`, `close`, `reopen`, `update` | the issue | the description |
| Merge Request Hook | `merge_request` | `open`, `close`, `reopen`, `update`, `approval`, `approved`, `unapproval`, `unapproved`, `merge` | the merge request | the description |
| Note Hook on an issue or merge request | `note` | `create`, `update` | that issue or merge request | the comment |
| Note Hook on a commit or snippet | `note` | `create`, `update` | none | the comment |
| Pipeline Hook | `pipeline` | none | the merge request it ran for, if it ran for one | `pipeline <status> on <branch>` |
| any other hook | its `object_kind` | the action it carries, if any | none | none |
The thread's URL is the project's `web_url` and `/-/issues/<n>` or
`/-/merge_requests/<n>`, the same for a thread's own events and for its
comments, whose payloads carry the comment's URL and not the thread's. The
envelope's `url` is the comment's own for a note.
`actor.id` is the sender's username, lowercased, and it is also
`fields.sender`, which `hear` matches. The assignee is the first assignee's
username. A comment on a merge request carries the merge request's. A
comment on an issue carries ids alone, so it has none. `entity` is the
thread for an issue or merge request and `note-<id>` for a comment, so an
edit does not mention again the people it mentioned before.
The fields a subscription may filter on, beside `type`, `action`, `repo` and
`label`, are `number`, `state`, `noteable` for what a comment is on,
`sender`, and for merge requests and their comments `head_branch`,
`head_sha`, `head_repo` and `base`. `state` is GitLab's word: `opened`,
`closed`, `merged` or `locked`. A pipeline carries `conclusion`, and
`number`, `head_branch`, `head_sha`, `head_repo` and `base` where it ran for a
merge request. `train` is the core's to derive from `head_branch`, so no
fixture sets it, and the core adds `speaker`.
A pipeline is a run of CI (`events.runs`), and a merge request's own events
tell its head (`events.head_from`). GitLab sends a Pipeline Hook at each
change of a pipeline's status, so one pipeline makes several events. Only the
last has a `conclusion`, in the words the core judges CI by: `success`,
`failure`, `cancelled` or `skipped`. A pipeline that is created, pending,
running, blocked on a manual job or being canceled has none.
The thread of a pipeline is read from the merge request's own URL in the
payload, not from the project the pipeline ran in. For a merge request,
`repo` names the target project from that URL, so the core can match the
event to the merge request by repo and number. `head_repo` names the pipeline
project when the pipeline ran in the source fork; the core uses it to check
that the event's head belongs to the train's merge request. If the fork's
pipeline ran in the target project, the payload does not give the source
path, so `head_repo` is left out. A pipeline that ran for no merge request
keeps the pipeline project's `repo`, has no thread and no `number`; its
`head_branch` still finds a train.
A merged results or merge train pipeline has no `head_sha`: its commit is
one GitLab made of the merge request and its target branch, not the head, so
the core leaves the run alone ("no head_sha").
The hook drops a delivery whose sender is a crew member's own account (the
core's rule for the yard's echo, after `yardr crew identity`). So a pipeline
that ran as the yard's account is not an event, and neither is a merge request
event of its own. The `pr` and `ci` reads are what cover the yard's own merge
requests; events cover other people's.
A mention is an `@username` in the text, once each, in order, lowercased.
A group mention such as `@group/subgroup` is left out, because a group is not
a crew member. So are an `@name` in code and an email address. A comment on a
commit or snippet is read for its text and mentions, but it has no thread to
answer in.
A delivery of a kind this does not read is recorded with its type, action,
repo and sender, and never refused, because GitLab disables a webhook after
four failed answers in a row. A body that is no JSON object is refused with
400.
## What it says
| command | request | answer |
|---|---|---|
| `reply` | `POST /projects/<path>/issues/<n>/notes`, or under `merge_requests`, with the key as a marker comment in the body | `{id, url}` |
| `posted` | the same notes, a comment whose body holds the key's marker | `{id, url}` or `{}` |
| `read` | the same notes, oldest first, without GitLab's system notes | `{messages}` |
| `open` | `POST /projects/<path>/issues`, the key as a marker in the description; for an assignee, `GET /users?username=` first | `{id, title, url}` |
| `opened` | `GET /projects/<path>/issues?state=all`, newest first, bounded by `created_after` when `since` is given | `{id, title, url}` or `{}` |
| `state` | `GET` the issue or merge request, then `PUT` with `state_event` `close` or `reopen`, or `add_labels` | `{status, moved, was?}` |
GitLab takes no client-chosen id, so the marker is how a retry finds what an
earlier attempt left. The project path goes into the URL with its slashes
as `%2F`. Lists ask for 100 and stop on a short page, since gitlab.com can
leave out the headers that count pages.
## What it reads back
| command | request | answer |
|---|---|---|
| `pr` | `GET /projects/<path>/merge_requests/<n>` | `{head, base, state}` |
| `ci` | the same, then `GET /projects/<path>`, then the jobs of the head pipeline: `GET /projects/<id>/pipelines/<id>/jobs`, 100 to a page | `{head, base, state, required, checks}` |
The daemon asks `pr` for the head of a merge request when no event has told
it, and `ci` for a train whose hook has an `event=ci` line; the train's brief
carries the same snapshot. `head` is the merge request's `sha` and `base` its
`target_branch`. `state` is GitLab's word except for `opened`, which becomes
`open` because the core asks whether the state is `open`; `closed`, `merged`
and `locked` pass as they are. `pr` also takes the merge request's address in
`url` when `repo` and `number` are not given. Either command fails, with
GitLab's own message, when a request fails or the answer holds no head or
base. The core logs that and asks again later.
GitLab keeps no list of the checks a branch requires. It gates a merge on the
head pipeline of the merge request, and only where the project requires
pipelines to succeed (`only_allow_merge_if_pipeline_succeeds`). So `ci`
reports the pipeline as one check named `pipeline`, and `required` is
`["pipeline"]` where the project requires it and empty where it does not. The
jobs of the pipeline, the latest attempt of each, follow as checks with the
names they have in GitLab. A project with nothing required is never called
green, and a red check still counts. A project answer without those settings
counts as one with nothing required.
The checks speak the tongue the core judges: completed or not, and a
completed one concluded `success`, `neutral`, `skipped`, `failure` or
`cancelled`.
| GitLab says | status | conclusion |
|---|---|---|
| `success` | `completed` | `success` |
| `failed` | `completed` | `failure`, or `neutral` for a job that GitLab lets fail |
| `canceled` | `completed` | `cancelled`, which the core reads as not run yet |
| `skipped` | `completed` | `skipped`. For the pipeline, only where the project lets a skipped pipeline merge (`allow_merge_on_skipped_pipeline`); otherwise `status` is `skipped`, which the core also reads as not run |
| any other (`created`, `pending`, `running`, `manual`, ...) | GitLab's word | none |
The checks are those of the head pipeline, and only when the merge request's
head is the commit asked about and the pipeline ran on it, or is a merged
results pipeline of the merge request (its `ref` is
`refs/merge-requests/<n>/merge`). For any other commit there are none, and
the core waits ("pipeline (not reported)"). The jobs are read from the
project the pipeline names, since a fork's pipeline runs in the fork; a token
that cannot read that project fails the read. Trigger jobs, the ones that
start a downstream pipeline, are not listed as checks. The core takes an
answer of 64 KB at most. A check is about
150 bytes, so a pipeline of more than 400 jobs makes an answer the core
refuses, and the read fails.
## What an agent may ask
Six reads, all GET, which an agent makes with `yardr call gitlab <call>` once
the operator grants them:
| call | parameters | request |
|---|---|---|
| `user` | none | `GET /user` |
| `project` | `project` | `GET /projects/<project>` |
| `merge_request` | `project`, `iid` | `GET /projects/<project>/merge_requests/<iid>` |
| `pipelines` | `project`; optional `ref`, `sha`, `status`; `limit` (20), `page` (1) | `GET /projects/<project>/pipelines`, newest first |
| `issues` | `project`; optional `labels`, `author`, `search`; `state` (`opened`, `closed` or `all`; opened), `limit` (20), `page` (1) | `GET /projects/<project>/issues`, newest first |
| `issue` | `project`, `iid` | `GET /projects/<project>/issues/<iid>` |
`project` is the path of a project, `group/project` with subgroups to any
depth, or its number. Its slashes go into the request as `%2F`. The pattern
refuses two dots in a row, which the core's own check does not: it refuses a
path segment that is only `..`, and not one that holds it among escaped
slashes. `iid` is the number of an issue or merge request in its project.
GitLab numbers the two apart, so the calls are told apart by name.
Each answer keeps the fields an agent reads and drops the rest, so a field
the call does not name never reaches the agent. A list leaves out
descriptions, which `issue` and `merge_request` carry. `merge_request`
includes the head pipeline with its status, which answers most questions about
CI. The pack's `read` preset names all six. `yardr pack show gitlab` prints
it, and SETUP.md grants it by name, because yardr's `--preset` takes only the
presets of packs inside its binary.
## Where it differs from GitHub's pack
- **Quick actions.** GitLab's notes API runs a line that starts with a slash
as a quick action, such as `/close`, `/assign` or `/approve`, and drops the
line from the text. The same holds for an issue's description. So what an
agent quotes from an issue could close, assign or approve as the yard. A
reply and a description are posted with one space before any line that
starts with a slash, which renders the same and runs nothing. It does this
inside code blocks too, since telling a closed fence from an open one is
GitLab's job and a wrong guess here would be a hole. Carriage returns go
first, because GitLab removes them before it looks.
- **Merge request status.** GitHub's pack refuses a pull request's status,
because it would close it unmerged. Here an issue and a merge request are
both open or closed. `done` closes, `started` reopens, and a merged merge
request stands at `done` and is never reopened. A stage map's move goes
forward only, so it never reopens anything.
- **Replies are top-level.** A comment on an issue or merge request has no
parent in this API, as on GitHub. GitLab's discussions would thread
them, and that waits.
- **Order.** GitLab lists notes newest first by default, so `read` and
`posted` ask for oldest first.
- **Required checks.** GitHub lists the checks a branch requires, by name.
GitLab gates on the head pipeline as a whole, so `ci` names one required
check, `pipeline`, and only where the project asks for it. A job is never
required on its own.
- **Words for CI.** GitLab's pipeline hook carries a `status`, and GitHub's
run events an `action` and a `conclusion`. The pack derives `conclusion`
from the status in GitHub's words, so the `conclusion` part of a hook line
reads the same for both: `failed` becomes `failure`, `canceled` becomes
`cancelled`. A merge request's `opened` is said `open` to the core.
- **Merged results pipelines.** They run on a commit GitLab makes of the
merge request and its target branch, while GitHub reports its checks on the
head commit. Their events carry no `head_sha`, and `ci` accepts the head
pipeline when it is one.
- **No branch thread.** GitHub gives a run on no pull request the thread
`<repo>@<branch>`, so a bead made for a failed run is found by the next. A
GitLab pipeline on no merge request has no thread.
- **The token.** It goes in a `PRIVATE-TOKEN` header, to curl on its stdin
as a config line and never as an argument. A token with a character a
GitLab token never has is refused before anything is sent. An answer that
repeats the token has it cut out of the message. Request bodies go to curl
as files, not arguments, so a long reply fits.
## A known gap: issue 7 and merge request 7
GitLab numbers its issues and its merge requests apart. Issue #7 and merge
request !7 are two things in one project. The core reads the two link shapes
as one numbered thread. For an event on a thread no bead tracks, it also
looks for a bead that tracks the same number in the other shape. So a bead
that tracks issue #7 is found by an event on merge request !7 when nothing
tracks !7, and the other way round. A `says` line can then move the wrong
bead, a `makes` line for the merge request finds a tracker and makes
nothing, and the same goes for a bead that closed.
The `events` key meets the same gap. The thread of a pipeline is the merge
request's, so a run on merge request !7 is found by the bead that tracks issue
#7 when nothing tracks !7, and a `says` line can move that bead. Where the
bead has no head from an event, `pr` is asked for the issue's address, which
names no merge request, and the read fails and is logged.
The pack declares both shapes all the same. `yardr reply` and `yardr thread
state` check an event's thread URL against them, so without the merge
request shape both are refused for a merge request. A hook with no `says` or
`makes` lines is not touched by this. The fix is an option in the core, filed
as yardr-dmfh, and this section goes when it lands.
## The secret token and the signing token
The pack checks the secret token, as the first slice's bead asks. GitLab's
docs now recommend a signing token for new webhooks, from GitLab 19.0, and
call the secret token weaker: a plain value in a header, with no check of the
body and no age. The core already verifies that signature, so a variant is a
change to `hook.verify` in `gitlab.yaml` and no more. It is filed as
yardr-packs-3rgk. The secret token stays the default until it is decided,
since every GitLab version sends it, self-managed ones older than 19.0
included.
## Not yet checked against GitLab
Everything above is held to fixtures and to a scratch yard with a stand-in
for curl. Nothing has run against a real GitLab. The live run should check
these first:
- A note's `body` comes back from the API with the HTML comment markers
intact. `posted` and `opened` find a retry's work by them.
- A reply with a line that starts with `/close` shows its text, and does not
close the issue.
- GitLab resolves a project path in any case, since the thread commands send
it as a delivery spelled it, and `link` folds it to lowercase.
- An issue made with `assignee_id` on a Free project is assigned, and
`GET /users?username=` matches a username in any case.
- The answer to creating an issue carries `references.full`, which names the
thread's project as GitLab spells it.
- A retry or a resend from GitLab's Recent events page carries the same
`Idempotency-Key`, and the hook answers it as a duplicate.
- A Pipeline Hook for a merge request has `merge_request` with the merge
request's own `url`, and one for a branch with no merge request has it null
or left out. GitLab's documentation says neither.
- A pipeline run for a merge request has the head of the source branch as
`object_attributes.sha`. The documentation shows a sample with a branch name
for `ref`, and an API sample with `refs/merge-requests/<n>/merge` for a
merged results pipeline, whose `sha` is not the head. The pack reads the
second from that sample.
- The merge request's `head_pipeline` is present for the token (GitLab shows it
only to a user who can view pipelines), carries `project_id`, and is the
pipeline of the head commit a few seconds after a push. Until then `ci`
reports none.
- `GET /projects/<id>/pipelines/<id>/jobs?include_retried=false&per_page=100`
pages as the notes do, and lists the latest attempt of each job.
- The token's role sees `only_allow_merge_if_pipeline_succeeds` and
`allow_merge_on_skipped_pipeline` on the project. GitLab says the fields of
a project vary with the permissions.
- A project path with its slashes as `%2F` is read by GitLab in a call's path
as it is in the thread commands'. The test pins that yardr sends them so,
and nothing more.
- `labels=a%2Cb` is read as the labels `a` and `b`, and `search` and
`author_username` filter as the documentation says.
## Files
- `pack.yaml`, `gitlab.yaml`: the pack and its declaration.
- `gitlab/normalize`: POSIX sh and jq, the verified body to the envelope.
- `gitlab/thread`: bash, curl and jq, the thread commands.
- `gitlab/mr`: bash, curl and jq, the `pr` and `ci` reads.
- `testdata/hook/`: the webhook bodies and the envelopes made of them,
goldens beside them.
- `testdata/thread/` and `testdata/mr/`: one directory for each case of a
thread command or of `pr` and `ci`, with its input, the answers the stand-in
gives in order, and the requests and output expected. `_answers/` holds the
answers they share.
- `testdata/calls/`: one file for each case of a call, with its parameters, the
answer GitLab is made to give and the request and output expected.
- `testdata/core.sh`, `testdata/standin.py`, `testdata/declaration.golden`: the
part of the test that runs the real yardr, the stand-in for GitLab on a
loopback port, and the declaration as the yard reads it.
- `testdata/bin/curl`: the stand-in for curl.
- `testdata/link.txt`: paths for the link patterns and what each makes.
- `test`: runs all of it.
## Runtimes
`gitlab/normalize` wants POSIX sh and jq, as the other source packs do.
`gitlab/thread` and `gitlab/mr` want bash 3.2 or later, curl and jq. The gate
knows no language, so this is the pack's word for what its programs need.
The test wants python3 and yardr besides, for its last part.
## Testing
```sh
./test
```
in this directory. It feeds each webhook body to normalize and compares the
envelope with its golden byte for byte, and checks that the fields the
envelopes carry and `hook.keys` agree both ways. A table runs the successful
pipeline through each of the thirteen statuses GitLab lists, and through
bodies with a merge request that is missing, empty or not one. It runs each
thread case and each case of `pr` and `ci` against the stand-in curl, which
makes no request, records the method, URL, headers and body the real one
would have been asked for, answers from the case's fixtures and refuses any
use of curl the script does not make today. A changed request fails its case,
and `mr` is held to GET. A canary token is looked for in every argument,
output and record, and has to be on curl's stdin alone. The link patterns are
run on the paths in `testdata/link.txt` through jq's regex engine.
The last part runs the real yardr, in a scratch yard of its own under `env -i`,
with no agent and one listener, the stand-in for GitLab on a loopback port. It
holds the declaration as the yard reads it to a golden, runs the grant
commands SETUP.md teaches, and makes each call of `testdata/calls/` through
`yardr call`: the golden has the request as it was sent (an encoded slash
stays `%2F`, the query is sorted and escaped), whether the token came in
`PRIVATE-TOKEN`, and what the call kept of the answer. A parameter a call
refuses shows as no request. The token is looked for in every file the run
leaves. The depot's gate runs `yardr pack check` on this directory first.
`UPDATE=1 ./test` writes the goldens again from what the programs print. Read
the diff before keeping it.
## Left out
Each of these waits for a word from mckean: git hosting for a depot whose
origin is on gitlab.com, which wants a `git` credential helper like GitHub's;
group webhooks; links and bases for a self-managed instance, which its
operator writes into a copy; work items that are not issues, such as tasks,
incidents and epics; replies into a discussion's thread; the signing token
above.
Also not here, because the bead did not ask for it: a call that lists merge
requests, one that reads a pipeline's jobs, and the trigger jobs and external
status checks of a pipeline as checks; a thread for a pipeline that ran for no
merge request; and anything that writes beyond the first slice's threads.
# Setting up GitLab
The pack wires GitLab into the yard as the declared source `gitlab`. The
yard hears a project's issues, merge requests and comments through a
webhook, and answers in those threads: it replies, opens an issue, and
closes or reopens an issue or a merge request. What a stage means in
GitLab is a flow pack's to say, not this file's.
The pack also reads a merge request and its CI back, which the daemon
uses to judge a run against the head of the merge request and to read CI
as state for a train, and it declares reads an agent makes with `yardr
call`. The file names gitlab.com. Step 11 says what a self-managed
instance changes.
1. **Apply the pack.** From main of the yardr-packs repository, as an
operator. The pack runs two programs of its own as you, `gitlab/normalize`
and `gitlab/thread`, and they need `jq`, `curl` and `bash`.
```sh
yardr pack apply /Users/mckean/projects/repos/yardr-packs/gitlab
```
2. **The hook and its secret.** GitLab sends the secret back in the
`X-Gitlab-Token` header of every delivery, and the hook compares it as
it arrives. Make a long random one. This prints it once, so copy it for
step 3:
```sh
openssl rand -hex 32
```
The secret goes in a file, mode 0600, which the hook names. A new file
takes its mode from the shell's umask, so narrow it first. The secret is
never a command's word, because that line would stay in the shell's
history. Run the second command, paste the secret blind and press Enter:
```sh
umask 077
```
```sh
IFS= read -rs v && printf '%s' "$v" > ~/.yardr/gitlab.secret; unset v
```
```sh
chmod 600 ~/.yardr/gitlab.secret
```
```sh
yardr hook add gitlab --source gitlab --secret-file ~/.yardr/gitlab.secret --unknown unknown
# hook gitlab saved: POST /hooks/gitlab (gitlab)
```
`--unknown` says what the hook does with a sender no user maps. `unknown`
holds the delivery on a mapping card until the operator answers it.
`allow` hears the sender as an outsider, whose words reach an inbox as
information, not instructions, which suits a public project. `ignore`
drops them. One hook serves every crew member, and `yardr hook list`
shows it with STATE `ok`.
3. **The webhook, on the project.** In GitLab open the project, then
Settings, Webhooks, Add new webhook. Set the URL to
`https://<public host>/hooks/gitlab`, where the path ends in the hook's
name from step 2, and the Secret token to step 2's secret. Tick the
triggers Comments, Work item events, Merge request events and Pipeline
events. Older GitLab calls the second one Issues events. Pipeline events
tell the yard how a run ended; step 8 reads CI without them, so leave
that one off if no hook of yours has a line for a pipeline. Leave SSL
verification on.
Leave the confidential and internal triggers off. The yard answers with
an ordinary comment, which everyone who can read the thread sees, so it
should not hear what only members may read.
GitLab also offers a signing token and calls the secret token weaker.
This pack checks the secret token, which every GitLab version sends. It
takes project webhooks. A group webhook sends the same bodies, but
nothing here has run against one.
4. **The yard's token.** The yard acts in GitLab as one account. From that
account make a personal access token: the avatar, Edit profile, Access,
Personal access tokens, Generate token, Legacy token. Give it the scope
`api` and no other. A token with `read_api` can read but not write, so
every reply, opened issue and status change answers 403. By default
GitLab lets a token live a year at most. Put the expiry in a calendar,
because an expired token answers 401.
Have the crew member ask for it, and answer on its card, in footplate or
in `yardr chat` (`ctrl+o`), where the field shows dots:
```sh
yardr bead ask <bead> "The token it answers with" --secret gitlab --for-crew <name> --env GITLAB_TOKEN --context "In GitLab, from the account the yard acts as: avatar, Edit profile, Access, Personal access tokens, Generate token, Legacy token. Scope api, and an expiry within a year."
```
The yard's server writes it into the crew member's env file, or with none
set into `~/.yardr/crew/<name>.env`, which is then set as `yardr crew set`
sets it. The running session does not see the new value, so restart the
crew member after giving it. By hand it is yours instead. Narrow the
umask first, as in step 2. The token is never a command's word, so type
or paste it blind after the second command:
```sh
umask 077
```
```sh
IFS= read -rs v && printf 'GITLAB_TOKEN=%s' "$v" > ~/.yardr/crew/<name>.env; unset v
```
```sh
chmod 600 ~/.yardr/crew/<name>.env
```
```sh
yardr crew set <name> env_file=~/.yardr/crew/<name>.env
```
`yardr source check` looks over the credential and every thread command,
and sends nothing to GitLab:
```sh
yardr source set gitlab env_file=~/.yardr/crew/<name>.env
```
```sh
yardr source check gitlab
```
5. **Its identity.** The username of the account the token acts as, the
handle after the @ on its GitLab profile. A comment that says
`@<username>` mentions the crew member, and what the account writes
itself never reaches its inbox.
```sh
yardr crew identity <name> --source gitlab --key <username>
```
A crew member setting itself up proposes it instead, from its session,
and the operator approves the card:
```sh
yardr bead ask <bead> "Who am I on GitLab?" --run "crew identity <name> --source gitlab --key <username>"
```
A username is kept lowercase, however it was typed.
6. **Who is heard.** The pack hears by identity. A delivery is kept when its
sender is a user of the yard, and step 2's `--unknown` word says what
happens to anyone else. Map each person by their GitLab username. The
same key is whom the yard assigns an issue to when it opens one for them:
```sh
yardr user identity <user> --source gitlab --key <their username>
```
Then subscribe the crew member to what it should hear. Mentions are the
usual start:
```sh
yardr subscribe <name> gitlab --scope mentions
```
`--scope involved` adds the threads it takes part in, and `--scope all`
everything the hook lets through. `--where` narrows by `type`, `action`,
`repo`, `label`, `noteable`, `state`, `number`, `conclusion`,
`head_branch`, `base` and `speaker`. `conclusion` is how a pipeline
ended: `success`, `failure`, `cancelled` or `skipped`, and none while it
runs.
7. **The grants.** Opening issues and setting their status are the
operator's to grant:
```sh
yardr crew set <name> open_threads=gitlab set_state=gitlab
```
8. **The daemon's reads.** Judging a pipeline against the head of its
merge request, and reading CI as state for a train, run with the hook's
crew member's credential, read from the member's env file (step 4) as for
a move. Nothing ambient is tried. The hook acts as that member, and
adding the member re-saves the hook, so the source and secret go again:
```sh
yardr hook add gitlab --source gitlab --secret-file ~/.yardr/gitlab.secret --unknown unknown --as <name> \
--says "event=ci conclusion=success stage=in-pr -> done" \
--says "event=ci conclusion=failure stage=in-pr -> changes"
```
The lines are the flow's (`yardr docs line` writes them for its train
flow). A hook with an `event=ci` line and no `--as` is refused. A hook
with no such line may skip the `--as`: events still move beads, and only
the live reads need the member. The train's brief carries the same read
from the source's own env file, since the brief has no hook.
GitLab keeps no list of the checks a branch requires. It gates a merge on
the head pipeline of the merge request, and only where the project
requires pipelines to succeed (`only_allow_merge_if_pipeline_succeeds` in
GitLab's API). So `ci` reports the pipeline as one check named
`pipeline`, required when the project requires it, and the jobs of the
pipeline as checks beside it. Where the project does not require it,
nothing is required: the yard moves a bead on a red pipeline and never on
a green one.
The hook drops a delivery sent by a crew member's own account (step 5),
as it does for a comment. A pipeline that ran as the yard's account is
never an event, so the `pr` and `ci` reads are what cover the yard's own
merge requests.
9. **What an agent may ask.** The pack declares reads of GitLab, which an
agent makes with `yardr call gitlab <call>`: `project`, `merge_request`,
`pipelines`, `issues`, `issue` and `user`. `yardr call --list` shows what
a caller may call, and README.md has each call's parameters. No call is
granted until you grant it, and each reads as the account of the token, so
a grant lets that crew member read everything the account may. `yardr
pack show gitlab` prints the pack's suggestion, one preset, `read`.
yardr's `--preset` names the presets of the packs inside its binary and not
those of a pack applied from a directory, so the grant is by name: `read`
is every read call of the source, one a later file adds included. Grant
it to those who need it:
```sh
yardr source grant gitlab read --crew yardmaster
```
A group of agents takes `--group <group>` in place of `--crew <name>`. A
grantee who comes later gets one card:
```sh
yardr bead ask <bead> "Let the reviewers read GitLab" --run "source grant gitlab read --group reviewers" --context "The reviewers read the issues and pipelines of a merge request to check a bead's work; the card names each call it covers."
```
The calls only read, so a token with the scope `read_api` is enough for
them, though the thread commands need `api` (step 4). One caller makes 60
calls of a source in an hour. The operator raises the limit where the work
needs it:
```sh
yardr source set gitlab calls_per_hour=300
```
10. **Test it.** As the crew member, open an issue in a project the account
can write to. The project may sit in subgroups, as
`<group>/<subgroup>/<project>`:
```sh
yardr thread open gitlab --in <group>/<project> "Hello from the yard" -b "testing the wiring"
```
The answer names the thread, `<group>/<project>#<n>`. Comment
`@<username>` on the issue in GitLab and the comment lands in the crew
member's inbox as mentioned. Answer it by its number, then close the
issue and open it again:
```sh
yardr reply <seq> "Seen. Closing this."
```
```sh
yardr thread state <group>/<project>#<n> done
```
```sh
yardr thread state <group>/<project>#<n> started
```
A merge request is `<group>/<project>!<n>`. `done` closes it without
merging, `started` reopens a closed one, and one that is merged stays
merged. `label:<name>` adds a label to either and never removes one.
`yardr thread list <name>` shows the threads the crew member takes part
in.
To open an issue assigned to a person, map them in step 6 and name them.
GitLab Free takes one assignee. If GitLab makes the issue and drops the
assignee, the command says so and the issue stays:
```sh
yardr thread open gitlab --in <group>/<project> --assign <user> "For you"
```
To see what a grant gives an agent, make one call yourself. The operator
needs no grant, and the answer is printed as data from GitLab, not as
instructions:
```sh
yardr call gitlab merge_request project=<group>/<project> iid=<n>
```
11. **A self-managed instance.** The file names gitlab.com in four places. For
an instance at `gitlab.example.com`, copy the pack's directory, set
`base` to `https://gitlab.example.com/api/v4`, `link.host` to
`gitlab.example.com`, and the two `fixed` templates under `link` to the
same host. Apply the copy instead. A second instance is a second copy
under another source name, which also changes `name` in `pack.yaml` and
`gitlab.yaml`. The copy's tests expect gitlab.com, so update them or leave
them out of your copy.
12. **Ending hearing.** Remove the hook, then delete the webhook in GitLab
and revoke the token there:
```sh
yardr hook rm gitlab
```
Events the hook accepted are kept.
Two limits matter before the yard writes in a project, and README.md has
both in full. A line of a reply that starts with a slash is posted with one
space in front, because GitLab would run it as a quick action. And GitLab
numbers issues and merge requests apart while the core takes issue 7 and
merge request 7 for one thread, which matters once a hook has `says` or
`makes` lines.
#!/bin/sh
# The gitlab pack's tests: what the gate runs beside yardr pack check.
# Needs no network and no secret, done in seconds; needs jq and bash, which
# the pack's programs need, and python3 and yardr for the last part, which
# fails without them since a skipped part would leave the calls unpinned.
#
# normalize turns each webhook body in testdata/hook/ into the yard's
# envelope; its output must match the golden beside it byte for byte. The
# fields those envelopes carry and hook.keys must agree both ways. The
# thread commands and the merge request reads (gitlab/mr: pr and ci) run
# against a stand-in curl (testdata/bin/curl) that makes no request: it
# records what the real one would have been asked, and answers from the
# case's fixtures, so a changed request fails a case. The token is a canary,
# looked for in every argument, output and record the commands make. The
# link patterns of gitlab.yaml are run on the paths in testdata/link.txt.
#
# The core part (testdata/core.sh) runs the real yardr in a scratch yard of
# its own: the declaration as the yard reads it, held to a golden; the
# grants SETUP.md teaches, run as written; and each testdata/calls/*.case,
# one `yardr call` of the pack's source against a stand-in for GitLab on a
# loopback port (testdata/standin.py). A case's golden holds the requests
# the stand-in saw (method, path and query as sent, whether the credential
# was the right one), the exit status, what the call kept of the answer and
# what it said on stderr. A parameter the call should refuse shows as no
# request at all.
#
# UPDATE=1 ./test writes the goldens again from what the programs print;
# read the diff before keeping it.
set -u
cd "$(dirname "$0")" || exit 1
here=$(pwd)
fail=0
if ! command -v jq >/dev/null 2>&1; then
echo "test: jq is not on PATH: the pack's programs need it"
exit 1
fi
if ! command -v bash >/dev/null 2>&1; then
echo "test: bash is not on PATH: gitlab/thread needs it"
exit 1
fi
YARDR=${YARDR:-$(command -v yardr || echo /Users/mckean/go/bin/yardr)}
work=$(mktemp -d)
# The core part's scratch yard and stand-in (below) are made when its turn
# comes, and the yard's server, which its first command starts, would
# outlive the directory it owns: stop it first. core is empty until then.
core=
standin_pid=
cleanup() {
[ -z "${standin_pid:-}" ] || kill "$standin_pid" 2>/dev/null
if [ -n "${core:-}" ]; then
env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester server stop >/dev/null 2>&1
rm -rf "${core:?}"
fi
rm -rf "${work:?}"
}
trap cleanup EXIT
trap 'exit 1' HUP INT TERM
# differ <label> <golden> <actual>: succeeds, after saying how, when actual
# differs from the golden; fails when they are the same. Under UPDATE it
# writes the golden again and fails: nothing differs then.
differ() {
if [ -n "${UPDATE-}" ]; then cp "$3" "$2"; return 1; fi
if cmp -s "$3" "$2"; then return 1; fi
echo "test: $1 differs from $2:"
diff -u "$2" "$3" 2>&1 | sed 's/^/ /' | head -40
return 0
}
# check <name> <actual file> <golden file>: ok when they are the same.
check() {
if [ ! -e "$3" ] && [ -z "${UPDATE-}" ]; then
echo "test: $1 has no golden $3 (UPDATE=1 ./test writes it)"
fail=1
elif differ "$1" "$3" "$2"; then
fail=1
elif [ -z "${UPDATE-}" ]; then
echo "test: $1 ok"
fi
}
# ok <name> <condition's exit status> [detail]
ok() {
if [ "$2" = 0 ]; then echo "test: $1 ok"; else echo "test: $1 FAILED${3:+: $3}"; fail=1; fi
}
# ---- normalize ------------------------------------------------------------
# The fork pipeline fixture keeps the target repo in `repo` and its fork in
# `head_repo`, so the core can match the run to the right merge request.
for f in testdata/hook/*.json; do
g=${f%.json}.golden
case ${f##*/} in not-an-object.json) continue ;; esac
if [ ! -e "$g" ] && [ -z "${UPDATE-}" ]; then
echo "test: normalize $f has no golden ($g)"
fail=1
continue
fi
./gitlab/normalize <"$f" >"$work/env" 2>"$work/err"
rc=$?
if [ "$rc" != 0 ]; then
echo "test: normalize $f failed ($rc): $(head -c 300 "$work/err")"
fail=1
elif differ "normalize $f" "$g" "$work/env"; then
fail=1
else
echo "test: normalize $f ok"
fi
done
# A body that is no JSON object is refused: a non-zero exit, and nothing on
# stdout for the core to take as an envelope.
if ./gitlab/normalize <testdata/hook/not-an-object.json >"$work/env" 2>"$work/err"; then
echo "test: normalize testdata/hook/not-an-object.json was not refused"
fail=1
elif [ -s "$work/env" ]; then
echo "test: normalize testdata/hook/not-an-object.json printed an envelope while refusing"
fail=1
else
echo "test: normalize testdata/hook/not-an-object.json refused ok"
fi
# A pipeline's status becomes the word the core judges CI by once it has
# ended, and none while it runs. The statuses are the thirteen GitLab's
# pipelines API lists; the body is the successful pipeline above with each.
for pair in success:success failed:failure canceled:cancelled skipped:skipped \
created: waiting_for_resource: preparing: waiting_for_callback: pending: running: canceling: manual: scheduled:; do
st=${pair%%:*}
want=${pair#*:}
jq -c --arg s "$st" '.body.object_attributes.status = $s' testdata/hook/pipeline-mr-success.json |
./gitlab/normalize >"$work/env" 2>"$work/err"
got=$(jq -r '.fields.conclusion[0] // ""' "$work/env" 2>/dev/null)
if [ "$got" = "$want" ]; then
echo "test: normalize a pipeline $st has the conclusion '$want' ok"
else
echo "test: normalize a pipeline $st has the conclusion '$got', want '$want'"
fail=1
fi
done
# A pipeline that ran for no merge request, or for one the payload does not
# give whole, has no thread, and is still recorded with its head. Each edit
# is made to the successful pipeline above.
while IFS='@' read -r label edit want; do
[ -n "$label" ] || continue
jq -c "$edit" testdata/hook/pipeline-mr-success.json | ./gitlab/normalize >"$work/env" 2>"$work/err"
rc=$?
if [ "$rc" = 0 ] && [ "$(jq -c "$want" "$work/env" 2>/dev/null)" = true ]; then
echo "test: normalize a pipeline with $label ok"
else
echo "test: normalize a pipeline with $label ($rc): $(head -c 300 "$work/err") $(head -c 300 "$work/env")"
fail=1
fi
done <<'EOF'
no merge_request key@del(.body.merge_request)@.thread == {} and (.fields | has("number") | not)
a null merge_request@.body.merge_request = null@.thread == {}
an empty merge_request@.body.merge_request = {}@.thread == {}
a merge_request that is a string@.body.merge_request = "16"@.thread == {}
a merge_request without a number@.body.merge_request.iid = 0@.thread == {}
a merge_request without a url@.body.merge_request.url = ""@.thread == {} and .fields.number == ["16"]
a merge_request whose url is none of a merge request's@.body.merge_request.url = "https://gitlab.com/acme/platform/shed/-/issues/16"@.thread == {} and .fields.number == ["16"]
a tag@.body.object_attributes.tag = true | del(.body.merge_request)@(.fields | has("head_branch") | not) and .fields.head_sha == ["e59094b8de0f2f91abbe4760a52d9137260252d8"]
no user@del(.body.user)@(.actor == {}) and (.fields | has("sender") | not) and .fields.conclusion == ["success"]
no status@del(.body.object_attributes.status)@(.fields | has("conclusion") | not) and .text == "pipeline on yard/roof-mend"
no object_attributes@del(.body.object_attributes)@.type == "pipeline" and .url == "" and .thread.id == "acme/platform/shed!16"
EOF
# ---- the fields are the keys ---------------------------------------------
# A field normalize sets is one a subscription may name only when
# hook.keys lists it (or it is a common key), and a key the file lists
# that no envelope here sets is stale. train is the core's to derive from
# head_branch, so no fixture sets it.
keys=$(sed -n 's/^ keys: \[\(.*\)\]$/\1/p' gitlab.yaml | tr -d ' ' | tr ',' ' ')
common="type action team project repo label event"
seen=$(for g in testdata/hook/*.golden; do jq -r '.fields // {} | keys[]' "$g"; done | sort -u | tr '\n' ' ')
bad=0
for k in $seen; do
case " $keys $common " in *" $k "*) ;; *) echo "test: a field $k is set and is in no hook.keys"; bad=1 ;; esac
done
for k in $keys; do
case " $seen " in *" $k "*) ;; *) [ "$k" = train ] || { echo "test: hook.keys lists $k and no envelope sets it"; bad=1; } ;; esac
done
if [ "$bad" = 0 ]; then echo "test: fields and hook.keys agree"; else fail=1; fi
# ---- the thread commands, and the merge request reads --------------------
stand=$PWD/testdata/bin
canary=glpat-CANARYa1b2c3d4e5f6
base=https://gitlab.com/api/v4
# Prove the stand-in is what the commands would run, before anything runs.
if [ "$(PATH="$stand:$PATH" command -v curl)" != "$stand/curl" ]; then
echo "test: the stand-in curl is not the first curl on PATH"
exit 1
fi
# run <program> <cmd> <state dir> <case dir> <token>: gitlab/thread or
# gitlab/mr (thread's six commands, mr's pr and ci) in a clean environment:
# the stand-in first on PATH, a home and a temporary directory of its own,
# the token and the base, and the yard's variables not at all.
run() {
env -i PATH="$stand:$PATH" HOME="$3" TMPDIR="$3" GITLAB_TOKEN="$5" YARDR_SOURCE_BASE="$base" \
STANDIN_DIR="$4" STANDIN_LOG="$3/requests" STANDIN_ARGV="$3/argv" STANDIN_STATE="$3" \
"./gitlab/$1" "$2" <"$4/in.json" >"$3/out" 2>"$3/err"
}
# Each directory of testdata/thread and testdata/mr is one run of the
# command its name starts with (thread's reply-issue is gitlab/thread reply).
# The pagination answers intentionally lack trailing newlines: separate API
# response bodies before combining them, regardless of how curl writes them.
for prog in thread mr; do
for d in "testdata/$prog"/*/; do
name=$(basename "$d")
case $name in _*) continue ;; esac
cmd=${name%%-*}
d=testdata/$prog/$name
st=$work/$prog-$name
mkdir -p "$st"
: >"$st/requests"
: >"$st/argv"
run "$prog" "$cmd" "$st" "$d" "$canary"
rc=$?
ok=1
if [ -n "${UPDATE-}" ]; then
if [ "$rc" = 0 ]; then rm -f "$d/err.golden"; else rm -f "$d/out.golden"; cp "$st/err" "$d/err.golden"; fi
fi
differ "$prog $name requests" "$d/requests.golden" "$st/requests" && ok=0
if [ -e "$d/err.golden" ]; then
# A refusal: a non-zero exit, what it said on stderr, nothing on stdout.
[ "$rc" != 0 ] || { echo "test: $prog $name: expected a failure, got exit 0"; ok=0; }
[ ! -s "$st/out" ] || { echo "test: $prog $name: printed to stdout while failing: $(head -c 200 "$st/out")"; ok=0; }
differ "$prog $name stderr" "$d/err.golden" "$st/err" && ok=0
else
[ "$rc" = 0 ] || { echo "test: $prog $name: exit $rc: $(head -c 300 "$st/err")"; ok=0; }
[ ! -s "$st/err" ] || { echo "test: $prog $name: printed to stderr: $(head -c 300 "$st/err")"; ok=0; }
if [ -e "$d/out.jq" ]; then
jq -c -f "$d/out.jq" "$st/out" >"$st/view" 2>/dev/null || cp "$st/out" "$st/view"
else
cp "$st/out" "$st/view"
fi
differ "$prog $name stdout" "$d/out.golden" "$st/view" && ok=0
fi
# The token is on curl's stdin and nowhere else.
for f in argv out err requests; do
if grep -qF "$canary" "$st/$f"; then
echo "test: $prog $name: the token is in $f"
ok=0
fi
done
if [ "$ok" = 1 ]; then echo "test: $prog $name ok"; else fail=1; fi
done
done
# A merge request with a description of over a megabyte, more than a command
# may be given as an argument: ci reads it all the same, since it hands its
# answers to jq as files. The case is made here and not kept.
big=$work/big
mkdir -p "$big/_answers" "$big/ci-big" "$big/run"
head -c 1300000 /dev/zero | tr '\0' x >"$big/text"
jq -c --rawfile d "$big/text" '.description = $d' testdata/mr/_answers/mr-green.json >"$big/_answers/mr-big.json"
cp testdata/mr/_answers/project-gated.json testdata/mr/_answers/jobs-green.json "$big/_answers/"
cp testdata/mr/ci-green/in.json "$big/ci-big/in.json"
printf '%s\n' '200 _answers/mr-big.json' '200 _answers/project-gated.json' '200 _answers/jobs-green.json' >"$big/ci-big/answers"
: >"$big/run/requests"
: >"$big/run/argv"
if run mr ci "$big/run" "$big/ci-big" "$canary" 2>/dev/null &&
[ "$(jq -c '[.head, .required, (.checks | length)]' "$big/run/out")" = '["e59094b8de0f2f91abbe4760a52d9137260252d8",["pipeline"],3]' ]; then
echo "test: mr ci reads a merge request too big for an argument ok"
else
echo "test: mr ci on a merge request too big for an argument failed: $(head -c 300 "$big/run/err")"
fail=1
fi
# mr only reads: none of its cases made a request that is not a GET.
writes=$(cat "$work"/mr-*/requests 2>/dev/null | grep -E '^[A-Z]+ ' | grep -v '^GET ' | head -3)
if [ -z "$writes" ]; then echo "test: mr made no request but a GET"; else echo "test: mr made a request that is not a GET: $writes"; fail=1; fi
# A token GitLab's has no characters like is refused before anything is
# sent, and is not repeated.
for target in "thread reply testdata/thread/reply-issue" "mr pr testdata/mr/pr-open" "mr ci testdata/mr/ci-green"; do
# shellcheck disable=SC2086 # three words, on purpose
set -- $target
mkdir -p "$work/badtoken"
cp "$3/in.json" "$work/badtoken/in.json"
for t in '' 'bad"token' 'with space' 'back\slash'; do
st=$work/badtoken/run
rm -rf "$st"
mkdir -p "$st"
: >"$st/requests"
: >"$st/argv"
if run "$1" "$2" "$st" "$work/badtoken" "$t"; then
echo "test: $1 $2 with the token '$t' was not refused"
fail=1
elif [ -s "$st/requests" ]; then
echo "test: $1 $2 with the token '$t' sent a request"
fail=1
elif [ "$(cat "$st/err")" != "GITLAB_TOKEN is not set, or holds a character a GitLab token has none of" ]; then
echo "test: $1 $2 with the token '$t' said: $(head -c 300 "$st/err")"
fail=1
elif [ -n "$t" ] && grep -qF -- "$t" "$st/err"; then
echo "test: $1 $2 with the token '$t' repeated it"
fail=1
else
echo "test: $1 $2 with a bad token '$t' refused ok"
fi
done
done
# ---- the link patterns ----------------------------------------------------
# gitlab.yaml's link.issue, link.pull.issue and link.repos, run on paths
# through jq's regex engine (the core's is Go's; these patterns use what
# both read alike), with link.fold applied to the groups before the fixed
# and thread templates expand, as the core does.
yaml() { sed -n "s/^$1: '\\(.*\\)'\$/\\1/p" gitlab.yaml; }
tmpl() { sed -n "s/^$1: \"\\(.*\\)\"\$/\\1/p" gitlab.yaml; }
issue_re=$(yaml ' issue')
pull_re=$(yaml ' issue')
repos_re=$(yaml ' repos')
issue_fixed=$(tmpl ' fixed')
issue_thread=$(tmpl ' thread')
pull_fixed=$(tmpl ' fixed')
pull_thread=$(tmpl ' thread')
missing=0
for v in "$issue_re" "$pull_re" "$repos_re" "$issue_fixed" "$issue_thread" "$pull_fixed" "$pull_thread"; do
[ -n "$v" ] || missing=1
done
if [ "$missing" = 1 ]; then
echo "test: the link patterns are not where test looks for them in gitlab.yaml"
fail=1
else
nl=0
while IFS=' ' read -r kind path want; do
case $kind in '' | '#'*) continue ;; esac
case $kind in
issue) re=$issue_re fixed=$issue_fixed thread=$issue_thread ;;
pull) re=$pull_re fixed=$pull_fixed thread=$pull_thread ;;
repos) re=$repos_re fixed='' thread='' ;;
esac
got=$(jq -n -r --arg re "$re" --arg fixed "$fixed" --arg thread "$thread" --arg path "$path" --arg kind "$kind" '
if $kind == "repos" then (if ($path | test("^(?:" + $re + ")$")) then ($path | ascii_downcase) else "-" end)
else ([$path | match($re)] | if length == 0 then "-"
else (.[0].captures | map(.string | ascii_downcase)) as $g
| def fill($t): $t | gsub("\\{1\\}"; $g[0]) | gsub("\\{2\\}"; $g[1]);
fill($fixed) + " " + fill($thread) end) end')
if [ "$got" = "$want" ]; then
nl=$((nl + 1))
else
echo "test: link $kind $path: got '$got', want '$want'"
fail=1
fi
done <testdata/link.txt
echo "test: link patterns: $nl paths ok"
fi
# ---- the core: the declaration, the grants and the calls ----------------
# Through the real yardr, in a scratch yard (testdata/core.sh).
if ! command -v python3 >/dev/null 2>&1; then
echo "test: core FAILED: needs python3 on PATH (the stand-in for GitLab is one)"
fail=1
elif [ ! -x "$YARDR" ]; then
echo "test: core FAILED: needs yardr ($YARDR)"
fail=1
else
. ./testdata/core.sh
fi
if [ "$fail" = 0 ]; then echo "test: gitlab ok"; fi
exit $fail
#!/bin/bash
# A stand-in for curl, for the thread tests: it makes no request. It records
# what the real curl would have been asked, and answers from the case's
# fixtures, in order. thread uses curl in one way only,
#
# curl -sS -o FILE -w '%{http_code}' -K - -X METHOD [-H HEADER]
# [--data-binary @FILE] URL
#
# and any other word is refused, so a changed invocation fails the test
# instead of passing unseen. The token is to come on stdin as curl's
# config (-K -), never as an argument. The test gives it:
# STANDIN_DIR the case's directory: its answers file lists, one line per
# request in order, "STATUS FILE", FILE relative to the
# directory of the cases
# STANDIN_LOG where each request is written, the token cut out
# STANDIN_ARGV where every argument is written as it came, for the test
# to look for the token in
# STANDIN_STATE a directory of the stand-in's own, for its count
set -u
out='' fmt='' cfg='' method='' url='' data='' hdrs=''
printf '%s\n' "$@" >>"$STANDIN_ARGV"
while [ $# -gt 0 ]; do
case $1 in
-sS) ;;
-o) out=$2; shift ;;
-w) fmt=$2; shift ;;
-K) cfg=$2; shift ;;
-X) method=$2; shift ;;
-H) hdrs="$hdrs header: $2
"; shift ;;
--data-binary) data=$2; shift ;;
-*) echo "standin curl: does not know $1" >&2; exit 2 ;;
*)
[ -z "$url" ] || { echo "standin curl: two urls" >&2; exit 2; }
url=$1 ;;
esac
shift
done
[ "$cfg" = - ] || { echo "standin curl: the token is to come on stdin (-K -)" >&2; exit 2; }
[ "$fmt" = '%{http_code}' ] || { echo "standin curl: -w is to be the status code" >&2; exit 2; }
[ -n "$out" ] && [ -n "$method" ] && [ -n "$url" ] || { echo "standin curl: wants -o, -X and a url" >&2; exit 2; }
conf=$(cat)
{
echo "$method $url"
echo " config: ${conf//"$GITLAB_TOKEN"/<token>}"
printf '%s' "$hdrs"
if [ -n "$data" ]; then
case $data in
@*) echo " body: $(cat "${data#@}")" ;;
*) echo "standin curl: the data is to be a file (--data-binary @FILE)" >&2; exit 2 ;;
esac
fi
echo
} >>"$STANDIN_LOG"
n=$(cat "$STANDIN_STATE/n" 2>/dev/null || echo 0)
n=$((n + 1))
echo "$n" >"$STANDIN_STATE/n"
line=$(sed -n "${n}p" "$STANDIN_DIR/answers")
if [ -z "$line" ]; then
echo "standin curl: no answer $n for $method $url" >&2
exit 7
fi
status=${line%% *}
file=${line#* }
cp "$(dirname "$STANDIN_DIR")/$file" "$out"
printf '%s' "$status"
=== args
issue
project=acme/platform/shed
iid=7
=== answer
{"id":41,"iid":7,"project_id":14,"title":"The gutter leaks","description":"Water comes through when it rains.","state":"opened","created_at":"2026-10-30T08:00:00.000Z","updated_at":"2026-11-01T08:00:00.000Z","closed_at":null,"labels":["bug","roof"],"milestone":null,"assignees":[{"id":51,"username":"Ada","name":"Ada Lovelace","state":"active"}],"author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"type":"ISSUE","user_notes_count":2,"merge_requests_count":1,"upvotes":0,"downvotes":0,"due_date":null,"confidential":false,"web_url":"https://gitlab.com/acme/platform/shed/-/issues/7","references":{"short":"#7","relative":"#7","full":"acme/platform/shed#7"},"time_stats":{"time_estimate":0,"total_time_spent":0},"issue_type":"issue","_links":{"self":"https://gitlab.com/api/v4/projects/14/issues/7"},"subscribed":false}
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed/issues/7
auth: ok
--- status
0
--- stdout
{
"assignees": [
{
"id": 51,
"name": "Ada Lovelace",
"state": "active",
"username": "Ada"
}
],
"author": {
"id": 52,
"name": "Bob Builder",
"state": "active",
"username": "Bob"
},
"closed_at": null,
"confidential": false,
"created_at": "2026-10-30T08:00:00.000Z",
"description": "Water comes through when it rains.",
"due_date": null,
"iid": 7,
"issue_type": "issue",
"labels": [
"bug",
"roof"
],
"merge_requests_count": 1,
"milestone": null,
"references": {
"full": "acme/platform/shed#7",
"relative": "#7",
"short": "#7"
},
"state": "opened",
"title": "The gutter leaks",
"updated_at": "2026-11-01T08:00:00.000Z",
"user_notes_count": 2,
"web_url": "https://gitlab.com/acme/platform/shed/-/issues/7"
}
--- stderr
=== args issues project=acme/platform/shed author=bob&state=closed
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call issues: parameter author does not match its pattern ^[A-Za-z0-9_.-]{1,255}$"
}
--- stderr
=== args issues project=acme/platform/shed state=merged
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call issues: parameter state is one of opened, closed, all"
}
--- stderr
=== args
issues
project=acme/platform/shed
state=all
labels=bug,roof
author=Bob
search=leaky gutter
limit=50
=== answer
[{"id": 41, "iid": 7, "project_id": 14, "title": "The gutter leaks", "description": "Water comes through when it rains.", "state": "opened", "created_at": "2026-10-30T08:00:00.000Z", "updated_at": "2026-11-01T08:00:00.000Z", "closed_at": null, "labels": ["bug", "roof"], "milestone": null, "assignees": [{"id": 51, "username": "Ada", "name": "Ada Lovelace", "state": "active"}], "author": {"id": 52, "username": "Bob", "name": "Bob Builder", "state": "active"}, "type": "ISSUE", "user_notes_count": 2, "merge_requests_count": 1, "upvotes": 0, "downvotes": 0, "due_date": null, "confidential": false, "web_url": "https://gitlab.com/acme/platform/shed/-/issues/7", "references": {"short": "#7", "relative": "#7", "full": "acme/platform/shed#7"}, "time_stats": {"time_estimate": 0, "total_time_spent": 0}, "issue_type": "issue", "_links": {"self": "https://gitlab.com/api/v4/projects/14/issues/7"}},
{"id": 40, "iid": 6, "project_id": 14, "title": "Paint the door", "description": "Red.", "state": "opened", "created_at": "2026-10-29T08:00:00.000Z", "updated_at": "2026-10-29T08:00:00.000Z", "closed_at": null, "labels": [], "milestone": null, "assignees": [], "author": {"id": 51, "username": "Ada", "name": "Ada Lovelace", "state": "active"}, "type": "ISSUE", "user_notes_count": 0, "merge_requests_count": 0, "upvotes": 0, "downvotes": 0, "due_date": null, "confidential": false, "web_url": "https://gitlab.com/acme/platform/shed/-/issues/6", "references": {"short": "#6", "relative": "#6", "full": "acme/platform/shed#6"}, "time_stats": {"time_estimate": 0, "total_time_spent": 0}, "issue_type": "issue", "_links": {"self": "https://gitlab.com/api/v4/projects/14/issues/6"}}]
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed/issues?author_username=Bob&labels=bug%2Croof&order_by=created_at&page=1&per_page=50&search=leaky+gutter&sort=desc&state=all
auth: ok
--- status
0
--- stdout
[
{
"assignees": [
{
"id": 51,
"name": "Ada Lovelace",
"state": "active",
"username": "Ada"
}
],
"author": {
"id": 52,
"name": "Bob Builder",
"state": "active",
"username": "Bob"
},
"closed_at": null,
"confidential": false,
"created_at": "2026-10-30T08:00:00.000Z",
"due_date": null,
"iid": 7,
"issue_type": "issue",
"labels": [
"bug",
"roof"
],
"merge_requests_count": 1,
"milestone": null,
"references": {
"full": "acme/platform/shed#7",
"relative": "#7",
"short": "#7"
},
"state": "opened",
"title": "The gutter leaks",
"updated_at": "2026-11-01T08:00:00.000Z",
"user_notes_count": 2,
"web_url": "https://gitlab.com/acme/platform/shed/-/issues/7"
},
{
"assignees": [],
"author": {
"id": 51,
"name": "Ada Lovelace",
"state": "active",
"username": "Ada"
},
"closed_at": null,
"confidential": false,
"created_at": "2026-10-29T08:00:00.000Z",
"due_date": null,
"iid": 6,
"issue_type": "issue",
"labels": [],
"merge_requests_count": 0,
"milestone": null,
"references": {
"full": "acme/platform/shed#6",
"relative": "#6",
"short": "#6"
},
"state": "opened",
"title": "Paint the door",
"updated_at": "2026-10-29T08:00:00.000Z",
"user_notes_count": 0,
"web_url": "https://gitlab.com/acme/platform/shed/-/issues/6"
}
]
--- stderr
=== args
issues
project=acme/platform/shed
=== answer
[{"id": 41, "iid": 7, "project_id": 14, "title": "The gutter leaks", "description": "Water comes through when it rains.", "state": "opened", "created_at": "2026-10-30T08:00:00.000Z", "updated_at": "2026-11-01T08:00:00.000Z", "closed_at": null, "labels": ["bug", "roof"], "milestone": null, "assignees": [{"id": 51, "username": "Ada", "name": "Ada Lovelace", "state": "active"}], "author": {"id": 52, "username": "Bob", "name": "Bob Builder", "state": "active"}, "type": "ISSUE", "user_notes_count": 2, "merge_requests_count": 1, "upvotes": 0, "downvotes": 0, "due_date": null, "confidential": false, "web_url": "https://gitlab.com/acme/platform/shed/-/issues/7", "references": {"short": "#7", "relative": "#7", "full": "acme/platform/shed#7"}, "time_stats": {"time_estimate": 0, "total_time_spent": 0}, "issue_type": "issue", "_links": {"self": "https://gitlab.com/api/v4/projects/14/issues/7"}},
{"id": 40, "iid": 6, "project_id": 14, "title": "Paint the door", "description": "Red.", "state": "opened", "created_at": "2026-10-29T08:00:00.000Z", "updated_at": "2026-10-29T08:00:00.000Z", "closed_at": null, "labels": [], "milestone": null, "assignees": [], "author": {"id": 51, "username": "Ada", "name": "Ada Lovelace", "state": "active"}, "type": "ISSUE", "user_notes_count": 0, "merge_requests_count": 0, "upvotes": 0, "downvotes": 0, "due_date": null, "confidential": false, "web_url": "https://gitlab.com/acme/platform/shed/-/issues/6", "references": {"short": "#6", "relative": "#6", "full": "acme/platform/shed#6"}, "time_stats": {"time_estimate": 0, "total_time_spent": 0}, "issue_type": "issue", "_links": {"self": "https://gitlab.com/api/v4/projects/14/issues/6"}}]
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed/issues?order_by=created_at&page=1&per_page=20&sort=desc&state=opened
auth: ok
--- status
0
--- stdout
[
{
"assignees": [
{
"id": 51,
"name": "Ada Lovelace",
"state": "active",
"username": "Ada"
}
],
"author": {
"id": 52,
"name": "Bob Builder",
"state": "active",
"username": "Bob"
},
"closed_at": null,
"confidential": false,
"created_at": "2026-10-30T08:00:00.000Z",
"due_date": null,
"iid": 7,
"issue_type": "issue",
"labels": [
"bug",
"roof"
],
"merge_requests_count": 1,
"milestone": null,
"references": {
"full": "acme/platform/shed#7",
"relative": "#7",
"short": "#7"
},
"state": "opened",
"title": "The gutter leaks",
"updated_at": "2026-11-01T08:00:00.000Z",
"user_notes_count": 2,
"web_url": "https://gitlab.com/acme/platform/shed/-/issues/7"
},
{
"assignees": [],
"author": {
"id": 51,
"name": "Ada Lovelace",
"state": "active",
"username": "Ada"
},
"closed_at": null,
"confidential": false,
"created_at": "2026-10-29T08:00:00.000Z",
"due_date": null,
"iid": 6,
"issue_type": "issue",
"labels": [],
"merge_requests_count": 0,
"milestone": null,
"references": {
"full": "acme/platform/shed#6",
"relative": "#6",
"short": "#6"
},
"state": "opened",
"title": "Paint the door",
"updated_at": "2026-10-29T08:00:00.000Z",
"user_notes_count": 0,
"web_url": "https://gitlab.com/acme/platform/shed/-/issues/6"
}
]
--- stderr
=== args merge_request project=acme/platform/shed iid=0
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call merge_request: parameter iid does not match its pattern ^[1-9][0-9]{0,9}$"
}
--- stderr
=== args
merge_request
project=acme/platform/shed
iid=99
=== answer
{"message":"404 Not found"}
=== status
404
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/99
auth: ok
--- status
1
--- stdout
{
"error": "source gitlab, call merge_request: the service answered 404"
}
--- stderr
=== args
merge_request
project=acme/platform/shed
iid=16
=== answer
{"id": 93, "iid": 16, "project_id": 14, "title": "Mend the roof", "description": "Fixes the leak.", "state": "opened", "created_at": "2026-11-02T09:55:00.000Z", "updated_at": "2026-11-02T10:03:12.000Z", "target_branch": "main", "source_branch": "yard/roof-mend", "author": {"id": 52, "username": "Bob", "name": "Bob Builder", "state": "active"}, "source_project_id": 14, "target_project_id": 14, "labels": [], "draft": false, "work_in_progress": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "merge_commit_sha": null, "squash_commit_sha": null, "references": {"short": "!16", "relative": "!16", "full": "acme/platform/shed!16"}, "web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16", "head_pipeline": {"id": 538317940, "iid": 1877, "project_id": 14, "sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "ref": "yard/roof-mend", "status": "success", "source": "merge_request_event", "created_at": "2026-11-02T10:00:10.000Z", "updated_at": "2026-11-02T10:03:12.000Z", "web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538317940", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "detailed_status": {"icon": "status_success", "text": "passed", "label": "passed", "group": "success"}, "archived": false}, "diff_refs": {"base_sha": "1162f719d711319a2efb2a35566f3bfdadee8bab", "head_sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "start_sha": "1162f719d711319a2efb2a35566f3bfdadee8bab"}, "user": {"can_merge": true}}
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16
auth: ok
--- status
0
--- stdout
{
"author": {
"id": 52,
"name": "Bob Builder",
"state": "active",
"username": "Bob"
},
"created_at": "2026-11-02T09:55:00.000Z",
"description": "Fixes the leak.",
"detailed_merge_status": "mergeable",
"draft": false,
"head_pipeline": {
"archived": false,
"before_sha": "0000000000000000000000000000000000000000",
"created_at": "2026-11-02T10:00:10.000Z",
"detailed_status": {
"group": "success",
"icon": "status_success",
"label": "passed",
"text": "passed"
},
"id": 538317940,
"iid": 1877,
"project_id": 14,
"ref": "yard/roof-mend",
"sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"source": "merge_request_event",
"status": "success",
"tag": false,
"updated_at": "2026-11-02T10:03:12.000Z",
"web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538317940",
"yaml_errors": null
},
"iid": 16,
"labels": [],
"references": {
"full": "acme/platform/shed!16",
"relative": "!16",
"short": "!16"
},
"sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"source_branch": "yard/roof-mend",
"source_project_id": 14,
"state": "opened",
"target_branch": "main",
"target_project_id": 14,
"title": "Mend the roof",
"updated_at": "2026-11-02T10:03:12.000Z",
"web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"
}
--- stderr
=== args pipelines project=acme/platform/shed sha=main
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call pipelines: parameter sha does not match its pattern ^[0-9a-fA-F]{7,64}$"
}
--- stderr
=== args pipelines project=acme/platform/shed status=exploded
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call pipelines: parameter status is one of created, waiting_for_resource, preparing, waiting_for_callback, pending, running, success, failed, canceling, canceled, skipped, manual, scheduled"
}
--- stderr
=== args
pipelines
project=acme/platform/shed
ref=yard/roof-mend
sha=e59094b
status=failed
limit=5
page=2
=== answer
[{"id": 538317940, "iid": 1877, "project_id": 14, "status": "success", "source": "merge_request_event", "ref": "yard/roof-mend", "sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "name": "Pipeline for merge request !16", "web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538317940", "created_at": "2026-11-02T10:00:10.000Z", "updated_at": "2026-11-02T10:03:12.000Z", "yaml_errors": null},
{"id": 538310000, "iid": 1876, "project_id": 14, "status": "failed", "source": "push", "ref": "main", "sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", "name": "Pipeline for branch: main", "web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538310000", "created_at": "2026-11-02T09:00:10.000Z", "updated_at": "2026-11-02T09:01:12.000Z", "yaml_errors": null}]
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed/pipelines?order_by=id&page=2&per_page=5&ref=yard%2Froof-mend&sha=e59094b&sort=desc&status=failed
auth: ok
--- status
0
--- stdout
[
{
"created_at": "2026-11-02T10:00:10.000Z",
"id": 538317940,
"iid": 1877,
"name": "Pipeline for merge request !16",
"project_id": 14,
"ref": "yard/roof-mend",
"sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"source": "merge_request_event",
"status": "success",
"updated_at": "2026-11-02T10:03:12.000Z",
"web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538317940"
},
{
"created_at": "2026-11-02T09:00:10.000Z",
"id": 538310000,
"iid": 1876,
"name": "Pipeline for branch: main",
"project_id": 14,
"ref": "main",
"sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"source": "push",
"status": "failed",
"updated_at": "2026-11-02T09:01:12.000Z",
"web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538310000"
}
]
--- stderr
=== args
pipelines
project=acme/platform/shed
=== answer
[{"id": 538317940, "iid": 1877, "project_id": 14, "status": "success", "source": "merge_request_event", "ref": "yard/roof-mend", "sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "name": "Pipeline for merge request !16", "web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538317940", "created_at": "2026-11-02T10:00:10.000Z", "updated_at": "2026-11-02T10:03:12.000Z", "yaml_errors": null},
{"id": 538310000, "iid": 1876, "project_id": 14, "status": "failed", "source": "push", "ref": "main", "sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", "name": "Pipeline for branch: main", "web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538310000", "created_at": "2026-11-02T09:00:10.000Z", "updated_at": "2026-11-02T09:01:12.000Z", "yaml_errors": null}]
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed/pipelines?order_by=id&page=1&per_page=20&sort=desc
auth: ok
--- status
0
--- stdout
[
{
"created_at": "2026-11-02T10:00:10.000Z",
"id": 538317940,
"iid": 1877,
"name": "Pipeline for merge request !16",
"project_id": 14,
"ref": "yard/roof-mend",
"sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"source": "merge_request_event",
"status": "success",
"updated_at": "2026-11-02T10:03:12.000Z",
"web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538317940"
},
{
"created_at": "2026-11-02T09:00:10.000Z",
"id": 538310000,
"iid": 1876,
"name": "Pipeline for branch: main",
"project_id": 14,
"ref": "main",
"sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"source": "push",
"status": "failed",
"updated_at": "2026-11-02T09:01:12.000Z",
"web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538310000"
}
]
--- stderr
=== args project project=acme/../platform
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call project: parameter project does not match its pattern ^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$"
}
--- stderr
=== args project project=acme//shed
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call project: parameter project does not match its pattern ^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$"
}
--- stderr
=== args project project=acme/platform/shed extra=1
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call project: \"extra\" is no parameter of the call (it takes: project)"
}
--- stderr
=== args
project
project=14
=== answer
{"id": 14, "description": "The tool shed", "name": "Shed", "name_with_namespace": "Acme / Platform / Shed", "path": "shed", "path_with_namespace": "acme/platform/shed", "created_at": "2025-03-04T10:00:00.000Z", "default_branch": "main", "topics": ["tools", "shed"], "ssh_url_to_repo": "git@gitlab.com:acme/platform/shed.git", "http_url_to_repo": "https://gitlab.com/acme/platform/shed.git", "web_url": "https://gitlab.com/acme/platform/shed", "last_activity_at": "2026-11-02T10:03:12.000Z", "namespace": {"id": 9, "name": "Platform", "path": "platform", "kind": "group", "full_path": "acme/platform"}, "_links": {"self": "https://gitlab.com/api/v4/projects/14"}, "archived": false, "visibility": "private", "open_issues_count": 3, "issues_enabled": true, "merge_requests_enabled": true, "only_allow_merge_if_pipeline_succeeds": true, "allow_merge_on_skipped_pipeline": false, "only_allow_merge_if_all_discussions_are_resolved": false, "merge_method": "merge", "runners_token": "must-not-be-kept", "permissions": {"project_access": {"access_level": 30}}}
--- requests
GET /api/v4/projects/14
auth: ok
--- status
0
--- stdout
{
"allow_merge_on_skipped_pipeline": false,
"archived": false,
"created_at": "2025-03-04T10:00:00.000Z",
"default_branch": "main",
"description": "The tool shed",
"id": 14,
"issues_enabled": true,
"last_activity_at": "2026-11-02T10:03:12.000Z",
"merge_method": "merge",
"merge_requests_enabled": true,
"name": "Shed",
"only_allow_merge_if_all_discussions_are_resolved": false,
"only_allow_merge_if_pipeline_succeeds": true,
"open_issues_count": 3,
"path_with_namespace": "acme/platform/shed",
"topics": [
"tools",
"shed"
],
"visibility": "private",
"web_url": "https://gitlab.com/acme/platform/shed"
}
--- stderr
=== args project
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call project: parameter project is missing"
}
--- stderr
=== args project project=shed
--- requests
--- status
1
--- stdout
{
"error": "source gitlab, call project: parameter project does not match its pattern ^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$"
}
--- stderr
=== args
project
project=acme/platform/shed
=== answer
{"id": 14, "description": "The tool shed", "name": "Shed", "name_with_namespace": "Acme / Platform / Shed", "path": "shed", "path_with_namespace": "acme/platform/shed", "created_at": "2025-03-04T10:00:00.000Z", "default_branch": "main", "topics": ["tools", "shed"], "ssh_url_to_repo": "git@gitlab.com:acme/platform/shed.git", "http_url_to_repo": "https://gitlab.com/acme/platform/shed.git", "web_url": "https://gitlab.com/acme/platform/shed", "last_activity_at": "2026-11-02T10:03:12.000Z", "namespace": {"id": 9, "name": "Platform", "path": "platform", "kind": "group", "full_path": "acme/platform"}, "_links": {"self": "https://gitlab.com/api/v4/projects/14"}, "archived": false, "visibility": "private", "open_issues_count": 3, "issues_enabled": true, "merge_requests_enabled": true, "only_allow_merge_if_pipeline_succeeds": true, "allow_merge_on_skipped_pipeline": false, "only_allow_merge_if_all_discussions_are_resolved": false, "merge_method": "merge", "runners_token": "must-not-be-kept", "permissions": {"project_access": {"access_level": 30}}}
--- requests
GET /api/v4/projects/acme%2Fplatform%2Fshed
auth: ok
--- status
0
--- stdout
{
"allow_merge_on_skipped_pipeline": false,
"archived": false,
"created_at": "2025-03-04T10:00:00.000Z",
"default_branch": "main",
"description": "The tool shed",
"id": 14,
"issues_enabled": true,
"last_activity_at": "2026-11-02T10:03:12.000Z",
"merge_method": "merge",
"merge_requests_enabled": true,
"name": "Shed",
"only_allow_merge_if_all_discussions_are_resolved": false,
"only_allow_merge_if_pipeline_succeeds": true,
"open_issues_count": 3,
"path_with_namespace": "acme/platform/shed",
"topics": [
"tools",
"shed"
],
"visibility": "private",
"web_url": "https://gitlab.com/acme/platform/shed"
}
--- stderr
=== args
user
=== answer
{"id": 52, "username": "Bob", "name": "Bob Builder", "state": "active", "avatar_url": "https://gitlab.com/uploads/-/system/user/avatar/52/avatar.png", "web_url": "https://gitlab.com/Bob", "email": "bob@example.com", "is_admin": false, "bio": "Builds things", "can_create_project": true}
--- requests
GET /api/v4/user
auth: ok
--- status
0
--- stdout
{
"name": "Bob Builder",
"username": "Bob"
}
--- stderr
# The core part of the gitlab test, sourced by ./test: the declaration as the
# yard reads it, the preset and the grants SETUP.md teaches, and the calls
# made through the real yardr against a stand-in on a loopback port
# (testdata/standin.py). Everything runs in a scratch yard under a short path
# (a unix socket's path is held to 100 bytes) with its own home and an
# environment of nothing but PATH: none of this session's YARDR_* variables
# lead back to a real yard. No daemon is started, so no agent is, and the
# only listener is the stand-in's, on 127.0.0.1 and a port the system
# chooses.
#
# ./test has set $YARDR, $here, $work, check, ok and cleanup (which stops the
# stand-in by its pid, stops the scratch yard's server, and removes the
# scratch yard, whatever core is by then).
core=$(mktemp -d /tmp/gitlabt.XXXXXX)
mkdir -p "$core/h"
yrun() { env -i HOME="$core/h" PATH="$PATH" "$YARDR" --home "$core/y" --actor tester "$@"; }
token=glpat-FIXTUREa1b2c3d4e5f6
if ! yrun init --harness claude --operator tester >"$core/init.out" 2>&1; then
echo "test: core FAILED: the scratch yard could not be made: $(tail -c 300 "$core/init.out")"
fail=1
elif ! yrun pack apply "$here" --yes >"$core/apply.out" 2>&1; then
echo "test: core FAILED: the pack did not apply from its directory: $(tail -c 300 "$core/apply.out")"
fail=1
else
# -- the declaration as the yard reads it, with the shipped base
yrun source show gitlab --json | jq -S .declaration >"$core/declaration.json"
check "declaration as the yard reads it (hook, events, thread, link, calls)" "$core/declaration.json" testdata/declaration.golden
jq -e '.base == "https://gitlab.com/api/v4" and ([.calls[] | .kind == "read" and .request.method == "GET"] | all)
and (.calls | map(.name) == ["user", "project", "merge_request", "pipelines", "issues", "issue"])
and .events.runs == ["pipeline"] and .events.head_from == ["merge_request"]
and .events.pr == ["gitlab/mr", "pr"] and .events.ci == ["gitlab/mr", "ci"]' "$core/declaration.json" >/dev/null
ok "the shipped base is gitlab.com, every call is a read by GET, and events name pipeline, merge_request and the two commands" $?
yrun pack show gitlab >"$core/packshow.out" 2>&1
grep -q '1\. read: ' "$core/packshow.out" && grep -q 'gitlab: read' "$core/packshow.out"
ok "pack show prints the one preset, read, as SETUP.md says" $?
# -- the grants SETUP.md teaches, run as written. They are read out of
# SETUP.md and not copied here: a copy would pass whatever SETUP teaches,
# and --preset is refused for a pack applied from a directory. The card of
# SETUP.md grants the group reviewers, which a fresh yard has none of.
grep -o 'source grant [^`"]*' SETUP.md >"$core/grants"
if [ ! -s "$core/grants" ]; then
echo "test: grant FAILED: SETUP.md teaches no source grant command"
fail=1
elif ! yrun group add reviewers --runner manual >"$core/out" 2>&1; then
echo "test: grant FAILED: the group of the card could not be made: $(tail -c 300 "$core/out")"
fail=1
else
while IFS= read -r cmd; do
# A grant is plain words, so $cmd is split on purpose. Its stdin is
# /dev/null so that it cannot read the rest of the list.
# shellcheck disable=SC2086
if yrun $cmd </dev/null >"$core/out" 2>&1; then
echo "test: grant ok (yardr $cmd)"
else
echo "test: grant FAILED: yardr refused 'yardr $cmd', which SETUP.md teaches: $(tail -c 300 "$core/out")"
fail=1
fi
done <"$core/grants"
fi
# -- the calls, through the real yardr against the stand-in. The operator's
# copy is the declaration with its base replaced, as SETUP.md's last step
# makes one for a self-managed instance; here the instance is the stand-in.
mkdir -p "$core/mine"
cp -R "$here/gitlab" "$core/mine/"
: >"$core/requests"
printf '{}\n' >"$core/answer.json"
python3 -I testdata/standin.py "$core/port" "$core/requests" "$core/answer.json" 2>"$core/standin.err" <<EOF &
$token
EOF
standin_pid=$!
i=0
while [ ! -s "$core/port" ] && [ "$i" -lt 200 ]; do sleep 0.05; i=$((i + 1)); done
if [ ! -s "$core/port" ]; then
echo "test: core FAILED: the stand-in did not listen: $(cat "$core/standin.err")"
fail=1
else
site=http://127.0.0.1:$(cat "$core/port")
sed "s|^base: .*|base: $site/api/v4|" "$here/gitlab.yaml" >"$core/mine/gitlab.yaml"
# The credential is given as SETUP.md gives it: yardr source key, here
# with the line on its standard input, which the server writes into the
# source's env file.
yrun source add "$core/mine/gitlab.yaml" >"$core/add.out" 2>&1 \
&& printf 'GITLAB_TOKEN=%s\n' "$token" | yrun source key gitlab >>"$core/add.out" 2>&1 \
&& yrun source set gitlab calls_per_hour=1000 >>"$core/add.out" 2>&1
ok "the operator's copy is added and its credential given by source key" $? "$(tail -c 300 "$core/add.out")"
# A case file is sections, each opened by a line "=== name": args (the
# call and its parameters, one word to a line), answer (the stand-in's
# body) and status (its HTTP status, 200 if left out).
for c in testdata/calls/*.case; do
n=$(basename "$c" .case)
d="$core/case_$n"
mkdir "$d"
awk -v dir="$d" '/^=== /{ name = substr($0, 5); f = dir "/" name; printf "" > f; next } { print >> f }' "$c"
if [ -e "$d/answer" ]; then cp "$d/answer" "$core/answer.json"; else printf '{}\n' >"$core/answer.json"; fi
rm -f "$core/answer.json.status"
[ ! -e "$d/status" ] || cp "$d/status" "$core/answer.json.status"
: >"$core/requests"
set --
while IFS= read -r word; do set -- "$@" "$word"; done <"$d/args"
yrun call gitlab "$@" --json >"$d/stdout" 2>"$d/stderr"
status=$?
{
echo "--- requests"
cat "$core/requests"
echo "--- status"
echo "$status"
echo "--- stdout"
if [ -s "$d/stdout" ]; then jq -S . "$d/stdout" 2>/dev/null || cat "$d/stdout"; fi
echo "--- stderr"
cat "$d/stderr"
} >"$d/golden"
check "call $n" "$d/golden" "testdata/calls/$n.golden"
done
rm -f "$core/answer.json.status"
# Reads only: of all the requests the cases made, the stand-in saw GETs.
methods=$(awk '/^--- requests/ { r = 1; next } /^--- status/ { r = 0 } r && /^[A-Z]/ { print $1 }' "$core"/case_*/golden | sort -u)
[ "$methods" = GET ]
ok "no request of any case is anything but a GET" $? "methods seen: $methods"
! grep -q 'auth: WRONG' "$core"/case_*/golden
ok "every request carried the credential in PRIVATE-TOKEN" $?
# -- the token is in no file the run left but the env file, and in no output
kill "$standin_pid" 2>/dev/null
standin_pid=
envfile=$core/y/sources/gitlab.env
leaks=$(grep -rlF -e "$token" "$core" 2>/dev/null | grep -vxF "$envfile")
[ -z "$leaks" ]
ok "the token is in no file of the run but the env file" $? "found in: $leaks"
grep -qF "$token" "$envfile"
ok "(and the env file does hold the token, so the search above looked at something)" $?
fi
fi
[ -z "${standin_pid:-}" ] || { kill "$standin_pid" 2>/dev/null; standin_pid=; }
{
"about": "GitLab issues and merge requests. Hears its webhooks and speaks in its threads.",
"base": "https://gitlab.com/api/v4",
"calls": [
{
"about": "Who the credential acts as.",
"answer": {
"max": 65536,
"select": [
"username",
"name"
]
},
"kind": "read",
"name": "user",
"request": {
"method": "GET",
"path": "/user"
}
},
{
"about": "Read a project, its default branch and merge settings.",
"answer": {
"max": 65536,
"select": [
"id",
"name",
"path_with_namespace",
"description",
"default_branch",
"visibility",
"web_url",
"archived",
"created_at",
"last_activity_at",
"open_issues_count",
"issues_enabled",
"merge_requests_enabled",
"only_allow_merge_if_pipeline_succeeds",
"allow_merge_on_skipped_pipeline",
"only_allow_merge_if_all_discussions_are_resolved",
"merge_method",
"topics"
]
},
"kind": "read",
"name": "project",
"params": [
{
"log": true,
"max": 255,
"name": "project",
"pattern": "^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$",
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/projects/{project}"
}
},
{
"about": "Read a merge request by its number, with its branches, head commit, head pipeline and merge status.",
"answer": {
"max": 262144,
"select": [
"iid",
"title",
"description",
"state",
"draft",
"source_branch",
"target_branch",
"source_project_id",
"target_project_id",
"sha",
"author",
"assignees",
"reviewers",
"labels",
"milestone",
"created_at",
"updated_at",
"merged_at",
"closed_at",
"detailed_merge_status",
"has_conflicts",
"blocking_discussions_resolved",
"user_notes_count",
"merge_when_pipeline_succeeds",
"squash",
"head_pipeline",
"references",
"web_url"
]
},
"kind": "read",
"name": "merge_request",
"params": [
{
"log": true,
"max": 255,
"name": "project",
"pattern": "^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$",
"type": "string"
},
{
"log": true,
"name": "iid",
"pattern": "^[1-9][0-9]{0,9}$",
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/projects/{project}/merge_requests/{iid}"
}
},
{
"about": "List a project's pipelines, newest first, of a branch or a commit or in a status.",
"answer": {
"max": 262144,
"select": [
"id",
"iid",
"project_id",
"status",
"source",
"ref",
"sha",
"name",
"web_url",
"created_at",
"updated_at"
]
},
"kind": "read",
"name": "pipelines",
"params": [
{
"log": true,
"max": 255,
"name": "project",
"pattern": "^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$",
"type": "string"
},
{
"max": 255,
"name": "ref",
"optional": true,
"type": "string"
},
{
"name": "sha",
"optional": true,
"pattern": "^[0-9a-fA-F]{7,64}$",
"type": "string"
},
{
"name": "status",
"optional": true,
"type": "enum",
"values": [
"created",
"waiting_for_resource",
"preparing",
"waiting_for_callback",
"pending",
"running",
"success",
"failed",
"canceling",
"canceled",
"skipped",
"manual",
"scheduled"
]
},
{
"default": 20,
"name": "limit",
"optional": true,
"type": "int"
},
{
"default": 1,
"name": "page",
"optional": true,
"type": "int"
}
],
"request": {
"method": "GET",
"path": "/projects/{project}/pipelines",
"query": [
{
"key": "ref",
"value": "{ref}"
},
{
"key": "sha",
"value": "{sha}"
},
{
"key": "status",
"value": "{status}"
},
{
"key": "order_by",
"value": "id"
},
{
"key": "sort",
"value": "desc"
},
{
"key": "per_page",
"value": "{limit}"
},
{
"key": "page",
"value": "{page}"
}
]
}
},
{
"about": "List a project's issues, newest first, by state, labels, author or words in the title and description.",
"answer": {
"max": 262144,
"select": [
"iid",
"title",
"state",
"labels",
"author",
"assignees",
"milestone",
"created_at",
"updated_at",
"closed_at",
"due_date",
"confidential",
"issue_type",
"user_notes_count",
"merge_requests_count",
"web_url",
"references"
]
},
"kind": "read",
"name": "issues",
"params": [
{
"log": true,
"max": 255,
"name": "project",
"pattern": "^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$",
"type": "string"
},
{
"default": "opened",
"name": "state",
"optional": true,
"type": "enum",
"values": [
"opened",
"closed",
"all"
]
},
{
"max": 255,
"name": "labels",
"optional": true,
"type": "string"
},
{
"name": "author",
"optional": true,
"pattern": "^[A-Za-z0-9_.-]{1,255}$",
"type": "string"
},
{
"max": 255,
"name": "search",
"optional": true,
"type": "string"
},
{
"default": 20,
"name": "limit",
"optional": true,
"type": "int"
},
{
"default": 1,
"name": "page",
"optional": true,
"type": "int"
}
],
"request": {
"method": "GET",
"path": "/projects/{project}/issues",
"query": [
{
"key": "state",
"value": "{state}"
},
{
"key": "labels",
"value": "{labels}"
},
{
"key": "author_username",
"value": "{author}"
},
{
"key": "search",
"value": "{search}"
},
{
"key": "order_by",
"value": "created_at"
},
{
"key": "sort",
"value": "desc"
},
{
"key": "per_page",
"value": "{limit}"
},
{
"key": "page",
"value": "{page}"
}
]
}
},
{
"about": "Read an issue by its number, with its description.",
"answer": {
"max": 262144,
"select": [
"iid",
"title",
"description",
"state",
"labels",
"author",
"assignees",
"milestone",
"created_at",
"updated_at",
"closed_at",
"due_date",
"confidential",
"issue_type",
"user_notes_count",
"merge_requests_count",
"web_url",
"references"
]
},
"kind": "read",
"name": "issue",
"params": [
{
"log": true,
"max": 255,
"name": "project",
"pattern": "^(?:[0-9]+|(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*(?:/(?:[A-Za-z0-9_]|\\.[A-Za-z0-9_-])(?:[A-Za-z0-9_-]|\\.[A-Za-z0-9_-])*)+)$",
"type": "string"
},
{
"log": true,
"name": "iid",
"pattern": "^[1-9][0-9]{0,9}$",
"type": "string"
}
],
"request": {
"method": "GET",
"path": "/projects/{project}/issues/{iid}"
}
}
],
"credential": {
"env": "GITLAB_TOKEN",
"header": "PRIVATE-TOKEN: {credential}"
},
"events": {
"ci": [
"gitlab/mr",
"ci"
],
"head_from": [
"merge_request"
],
"pr": [
"gitlab/mr",
"pr"
],
"runs": [
"pipeline"
]
},
"hear": {
"address": {
"env": ""
},
"by": "identity",
"field": "sender"
},
"hook": {
"bodies": "unique-per-hook",
"keys": [
"number",
"state",
"noteable",
"conclusion",
"head_branch",
"head_sha",
"head_repo",
"base",
"train",
"sender"
],
"normalize": [
"gitlab/normalize"
],
"untimed": true,
"verify": {
"delivery": {
"header": "Idempotency-Key"
},
"scheme": "secret",
"signature": {
"encoding": "",
"header": "X-Gitlab-Token"
},
"signed": ""
}
},
"identity": {
"fold": "lower"
},
"link": {
"fixed": "https://gitlab.com/{1}/-/issues/{2}",
"fold": "lower",
"host": "gitlab.com",
"issue": "^/((?:[A-Za-z0-9_.][A-Za-z0-9_.-]*/)+[A-Za-z0-9_.][A-Za-z0-9_.-]*)/(?:-/)?issues/([1-9][0-9]*)(?:/.*)?$",
"pull": {
"fixed": "https://gitlab.com/{1}/-/merge_requests/{2}",
"issue": "^/((?:[A-Za-z0-9_.][A-Za-z0-9_.-]*/)+[A-Za-z0-9_.][A-Za-z0-9_.-]*)/(?:-/)?merge_requests/([1-9][0-9]*)(?:/.*)?$",
"thread": "{1}!{2}"
},
"repos": "(?:[A-Za-z0-9_.][A-Za-z0-9_.-]*/)+[A-Za-z0-9_.][A-Za-z0-9_.-]*",
"thread": "{1}#{2}"
},
"name": "gitlab",
"thread": {
"assignee": "key",
"labels": true,
"open": [
"gitlab/thread",
"open"
],
"opened": [
"gitlab/thread",
"opened"
],
"place": "project",
"posted": [
"gitlab/thread",
"posted"
],
"read": [
"gitlab/thread",
"read"
],
"reply": [
"gitlab/thread",
"reply"
],
"state": [
"gitlab/thread",
"state"
],
"states": [
"started",
"done"
]
}
}
{"source":"gitlab","type":"issue","action":"close","actor":{"id":"bob","name":"Bob"},"thread":{"id":"Acme/Shed#7","title":"Roof leaks over the shed door","url":"https://gitlab.com/Acme/Shed/-/issues/7"},"mentions":[],"url":"https://gitlab.com/Acme/Shed/-/issues/7","assignee":"","text":"The roof leaks when it rains.","fields":{"repo":["Acme/Shed"],"sender":["bob"],"number":["7"],"state":["closed"]},"entity":"Acme/Shed#7"}
{
"headers": {
"Idempotency-Key": ["4b1d9c0e-7c52-4a0e-8d7e-0a5bb3f1c2aa"],
"X-Gitlab-Event": ["Issue Hook"]
},
"body": {
"object_kind": "issue",
"event_type": "issue",
"user": {"id": 52, "name": "Bob Builder", "username": "Bob"},
"project": {"id": 14, "name": "Shed", "web_url": "https://gitlab.com/Acme/Shed", "path_with_namespace": "Acme/Shed"},
"object_attributes": {
"id": 301, "iid": 7, "title": "Roof leaks over the shed door", "description": "The roof leaks when it rains.",
"state": "closed", "action": "close", "url": "https://gitlab.com/Acme/Shed/-/issues/7"
},
"assignees": [],
"labels": [],
"changes": {"state_id": {"previous": 1, "current": 2}}
}
}
{"source":"gitlab","type":"issue","action":"open","actor":{"id":"ada","name":"Ada"},"thread":{"id":"Acme/Shed#7","title":"Roof leaks over the shed door","url":"https://gitlab.com/Acme/Shed/-/issues/7"},"mentions":["bob"],"url":"https://gitlab.com/Acme/Shed/-/issues/7","assignee":"bob","text":"The roof leaks when it rains. cc @Bob, and the @Acme/builders group. Mail ada@example.com is not a mention. ``` @in-code stays text ```","fields":{"repo":["Acme/Shed"],"sender":["ada"],"number":["7"],"state":["opened"],"label":["bug","roof"]},"entity":"Acme/Shed#7"}
{
"headers": {
"Content-Type": ["application/json"],
"Idempotency-Key": ["0e8f1b1c-5a3e-4f4d-9b6a-2a1c6f3d7e10"],
"User-Agent": ["GitLab/19.1.0"],
"X-Gitlab-Event": ["Issue Hook"],
"X-Gitlab-Event-UUID": ["13792a34-cac6-4fda-95a8-c58e00a3954e"],
"X-Gitlab-Instance": ["https://gitlab.com"],
"X-Gitlab-Webhook-UUID": ["02affd2d-2cba-4033-917d-ec22d5dc4b38"]
},
"body": {
"object_kind": "issue",
"event_type": "issue",
"user": {"id": 51, "name": "Ada Lovelace", "username": "Ada", "avatar_url": "https://secure.gravatar.com/avatar/1?s=80&d=identicon", "email": "[REDACTED]"},
"project": {
"id": 14, "name": "Shed", "description": "The shed.", "web_url": "https://gitlab.com/Acme/Shed", "avatar_url": null,
"git_ssh_url": "git@gitlab.com:Acme/Shed.git", "git_http_url": "https://gitlab.com/Acme/Shed.git",
"namespace": "Acme", "visibility_level": 20, "path_with_namespace": "Acme/Shed", "default_branch": "main",
"ci_config_path": null, "homepage": "https://gitlab.com/Acme/Shed", "url": "git@gitlab.com:Acme/Shed.git",
"ssh_url": "git@gitlab.com:Acme/Shed.git", "http_url": "https://gitlab.com/Acme/Shed.git"
},
"object_attributes": {
"id": 301, "iid": 7, "title": "Roof leaks over the shed door",
"description": "The roof leaks when it rains.\n\ncc @Bob, and the @Acme/builders group. Mail ada@example.com is not a mention.\n\n```\n@in-code stays text\n```\n",
"state": "opened", "action": "open", "url": "https://gitlab.com/Acme/Shed/-/issues/7",
"author_id": 51, "project_id": 14, "assignee_ids": [52], "assignee_id": 52,
"created_at": "2026-11-02T09:15:43.000Z", "updated_at": "2026-11-02T09:15:43.000Z",
"confidential": false, "type": "Issue"
},
"repository": {"name": "Shed", "url": "git@gitlab.com:Acme/Shed.git", "description": "The shed.", "homepage": "https://gitlab.com/Acme/Shed"},
"assignees": [{"name": "Bob Builder", "username": "Bob", "avatar_url": "https://secure.gravatar.com/avatar/2?s=80&d=identicon"}],
"assignee": {"name": "Bob Builder", "username": "Bob", "avatar_url": "https://secure.gravatar.com/avatar/2?s=80&d=identicon"},
"labels": [
{"id": 206, "title": "bug", "color": "#ff0000", "project_id": 14, "template": false, "description": null, "type": "ProjectLabel", "group_id": null},
{"id": 207, "title": "roof", "color": "#00ff00", "project_id": 14, "template": false, "description": null, "type": "ProjectLabel", "group_id": null}
],
"changes": {}
}
}
{"source":"gitlab","type":"merge_request","action":"merge","actor":{"id":"ada","name":"Ada"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":[],"url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","assignee":"","text":"","fields":{"repo":["acme/platform/shed"],"sender":["ada"],"number":["16"],"state":["merged"],"head_branch":["yard/roof-mend"],"head_sha":["e59094b8de0f2f91abbe4760a52d9137260252d8"],"head_repo":["acme/platform/shed"],"base":["main"]},"entity":"acme/platform/shed!16"}
{
"headers": {"X-Gitlab-Event": ["Merge Request Hook"]},
"body": {
"object_kind": "merge_request",
"event_type": "merge_request",
"user": {"id": 51, "name": "Ada Lovelace", "username": "Ada"},
"project": {"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "path_with_namespace": "acme/platform/shed"},
"object_attributes": {
"id": 93, "iid": 16, "title": "Mend the roof", "description": "",
"state": "merged", "action": "merge",
"source_branch": "yard/roof-mend", "target_branch": "main",
"last_commit": {"id": "e59094b8de0f2f91abbe4760a52d9137260252d8"},
"source": {"path_with_namespace": "acme/platform/shed"},
"url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"
},
"labels": [],
"assignees": []
}
}
{"source":"gitlab","type":"merge_request","action":"open","actor":{"id":"bob","name":"Bob"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":["ada"],"url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","assignee":"bob","text":"Fixes the leak. @ada please review.","fields":{"repo":["acme/platform/shed"],"sender":["bob"],"number":["16"],"state":["opened"],"head_branch":["yard/roof-mend"],"head_sha":["e59094b8de0f2f91abbe4760a52d9137260252d8"],"head_repo":["bob/shed"],"base":["main"],"label":["roof"]},"entity":"acme/platform/shed!16"}
{
"headers": {
"Idempotency-Key": ["9d6f1f1a-3b0c-4e68-a8d3-6f7a0c1e2b44"],
"X-Gitlab-Event": ["Merge Request Hook"]
},
"body": {
"object_kind": "merge_request",
"event_type": "merge_request",
"user": {"id": 52, "name": "Bob Builder", "username": "Bob"},
"project": {
"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "namespace": "Platform",
"path_with_namespace": "acme/platform/shed", "default_branch": "main"
},
"object_attributes": {
"id": 93, "iid": 16, "title": "Mend the roof",
"description": "Fixes the leak. @ada please review.",
"state": "opened", "action": "open", "draft": false,
"source_branch": "yard/roof-mend", "target_branch": "main",
"source_project_id": 15, "target_project_id": 14,
"last_commit": {"id": "e59094b8de0f2f91abbe4760a52d9137260252d8", "message": "Mend", "title": "Mend", "url": "https://gitlab.com/acme/platform/shed/-/commit/e59094b8de0f2f91abbe4760a52d9137260252d8"},
"source": {"id": 15, "name": "Shed", "path_with_namespace": "bob/shed", "web_url": "https://gitlab.com/bob/shed"},
"target": {"id": 14, "name": "Shed", "path_with_namespace": "acme/platform/shed", "web_url": "https://gitlab.com/acme/platform/shed"},
"url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16",
"assignee_ids": [52], "reviewer_ids": [51]
},
"labels": [{"id": 19, "title": "roof", "color": "#adb21a", "type": "GroupLabel", "group_id": 24}],
"changes": {"updated_at": {"previous": "2026-11-02T10:00:00.000Z", "current": "2026-11-02T10:00:05.000Z"}},
"assignees": [{"id": 52, "name": "Bob Builder", "username": "Bob", "email": "[REDACTED]"}],
"reviewers": [{"id": 51, "name": "Ada Lovelace", "username": "Ada", "state": "unreviewed", "re_requested": false}]
}
}
{"source":"gitlab","type":"merge_request","action":"open","actor":{"id":"ada","name":"Ada"},"thread":{"id":"acme/shed!3","title":"A body that names no kind","url":"https://gitlab.com/acme/shed/-/merge_requests/3"},"mentions":[],"url":"https://gitlab.com/acme/shed/-/merge_requests/3","assignee":"","text":"text","fields":{"repo":["acme/shed"],"sender":["ada"],"number":["3"],"state":["opened"]},"entity":"acme/shed!3"}
{
"headers": {"X-Gitlab-Event": ["Merge Request Hook"]},
"body": {
"user": {"username": "Ada"},
"project": {"web_url": "https://gitlab.com/acme/shed", "path_with_namespace": "acme/shed"},
"object_attributes": {"iid": 3, "title": "A body that names no kind", "state": "opened", "action": "open", "description": "text"}
}
}
{"headers": {"X-Gitlab-Event": ["Issue Hook"]}, "body": "this is text, not JSON"}
{"source":"gitlab","type":"note","action":"create","actor":{"id":"ada","name":"Ada"},"thread":{},"mentions":["bob"],"text":"This is a commit comment. @Bob how does this work?","fields":{"repo":["Acme/Shed"],"sender":["ada"],"noteable":["commit"]}}
{
"headers": {"X-Gitlab-Event": ["Note Hook"]},
"body": {
"object_kind": "note",
"event_type": "note",
"user": {"id": 51, "name": "Ada Lovelace", "username": "Ada"},
"project_id": 14,
"project": {"id": 14, "name": "Shed", "web_url": "https://gitlab.com/Acme/Shed", "path_with_namespace": "Acme/Shed"},
"object_attributes": {
"id": 1243, "note": "This is a commit comment. @Bob how does this work?", "noteable_type": "Commit",
"action": "create", "commit_id": "cfe32cf61b73a0d5e9f13e774abde7ff789b1660",
"url": "https://gitlab.com/Acme/Shed/-/commit/cfe32cf61b73a0d5e9f13e774abde7ff789b1660#note_1243"
},
"commit": {"id": "cfe32cf61b73a0d5e9f13e774abde7ff789b1660", "message": "Add submodule"}
}
}
{"source":"gitlab","type":"note","action":"create","actor":{"id":"ada","name":"Ada"},"thread":{"id":"Acme/Shed#7","title":"Roof leaks over the shed door","url":"https://gitlab.com/Acme/Shed/-/issues/7"},"mentions":["yardmaster"],"url":"https://gitlab.com/Acme/Shed/-/issues/7#note_1241","assignee":"","text":"@Yardmaster can you look at this? It leaks on the door side.","fields":{"repo":["Acme/Shed"],"sender":["ada"],"noteable":["issue"],"number":["7"],"state":["opened"],"label":["bug","roof"]},"message":"1241","entity":"note-1241"}
{
"headers": {
"Idempotency-Key": ["c4a2e7b0-1d3f-4b59-9c8e-5d6f7a8b9c01"],
"X-Gitlab-Event": ["Note Hook"]
},
"body": {
"object_kind": "note",
"event_type": "note",
"user": {"id": 51, "name": "Ada Lovelace", "username": "Ada"},
"project_id": 14,
"project": {"id": 14, "name": "Shed", "web_url": "https://gitlab.com/Acme/Shed", "path_with_namespace": "Acme/Shed"},
"object_attributes": {
"id": 1241, "internal": false,
"note": "@Yardmaster can you look at this?\n\nIt leaks\non the door side.",
"noteable_type": "Issue", "author_id": 51, "project_id": 14, "noteable_id": 92, "system": false,
"action": "create", "created_at": "2026-11-02T09:30:40.000Z", "updated_at": "2026-11-02T09:30:40.000Z",
"url": "https://gitlab.com/Acme/Shed/-/issues/7#note_1241"
},
"issue": {
"id": 92, "iid": 7, "title": "Roof leaks over the shed door", "state": "opened", "description": "The roof leaks when it rains.",
"assignee_ids": [52], "assignee_id": 52,
"labels": [{"id": 206, "title": "bug"}, {"id": 207, "title": "roof"}]
}
}
}
{"source":"gitlab","type":"note","action":"update","actor":{"id":"ada","name":"Ada"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":["bob.smith"],"url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16#note_1244","assignee":"bob","text":"Looks right. Run `/merge @not-a-mention` and ask @acme/reviewers, @bob.smith.","fields":{"repo":["acme/platform/shed"],"sender":["ada"],"noteable":["merge_request"],"number":["16"],"state":["opened"],"head_branch":["yard/roof-mend"],"head_sha":["e59094b8de0f2f91abbe4760a52d9137260252d8"],"head_repo":["acme/platform/shed"],"base":["main"],"label":["roof"]},"message":"1244","entity":"note-1244"}
{
"headers": {"X-Gitlab-Event": ["Note Hook"]},
"body": {
"object_kind": "note",
"event_type": "note",
"user": {"id": 51, "name": "Ada Lovelace", "username": "Ada"},
"project_id": 14,
"project": {"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "path_with_namespace": "acme/platform/shed"},
"object_attributes": {
"id": 1244, "internal": false,
"note": "Looks right. Run `/merge @not-a-mention` and ask @acme/reviewers, @bob.smith.",
"noteable_type": "MergeRequest", "author_id": 51, "project_id": 14, "noteable_id": 93, "system": false,
"action": "update", "url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16#note_1244"
},
"merge_request": {
"id": 93, "iid": 16, "title": "Mend the roof", "state": "opened",
"source_branch": "yard/roof-mend", "target_branch": "main", "source_project_id": 14, "target_project_id": 14,
"last_commit": {"id": "e59094b8de0f2f91abbe4760a52d9137260252d8"},
"source": {"path_with_namespace": "acme/platform/shed"},
"labels": [{"id": 19, "title": "roof"}],
"assignee": {"name": "Bob Builder", "username": "Bob"}
}
}
}
{"source":"gitlab","type":"pipeline","action":"","actor":{"id":"jsmith","name":"JSmith"},"thread":{},"mentions":[],"url":"https://gitlab.com/acme/platform/shed/-/pipelines/40","text":"pipeline failed on main","fields":{"repo":["acme/platform/shed"],"sender":["jsmith"],"conclusion":["failure"],"head_branch":["main"],"head_sha":["da1560886d4f094c3e6c9ef40349f7d38b5d27d7"],"head_repo":["acme/platform/shed"]}}
{
"headers": {
"Idempotency-Key": ["3f6b9d2e-8a15-4b07-bc42-7e0d1a9c6f66"],
"X-Gitlab-Event": ["Pipeline Hook"]
},
"body": {
"object_kind": "pipeline",
"object_attributes": {
"id": 40, "iid": 12, "name": "Pipeline for branch: main",
"ref": "main", "tag": false,
"sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"before_sha": "95790bf891e76fee5e1747ab589903a6a1f80f22",
"source": "push", "status": "failed", "detailed_status": "failed",
"stages": ["build", "test"],
"created_at": "2026-11-02T09:00:10.000Z", "finished_at": "2026-11-02T09:01:12.000Z",
"duration": 62, "queued_duration": 3,
"url": "https://gitlab.com/acme/platform/shed/-/pipelines/40"
},
"merge_request": null,
"user": {"id": 4, "name": "John Smith", "username": "JSmith", "avatar_url": null},
"project": {
"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "namespace": "Platform",
"path_with_namespace": "acme/platform/shed", "default_branch": "main"
},
"commit": {"id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", "title": "Fix the gutter"},
"builds": []
}
}
{"source":"gitlab","type":"pipeline","action":"","actor":{"id":"bob","name":"Bob"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":[],"url":"https://gitlab.com/bob/shed/-/pipelines/77","text":"pipeline failed on roof-mend","fields":{"repo":["acme/platform/shed"],"sender":["bob"],"number":["16"],"conclusion":["failure"],"head_branch":["roof-mend"],"head_sha":["e59094b8de0f2f91abbe4760a52d9137260252d8"],"head_repo":["bob/shed"],"base":["main"]}}
{
"headers": {
"Idempotency-Key": ["0c4e7f9a-3d51-4c2b-8e66-1b7a5d9c3f33"],
"X-Gitlab-Event": ["Pipeline Hook"]
},
"body": {
"object_kind": "pipeline",
"object_attributes": {
"id": 77, "iid": 9, "name": "Pipeline for branch: roof-mend",
"ref": "roof-mend", "tag": false,
"sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"before_sha": "0000000000000000000000000000000000000000",
"source": "merge_request_event", "status": "failed", "detailed_status": "failed",
"stages": ["test"],
"created_at": "2026-11-02T10:00:10.000Z", "finished_at": "2026-11-02T10:01:12.000Z",
"duration": 62, "queued_duration": 3,
"url": "https://gitlab.com/bob/shed/-/pipelines/77"
},
"merge_request": {
"id": 93, "iid": 16, "title": "Mend the roof",
"source_branch": "roof-mend", "source_project_id": 15,
"target_branch": "main", "target_project_id": 14,
"state": "opened", "merge_status": "can_be_merged", "detailed_merge_status": "ci_must_pass",
"url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"
},
"user": {"id": 52, "name": "Bob Builder", "username": "Bob", "avatar_url": null},
"project": {
"id": 15, "name": "Shed", "web_url": "https://gitlab.com/bob/shed", "namespace": "Bob",
"path_with_namespace": "bob/shed", "default_branch": "main"
},
"commit": {"id": "e59094b8de0f2f91abbe4760a52d9137260252d8", "title": "Mend"},
"builds": []
}
}
{"source":"gitlab","type":"pipeline","action":"","actor":{"id":"bob","name":"Bob"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":[],"url":"https://gitlab.com/acme/platform/shed/-/pipelines/78","text":"pipeline success on roof-mend","fields":{"repo":["acme/platform/shed"],"sender":["bob"],"number":["16"],"conclusion":["success"],"head_branch":["roof-mend"],"head_sha":["e59094b8de0f2f91abbe4760a52d9137260252d8"],"base":["main"]}}
{
"headers": {
"Idempotency-Key": ["5a2d8b6c-9e47-4d13-a0c8-6f1e4b2a7d44"],
"X-Gitlab-Event": ["Pipeline Hook"]
},
"body": {
"object_kind": "pipeline",
"object_attributes": {
"id": 78, "iid": 21, "name": "Pipeline for merge request !16",
"ref": "roof-mend", "tag": false,
"sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"before_sha": "0000000000000000000000000000000000000000",
"source": "merge_request_event", "status": "success", "detailed_status": "passed",
"stages": ["test"],
"created_at": "2026-11-02T10:00:10.000Z", "finished_at": "2026-11-02T10:01:12.000Z",
"duration": 62, "queued_duration": 3,
"url": "https://gitlab.com/acme/platform/shed/-/pipelines/78"
},
"merge_request": {
"id": 93, "iid": 16, "title": "Mend the roof",
"source_branch": "roof-mend", "source_project_id": 15,
"target_branch": "main", "target_project_id": 14,
"state": "opened", "merge_status": "can_be_merged", "detailed_merge_status": "mergeable",
"url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"
},
"user": {"id": 52, "name": "Bob Builder", "username": "Bob", "avatar_url": null},
"project": {
"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "namespace": "Platform",
"path_with_namespace": "acme/platform/shed", "default_branch": "main"
},
"commit": {"id": "e59094b8de0f2f91abbe4760a52d9137260252d8", "title": "Mend"},
"builds": []
}
}
{"source":"gitlab","type":"pipeline","action":"","actor":{"id":"bob","name":"Bob"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":[],"url":"https://gitlab.com/acme/platform/shed/-/pipelines/33","text":"pipeline failed on yard/roof-mend","fields":{"repo":["acme/platform/shed"],"sender":["bob"],"number":["16"],"conclusion":["failure"],"head_branch":["yard/roof-mend"],"head_repo":["acme/platform/shed"],"base":["main"]}}
{
"headers": {
"Idempotency-Key": ["8e1f3a5c-2b64-4e90-9d37-3c5a7e1b4f55"],
"X-Gitlab-Event": ["Pipeline Hook"]
},
"body": {
"object_kind": "pipeline",
"object_attributes": {
"id": 33, "iid": 5, "name": "Merged results pipeline",
"ref": "refs/merge-requests/16/merge", "tag": false,
"sha": "1604b0c46c395822e4e9478777f8e54ac99fe5b9",
"before_sha": "1604b0c46c395822e4e9478777f8e54ac99fe5b9",
"source": "merge_request_event", "status": "failed", "detailed_status": "failed",
"stages": ["test"],
"created_at": "2026-11-02T12:00:10.000Z", "finished_at": "2026-11-02T12:01:12.000Z",
"duration": 62, "queued_duration": 3,
"url": "https://gitlab.com/acme/platform/shed/-/pipelines/33"
},
"merge_request": {
"id": 93, "iid": 16, "title": "Mend the roof",
"source_branch": "yard/roof-mend", "source_project_id": 14,
"target_branch": "main", "target_project_id": 14,
"state": "opened", "merge_status": "can_be_merged", "detailed_merge_status": "ci_must_pass",
"url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"
},
"user": {"id": 52, "name": "Bob Builder", "username": "Bob", "avatar_url": null},
"project": {
"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "namespace": "Platform",
"path_with_namespace": "acme/platform/shed", "default_branch": "main"
},
"commit": {"id": "1604b0c46c395822e4e9478777f8e54ac99fe5b9", "title": "Merge branch 'yard/roof-mend' into 'main'"},
"builds": []
}
}
{"source":"gitlab","type":"pipeline","action":"","actor":{"id":"bob","name":"Bob"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":[],"url":"https://gitlab.com/acme/platform/shed/-/pipelines/32","text":"pipeline failed on yard/roof-mend","fields":{"repo":["acme/platform/shed"],"sender":["bob"],"number":["16"],"conclusion":["failure"],"head_branch":["yard/roof-mend"],"head_sha":["9fceb02d0ae598e95dc970b74767f19372d61af8"],"head_repo":["acme/platform/shed"],"base":["main"]}}
{
"headers": {
"Idempotency-Key": ["7d0c1c3e-5f0a-4a43-b1f1-4a6f3c0f1d22"],
"X-Gitlab-Event": ["Pipeline Hook"]
},
"body": {
"object_kind": "pipeline",
"object_attributes": {
"id": 32, "iid": 4, "name": "Pipeline for branch: yard/roof-mend",
"ref": "yard/roof-mend", "tag": false,
"sha": "9fceb02d0ae598e95dc970b74767f19372d61af8",
"before_sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"source": "merge_request_event", "status": "failed", "detailed_status": "failed",
"stages": ["build", "test"],
"created_at": "2026-11-02T11:00:10.000Z", "finished_at": "2026-11-02T11:01:12.000Z",
"duration": 62, "queued_duration": 4,
"url": "https://gitlab.com/acme/platform/shed/-/pipelines/32"
},
"merge_request": {
"id": 93, "iid": 16, "title": "Mend the roof",
"source_branch": "yard/roof-mend", "source_project_id": 14,
"target_branch": "main", "target_project_id": 14,
"state": "opened", "merge_status": "can_be_merged", "detailed_merge_status": "ci_must_pass",
"url": "https://gitlab.com/acme/platform/shed/merge_requests/16"
},
"user": {"id": 52, "name": "Bob Builder", "username": "Bob", "avatar_url": null},
"project": {
"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "namespace": "Platform",
"path_with_namespace": "acme/platform/shed", "default_branch": "main"
},
"commit": {"id": "9fceb02d0ae598e95dc970b74767f19372d61af8", "title": "Mend again"},
"builds": []
}
}
{"source":"gitlab","type":"pipeline","action":"","actor":{"id":"bob","name":"Bob"},"thread":{"id":"acme/platform/shed!16","title":"Mend the roof","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16"},"mentions":[],"url":"https://gitlab.com/acme/platform/shed/-/pipelines/31","text":"pipeline success on yard/roof-mend","fields":{"repo":["acme/platform/shed"],"sender":["bob"],"number":["16"],"conclusion":["success"],"head_branch":["yard/roof-mend"],"head_sha":["e59094b8de0f2f91abbe4760a52d9137260252d8"],"head_repo":["acme/platform/shed"],"base":["main"]}}
{
"headers": {
"Idempotency-Key": ["2b8e5a0e-6c1f-4f0e-9a52-0d3f6a7c9e11"],
"X-Gitlab-Event": ["Pipeline Hook"]
},
"body": {
"object_kind": "pipeline",
"object_attributes": {
"id": 31, "iid": 3, "name": "Pipeline for branch: yard/roof-mend",
"ref": "yard/roof-mend", "tag": false,
"sha": "e59094b8de0f2f91abbe4760a52d9137260252d8",
"before_sha": "0000000000000000000000000000000000000000",
"source": "merge_request_event", "status": "success", "detailed_status": "passed",
"stages": ["build", "test"],
"created_at": "2026-11-02T10:00:10.000Z", "finished_at": "2026-11-02T10:03:12.000Z",
"duration": 63, "queued_duration": 10,
"url": "https://gitlab.com/acme/platform/shed/-/pipelines/31"
},
"merge_request": {
"id": 93, "iid": 16, "title": "Mend the roof",
"source_branch": "yard/roof-mend", "source_project_id": 14,
"target_branch": "main", "target_project_id": 14,
"state": "opened", "merge_status": "can_be_merged", "detailed_merge_status": "mergeable",
"url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"
},
"user": {"id": 52, "name": "Bob Builder", "username": "Bob", "avatar_url": null},
"project": {
"id": 14, "name": "Shed", "web_url": "https://gitlab.com/acme/platform/shed", "namespace": "Platform",
"path_with_namespace": "acme/platform/shed", "default_branch": "main"
},
"commit": {"id": "e59094b8de0f2f91abbe4760a52d9137260252d8", "title": "Mend", "url": "https://gitlab.com/acme/platform/shed/-/commit/e59094b8de0f2f91abbe4760a52d9137260252d8"},
"builds": []
}
}
{"source":"gitlab","type":"push","action":"","actor":{"id":"jsmith","name":"jsmith"},"thread":{},"mentions":[],"fields":{"repo":["mike/diaspora"],"sender":["jsmith"]}}
{
"headers": {"X-Gitlab-Event": ["Push Hook"]},
"body": {
"object_kind": "push",
"event_name": "push",
"before": "95790bf891e76fee5e1747ab589903a6a1f80f22",
"after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7",
"ref": "refs/heads/main",
"user_id": 4,
"user_name": "John Smith",
"user_username": "jsmith",
"project": {"id": 15, "name": "Diaspora", "web_url": "https://gitlab.com/mike/diaspora", "path_with_namespace": "mike/diaspora"},
"commits": [],
"total_commits_count": 0
}
}
{"source":"gitlab","type":"work_item","action":"open","actor":{"id":"ada","name":"Ada"},"thread":{},"mentions":[],"fields":{"repo":["Acme/Shed"],"sender":["ada"]}}
{
"headers": {"X-Gitlab-Event": ["Issue Hook"]},
"body": {
"object_kind": "work_item",
"event_type": "work_item",
"user": {"id": 51, "name": "Ada Lovelace", "username": "Ada"},
"project": {"id": 14, "name": "Shed", "web_url": "https://gitlab.com/Acme/Shed", "path_with_namespace": "Acme/Shed"},
"object_attributes": {"id": 400, "iid": 8, "title": "Check the gutters", "type": "Task", "state": "opened", "action": "open", "url": "https://gitlab.com/Acme/Shed/-/work_items/8"}
}
}
# The link patterns of gitlab.yaml on paths: the shape (issue, pull, repos), a # path, and what the core makes of it, after link.fold: the fixed URL and the # thread id, or - for a path the pattern does not read. Tab separated. issue /Acme/Shed/-/issues/7 https://gitlab.com/acme/shed/-/issues/7 acme/shed#7 issue /acme/platform/shed/-/issues/12/designs https://gitlab.com/acme/platform/shed/-/issues/12 acme/platform/shed#12 issue /acme/shed/issues/3 https://gitlab.com/acme/shed/-/issues/3 acme/shed#3 issue /a/b/-/issues/1 https://gitlab.com/a/b/-/issues/1 a/b#1 issue /acme/shed/-/issues/new - issue /acme/shed/-/issues/0 - issue /acme/shed/-/issues/07 - issue /acme/shed/-/merge_requests/3 - issue /acme/-/issues/3 - issue /-/issues/3 - issue /acme/shed/-/work_items/3 - pull /acme/shed/-/merge_requests/16 https://gitlab.com/acme/shed/-/merge_requests/16 acme/shed!16 pull /Acme/Platform/Shed/merge_requests/16/diffs https://gitlab.com/acme/platform/shed/-/merge_requests/16 acme/platform/shed!16 pull /acme/shed/-/issues/16 - pull /acme/shed/-/merge_requests/new - repos acme/shed acme/shed repos Acme/Platform/Shed acme/platform/shed repos shed - repos acme/shed/ - repos acme/-/shed -
{"message":"403 Forbidden"}
{"message": "404 Project Not Found"}
[]
[{"id":9102,"status":"success","stage":"test","name":"rspec","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9102","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9101,"status":"success","stage":"build","name":"build","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9101","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}}]
[{"id":9109,"status":"pending","stage":"test","name":"nightly","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9109","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9108,"status":"running","stage":"test","name":"slow","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9108","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9107,"status":"skipped","stage":"deploy","name":"docs","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9107","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9106,"status":"manual","stage":"deploy","name":"deploy","ref":"yard/roof-mend","tag":false,"allow_failure":true,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9106","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9105,"status":"canceled","stage":"test","name":"e2e","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9105","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9104,"status":"failed","stage":"test","name":"coverage","ref":"yard/roof-mend","tag":false,"allow_failure":true,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9104","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9103,"status":"failed","stage":"test","name":"lint","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9103","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9102,"status":"success","stage":"test","name":"rspec","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9102","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}},{"id":9101,"status":"success","stage":"build","name":"build","ref":"yard/roof-mend","tag":false,"allow_failure":false,"created_at":"2026-11-02T10:00:10.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9101","pipeline":{"id":538317940,"project_id":14,"ref":"yard/roof-mend","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","status":"success"}}]
[{"id":9301,"status":"success","stage":"test","name":"rspec 1/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9301"},{"id":9302,"status":"success","stage":"test","name":"rspec 2/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9302"},{"id":9303,"status":"success","stage":"test","name":"rspec 3/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9303"},{"id":9304,"status":"success","stage":"test","name":"rspec 4/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9304"},{"id":9305,"status":"success","stage":"test","name":"rspec 5/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9305"},{"id":9306,"status":"success","stage":"test","name":"rspec 6/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9306"},{"id":9307,"status":"success","stage":"test","name":"rspec 7/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9307"},{"id":9308,"status":"success","stage":"test","name":"rspec 8/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9308"},{"id":9309,"status":"success","stage":"test","name":"rspec 9/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9309"},{"id":9310,"status":"success","stage":"test","name":"rspec 10/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9310"},{"id":9311,"status":"success","stage":"test","name":"rspec 11/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9311"},{"id":9312,"status":"success","stage":"test","name":"rspec 12/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9312"},{"id":9313,"status":"success","stage":"test","name":"rspec 13/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9313"},{"id":9314,"status":"success","stage":"test","name":"rspec 14/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9314"},{"id":9315,"status":"success","stage":"test","name":"rspec 15/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9315"},{"id":9316,"status":"success","stage":"test","name":"rspec 16/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9316"},{"id":9317,"status":"success","stage":"test","name":"rspec 17/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9317"},{"id":9318,"status":"success","stage":"test","name":"rspec 18/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9318"},{"id":9319,"status":"success","stage":"test","name":"rspec 19/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9319"},{"id":9320,"status":"success","stage":"test","name":"rspec 20/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9320"},{"id":9321,"status":"success","stage":"test","name":"rspec 21/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9321"},{"id":9322,"status":"success","stage":"test","name":"rspec 22/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9322"},{"id":9323,"status":"success","stage":"test","name":"rspec 23/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9323"},{"id":9324,"status":"success","stage":"test","name":"rspec 24/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9324"},{"id":9325,"status":"success","stage":"test","name":"rspec 25/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9325"},{"id":9326,"status":"success","stage":"test","name":"rspec 26/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9326"},{"id":9327,"status":"success","stage":"test","name":"rspec 27/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9327"},{"id":9328,"status":"success","stage":"test","name":"rspec 28/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9328"},{"id":9329,"status":"success","stage":"test","name":"rspec 29/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9329"},{"id":9330,"status":"success","stage":"test","name":"rspec 30/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9330"},{"id":9331,"status":"success","stage":"test","name":"rspec 31/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9331"},{"id":9332,"status":"success","stage":"test","name":"rspec 32/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9332"},{"id":9333,"status":"success","stage":"test","name":"rspec 33/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9333"},{"id":9334,"status":"success","stage":"test","name":"rspec 34/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9334"},{"id":9335,"status":"success","stage":"test","name":"rspec 35/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9335"},{"id":9336,"status":"success","stage":"test","name":"rspec 36/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9336"},{"id":9337,"status":"success","stage":"test","name":"rspec 37/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9337"},{"id":9338,"status":"success","stage":"test","name":"rspec 38/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9338"},{"id":9339,"status":"success","stage":"test","name":"rspec 39/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9339"},{"id":9340,"status":"success","stage":"test","name":"rspec 40/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9340"},{"id":9341,"status":"success","stage":"test","name":"rspec 41/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9341"},{"id":9342,"status":"success","stage":"test","name":"rspec 42/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9342"},{"id":9343,"status":"success","stage":"test","name":"rspec 43/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9343"},{"id":9344,"status":"success","stage":"test","name":"rspec 44/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9344"},{"id":9345,"status":"success","stage":"test","name":"rspec 45/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9345"},{"id":9346,"status":"success","stage":"test","name":"rspec 46/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9346"},{"id":9347,"status":"success","stage":"test","name":"rspec 47/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9347"},{"id":9348,"status":"success","stage":"test","name":"rspec 48/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9348"},{"id":9349,"status":"success","stage":"test","name":"rspec 49/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9349"},{"id":9350,"status":"success","stage":"test","name":"rspec 50/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9350"},{"id":9351,"status":"success","stage":"test","name":"rspec 51/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9351"},{"id":9352,"status":"success","stage":"test","name":"rspec 52/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9352"},{"id":9353,"status":"success","stage":"test","name":"rspec 53/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9353"},{"id":9354,"status":"success","stage":"test","name":"rspec 54/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9354"},{"id":9355,"status":"success","stage":"test","name":"rspec 55/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9355"},{"id":9356,"status":"success","stage":"test","name":"rspec 56/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9356"},{"id":9357,"status":"success","stage":"test","name":"rspec 57/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9357"},{"id":9358,"status":"success","stage":"test","name":"rspec 58/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9358"},{"id":9359,"status":"success","stage":"test","name":"rspec 59/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9359"},{"id":9360,"status":"success","stage":"test","name":"rspec 60/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9360"},{"id":9361,"status":"success","stage":"test","name":"rspec 61/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9361"},{"id":9362,"status":"success","stage":"test","name":"rspec 62/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9362"},{"id":9363,"status":"success","stage":"test","name":"rspec 63/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9363"},{"id":9364,"status":"success","stage":"test","name":"rspec 64/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9364"},{"id":9365,"status":"success","stage":"test","name":"rspec 65/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9365"},{"id":9366,"status":"success","stage":"test","name":"rspec 66/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9366"},{"id":9367,"status":"success","stage":"test","name":"rspec 67/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9367"},{"id":9368,"status":"success","stage":"test","name":"rspec 68/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9368"},{"id":9369,"status":"success","stage":"test","name":"rspec 69/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9369"},{"id":9370,"status":"success","stage":"test","name":"rspec 70/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9370"},{"id":9371,"status":"success","stage":"test","name":"rspec 71/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9371"},{"id":9372,"status":"success","stage":"test","name":"rspec 72/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9372"},{"id":9373,"status":"success","stage":"test","name":"rspec 73/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9373"},{"id":9374,"status":"success","stage":"test","name":"rspec 74/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9374"},{"id":9375,"status":"success","stage":"test","name":"rspec 75/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9375"},{"id":9376,"status":"success","stage":"test","name":"rspec 76/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9376"},{"id":9377,"status":"success","stage":"test","name":"rspec 77/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9377"},{"id":9378,"status":"success","stage":"test","name":"rspec 78/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9378"},{"id":9379,"status":"success","stage":"test","name":"rspec 79/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9379"},{"id":9380,"status":"success","stage":"test","name":"rspec 80/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9380"},{"id":9381,"status":"success","stage":"test","name":"rspec 81/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9381"},{"id":9382,"status":"success","stage":"test","name":"rspec 82/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9382"},{"id":9383,"status":"success","stage":"test","name":"rspec 83/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9383"},{"id":9384,"status":"success","stage":"test","name":"rspec 84/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9384"},{"id":9385,"status":"success","stage":"test","name":"rspec 85/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9385"},{"id":9386,"status":"success","stage":"test","name":"rspec 86/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9386"},{"id":9387,"status":"success","stage":"test","name":"rspec 87/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9387"},{"id":9388,"status":"success","stage":"test","name":"rspec 88/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9388"},{"id":9389,"status":"success","stage":"test","name":"rspec 89/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9389"},{"id":9390,"status":"success","stage":"test","name":"rspec 90/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9390"},{"id":9391,"status":"success","stage":"test","name":"rspec 91/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9391"},{"id":9392,"status":"success","stage":"test","name":"rspec 92/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9392"},{"id":9393,"status":"success","stage":"test","name":"rspec 93/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9393"},{"id":9394,"status":"success","stage":"test","name":"rspec 94/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9394"},{"id":9395,"status":"success","stage":"test","name":"rspec 95/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9395"},{"id":9396,"status":"success","stage":"test","name":"rspec 96/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9396"},{"id":9397,"status":"success","stage":"test","name":"rspec 97/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9397"},{"id":9398,"status":"success","stage":"test","name":"rspec 98/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9398"},{"id":9399,"status":"success","stage":"test","name":"rspec 99/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9399"},{"id":9400,"status":"success","stage":"test","name":"rspec 100/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9400"}][{"id":9401,"status":"success","stage":"test","name":"rspec 101/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9401"},{"id":9402,"status":"success","stage":"test","name":"rspec 102/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9402"},{"id":9403,"status":"success","stage":"test","name":"rspec 103/103","ref":"yard/roof-mend","tag":false,"allow_failure":false,"web_url":"https://gitlab.com/acme/platform/shed/-/jobs/9403"}]{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"closed","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"success","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"icon":"status_success","text":"passed","label":"passed","group":"success"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true},"closed_at":"2026-11-02T10:30:00.000Z"}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":15,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":77,"iid":1877,"project_id":15,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"success","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/bob/shed/-/pipelines/77","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"icon":"status_success","text":"passed","label":"passed","group":"success"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id": 93, "iid": 16, "project_id": 14, "title": "Mend the roof", "description": "Fixes the leak.", "state": "opened", "created_at": "2026-11-02T09:55:00.000Z", "updated_at": "2026-11-02T10:03:12.000Z", "target_branch": "main", "source_branch": "yard/roof-mend", "author": {"id": 52, "username": "Bob", "name": "Bob Builder", "state": "active"}, "source_project_id": 14, "target_project_id": 14, "labels": [], "draft": false, "work_in_progress": false, "merge_status": "can_be_merged", "detailed_merge_status": "mergeable", "sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "merge_commit_sha": null, "squash_commit_sha": null, "references": {"short": "!16", "relative": "!16", "full": "acme/platform/shed!16"}, "web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16", "head_pipeline": {"id": 538317940, "iid": 1877, "project_id": 14, "sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "ref": "yard/roof-mend", "status": "success", "source": "merge_request_event", "created_at": "2026-11-02T10:00:10.000Z", "updated_at": "2026-11-02T10:03:12.000Z", "web_url": "https://gitlab.com/acme/platform/shed/-/pipelines/538317940", "before_sha": "0000000000000000000000000000000000000000", "tag": false, "yaml_errors": null, "detailed_status": {"icon": "status_success", "text": "passed", "label": "passed", "group": "success"}, "archived": false}, "diff_refs": {"base_sha": "1162f719d711319a2efb2a35566f3bfdadee8bab", "head_sha": "e59094b8de0f2f91abbe4760a52d9137260252d8", "start_sha": "1162f719d711319a2efb2a35566f3bfdadee8bab"}, "user": {"can_merge": true}}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"merged","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":"7d1b2f0a9c3e4d5b6a7f8091a2b3c4d5e6f70812","squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"success","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"icon":"status_success","text":"passed","label":"passed","group":"success"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true},"merged_at":"2026-11-02T10:30:00.000Z"}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"1604b0c46c395822e4e9478777f8e54ac99fe5b9","ref":"refs/merge-requests/16/merge","status":"success","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"icon":"status_success","text":"passed","label":"passed","group":"success"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":null,"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true},"pipeline":null}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"success","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"icon":"status_success","text":"passed","label":"passed","group":"success"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"success","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"icon":"status_success","text":"passed","label":"passed","group":"success"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"9fceb02d0ae598e95dc970b74767f19372d61af8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"success","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"icon":"status_success","text":"passed","label":"passed","group":"success"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"9fceb02d0ae598e95dc970b74767f19372d61af8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"ci_must_pass","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"failed","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"text":"failed","group":"failed"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"ci_still_running","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"running","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"text":"running","group":"running"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id":93,"iid":16,"project_id":14,"title":"Mend the roof","description":"Fixes the leak.","state":"opened","created_at":"2026-11-02T09:55:00.000Z","updated_at":"2026-11-02T10:03:12.000Z","target_branch":"main","source_branch":"yard/roof-mend","author":{"id":52,"username":"Bob","name":"Bob Builder","state":"active"},"source_project_id":14,"target_project_id":14,"labels":[],"draft":false,"work_in_progress":false,"merge_status":"can_be_merged","detailed_merge_status":"mergeable","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","merge_commit_sha":null,"squash_commit_sha":null,"references":{"short":"!16","relative":"!16","full":"acme/platform/shed!16"},"web_url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16","head_pipeline":{"id":538317940,"iid":1877,"project_id":14,"sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","ref":"yard/roof-mend","status":"skipped","source":"merge_request_event","created_at":"2026-11-02T10:00:10.000Z","updated_at":"2026-11-02T10:03:12.000Z","web_url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940","before_sha":"0000000000000000000000000000000000000000","tag":false,"yaml_errors":null,"detailed_status":{"text":"skipped","group":"skipped"},"archived":false},"diff_refs":{"base_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab","head_sha":"e59094b8de0f2f91abbe4760a52d9137260252d8","start_sha":"1162f719d711319a2efb2a35566f3bfdadee8bab"},"user":{"can_merge":true}}
{"id":14,"name":"Shed","path_with_namespace":"acme/platform/shed","default_branch":"main"}
{"id": 14, "name": "Shed", "name_with_namespace": "Acme / Platform / Shed", "path": "shed", "path_with_namespace": "acme/platform/shed", "default_branch": "main", "visibility": "private", "web_url": "https://gitlab.com/acme/platform/shed", "archived": false, "merge_requests_enabled": true, "only_allow_merge_if_pipeline_succeeds": true, "allow_merge_on_skipped_pipeline": false, "only_allow_merge_if_all_discussions_are_resolved": false, "merge_method": "merge"}
{"id":14,"name":"Shed","name_with_namespace":"Acme / Platform / Shed","path":"shed","path_with_namespace":"acme/platform/shed","default_branch":"main","visibility":"private","web_url":"https://gitlab.com/acme/platform/shed","archived":false,"merge_requests_enabled":true,"only_allow_merge_if_pipeline_succeeds":false,"allow_merge_on_skipped_pipeline":false,"only_allow_merge_if_all_discussions_are_resolved":false,"merge_method":"merge"}
{"id":14,"name":"Shed","name_with_namespace":"Acme / Platform / Shed","path":"shed","path_with_namespace":"acme/platform/shed","default_branch":"main","visibility":"private","web_url":"https://gitlab.com/acme/platform/shed","archived":false,"merge_requests_enabled":true,"only_allow_merge_if_pipeline_succeeds":true,"allow_merge_on_skipped_pipeline":true,"only_allow_merge_if_all_discussions_are_resolved":false,"merge_method":"merge"}
{sha} refs/heads/main: want the commit's SHA
{"repo":"acme/platform/shed","number":"16","sha":"refs/heads/main"}
200 _answers/mr-green.json 200 _answers/project-bare.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":[],"checks":[{"name":"pipeline","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-fork.json 200 _answers/project-gated.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"completed","conclusion":"success","url":"https://gitlab.com/bob/shed/-/pipelines/77"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/15/pipelines/77/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json 200 _answers/project-gated.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json 200 _answers/project-gated.json 403 _answers/err-forbidden.json
https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1: HTTP 403: 403 Forbidden
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-merged.json 200 _answers/project-gated.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"merged","required":["pipeline"],"checks":[{"name":"pipeline","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-mergedresults.json 200 _answers/project-gated.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json 200 _answers/project-gated.json
{"repo":"acme/platform/shed","number":"16","sha":"da1560886d4f094c3e6c9ef40349f7d38b5d27d7"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-nopipeline.json 200 _answers/project-gated.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-nopipelineid.json 200 _answers/project-gated.json
the answer's head pipeline has no number
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>"
404 _answers/err-notfound.json
https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16: HTTP 404: 404 Project Not Found
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json 200 _answers/project-gated.json 200 _answers/jobs-page1.json 200 _answers/jobs-page2.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
[104,"pipeline","rspec 1/103","rspec 103/103",["pipeline"]]
[(.checks | length), .checks[0].name, .checks[1].name, .checks[-1].name, .required]
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=2 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-pipeline-behind.json 200 _answers/project-gated.json
{"repo":"acme/platform/shed","number":"16","sha":"9fceb02d0ae598e95dc970b74767f19372d61af8"}
{"head":"9fceb02d0ae598e95dc970b74767f19372d61af8","base":"main","state":"open","required":["pipeline"],"checks":[]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json 403 _answers/err-forbidden.json
https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed: HTTP 403: 403 Forbidden
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-red.json 200 _answers/project-gated.json 200 _answers/jobs-mixed.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"completed","conclusion":"failure","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"nightly","status":"pending","conclusion":"","url":"https://gitlab.com/acme/platform/shed/-/jobs/9109"},{"name":"slow","status":"running","conclusion":"","url":"https://gitlab.com/acme/platform/shed/-/jobs/9108"},{"name":"docs","status":"completed","conclusion":"skipped","url":"https://gitlab.com/acme/platform/shed/-/jobs/9107"},{"name":"deploy","status":"manual","conclusion":"","url":"https://gitlab.com/acme/platform/shed/-/jobs/9106"},{"name":"e2e","status":"completed","conclusion":"cancelled","url":"https://gitlab.com/acme/platform/shed/-/jobs/9105"},{"name":"coverage","status":"completed","conclusion":"neutral","url":"https://gitlab.com/acme/platform/shed/-/jobs/9104"},{"name":"lint","status":"completed","conclusion":"failure","url":"https://gitlab.com/acme/platform/shed/-/jobs/9103"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-running.json 200 _answers/project-gated.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"running","conclusion":"","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-skipped.json 200 _answers/project-gated.json 200 _answers/jobs-empty.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"skipped","conclusion":"","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-skipped.json 200 _answers/project-skipok.json 200 _answers/jobs-empty.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"completed","conclusion":"skipped","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json 200 _answers/project-open.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"e59094b8de0f2f91abbe4760a52d9137260252d8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":[],"checks":[{"name":"pipeline","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json 200 _answers/project-gated.json 200 _answers/jobs-green.json
{"repo":"acme/platform/shed","number":"16","sha":"E59094B8DE0F2F91ABBE4760A52D9137260252D8"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open","required":["pipeline"],"checks":[{"name":"pipeline","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/pipelines/538317940"},{"name":"rspec","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9102"},{"name":"build","status":"completed","conclusion":"success","url":"https://gitlab.com/acme/platform/shed/-/jobs/9101"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/14/pipelines/538317940/jobs?include_retried=false&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
{number} 16/../1: want the merge request's number
{"repo":"acme/platform/shed","number":"16/../1"}
{repo} shed: want group/project
{"repo":"shed","number":"16"}
200 _answers/mr-closed.json
{"repo":"acme/platform/shed","number":"16","url":""}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"closed"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
{repo} acme/../platform: want group/project
{"repo":"acme/../platform","number":"16"}
200 _answers/mr-merged.json
{"repo":"acme/platform/shed","number":"16","url":""}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"merged"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
the merge request is named by neither {repo, number} nor {url}
{"url":"https://gitlab.com/acme/platform/shed/-/issues/16"}
200 _answers/mr-nosha.json
the answer holds no head or base
{"repo":"acme/platform/shed","number":"16"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
404 _answers/err-notfound.json
https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16: HTTP 404: 404 Project Not Found
{"repo":"acme/platform/shed","number":"16"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json
{"repo":"acme/platform/shed","number":"16","url":""}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json
{"url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16/diffs"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-green.json
{"url":"https://gitlab.com/acme/platform/shed/merge_requests/16"}
{"head":"e59094b8de0f2f91abbe4760a52d9137260252d8","base":"main","state":"open"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
# A stand-in for GitLab's REST API, for the calls the test makes through the
# real yardr. It answers every request with the body and status the test has
# left in its answer files, read again for each request, so one process
# serves every case in turn. It records each request in the log it is given:
# the method, and the path and query exactly as they arrived (an encoded
# slash stays %2F), and whether PRIVATE-TOKEN was the value expected. Any
# method is recorded, so that a request that is not a GET shows in a golden.
# It writes the port it bound, on 127.0.0.1, to the file it is given once it
# listens. The token it expects is the first line of its standard input and
# not an argument, which anyone on the machine could read.
#
# python3 -I standin.py <port file> <request log> <answer file> < <expected token>
#
# The status is the first line of <answer file>.status, 200 if there is no
# such file.
import http.server
import os
import sys
port_file, log_file, answer_file = sys.argv[1:4]
expected = sys.stdin.readline().strip()
class Handler(http.server.BaseHTTPRequestHandler):
def handle_any(self):
auth = "ok" if self.headers.get("PRIVATE-TOKEN") == expected else "WRONG"
with open(log_file, "a") as f:
f.write("%s %s\n auth: %s\n" % (self.command, self.path, auth))
status = 200
if os.path.exists(answer_file + ".status"):
with open(answer_file + ".status") as f:
status = int(f.readline())
with open(answer_file, "rb") as f:
body = f.read()
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = handle_any
def log_message(self, *args):
pass
server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
with open(port_file, "w") as f:
f.write(str(server.server_address[1]))
server.serve_forever()
{"message": "403 Forbidden - the token glpat-CANARYa1b2c3d4e5f6 may not write to this project"}
{"message": "404 Project Not Found"}
{"message": {"title": ["can't be blank"]}}
{"message": "401 Unauthorized"}
{"id": 92, "iid": 7, "project_id": 14, "title": "Roof leaks over the shed door", "description": "The roof leaks when it rains.", "state": "closed", "labels": ["bug", "roof"], "web_url": "https://gitlab.com/Acme/Shed/-/issues/7", "references": {"short": "#7", "relative": "#7", "full": "Acme/Shed#7"}, "issue_type": "issue"}
{"id": 311, "iid": 10, "project_id": 14, "title": "For Bob", "description": "<!-- yardr-thread 8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05 -->", "state": "opened", "labels": [], "assignees": [{"id": 52, "username": "Bob", "name": "Bob Builder"}], "assignee": {"id": 52, "username": "Bob"}, "web_url": "https://gitlab.com/Acme/Shed/-/issues/10", "references": {"short": "#10", "relative": "#10", "full": "Acme/Shed#10"}}
{"id": 312, "iid": 4, "project_id": 15, "title": "Nested", "description": "", "state": "opened", "labels": [], "assignees": [], "web_url": "https://gitlab.com/acme/platform/shed/-/issues/4", "references": {"short": "#4", "relative": "#4", "full": "acme/platform/shed#4"}}
{"id": 310, "iid": 9, "project_id": 14, "title": "Check the gutters", "description": "Before winter.\n\n<!-- yardr-thread 4e1b7d3a-9c25-4f08-b6a1-3d5f0a7c8e90 -->", "state": "opened", "labels": [], "assignees": [], "assignee": null, "web_url": "https://gitlab.com/Acme/Shed/-/issues/9", "references": {"short": "#9", "relative": "#9", "full": "Acme/Shed#9"}}
{"id": 92, "iid": 7, "project_id": 14, "title": "Roof leaks over the shed door", "description": "The roof leaks when it rains.", "state": "opened", "labels": ["bug"], "web_url": "https://gitlab.com/Acme/Shed/-/issues/7", "references": {"short": "#7", "relative": "#7", "full": "Acme/Shed#7"}, "issue_type": "issue"}
{"id": 92, "iid": 7, "title": "Roof leaks over the shed door", "state": "closed", "labels": ["bug"], "web_url": "https://gitlab.com/Acme/Shed/-/issues/7", "closed_at": "2026-11-02T10:30:00.000Z"}
{"id": 92, "iid": 7, "title": "Roof leaks over the shed door", "state": "opened", "labels": ["bug", "roof"], "web_url": "https://gitlab.com/Acme/Shed/-/issues/7"}
{"id": 92, "iid": 7, "title": "Roof leaks over the shed door", "state": "opened", "labels": ["bug", "roof"], "web_url": "https://gitlab.com/Acme/Shed/-/issues/7", "closed_at": null}
[
{"id": 313, "iid": 12, "title": "Newest, no marker", "description": "Something else.", "state": "opened", "web_url": "https://gitlab.com/Acme/Shed/-/issues/12", "references": {"full": "Acme/Shed#12"}},
{"id": 311, "iid": 10, "title": "For Bob", "description": "For Bob.\n\n<!-- yardr-thread 8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05 -->", "state": "closed", "web_url": "https://gitlab.com/Acme/Shed/-/issues/10", "references": {"full": "Acme/Shed#10"}},
{"id": 310, "iid": 9, "title": "Check the gutters", "description": "Before winter.", "state": "opened", "web_url": "https://gitlab.com/Acme/Shed/-/issues/9", "references": {"full": "Acme/Shed#9"}}
]
{"id": 93, "iid": 16, "project_id": 14, "title": "Mend the roof", "state": "closed", "labels": [], "web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16", "source_branch": "yard/roof-mend", "target_branch": "main"}
{"id": 93, "iid": 16, "project_id": 14, "title": "Mend the roof", "state": "locked", "labels": [], "web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"}
{"id": 93, "iid": 16, "project_id": 14, "title": "Mend the roof", "state": "merged", "labels": ["roof"], "web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16", "source_branch": "yard/roof-mend", "target_branch": "main", "merged_at": "2026-11-02T11:00:00.000Z"}
{"id": 93, "iid": 16, "project_id": 14, "title": "Mend the roof", "state": "opened", "labels": [], "web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16", "source_branch": "yard/roof-mend", "target_branch": "main", "merge_status": "can_be_merged"}
{"id": 93, "iid": 16, "title": "Mend the roof", "state": "closed", "labels": [], "web_url": "https://gitlab.com/acme/platform/shed/-/merge_requests/16"}
{"id": 92, "iid": 7, "title": "An answer with no state"}
{"id": 1251, "type": null, "body": "posted", "author": {"id": 77, "username": "yardmaster", "name": "Yard Master", "state": "active"}, "created_at": "2026-11-02T10:06:00.000Z", "updated_at": "2026-11-02T10:06:00.000Z", "system": false, "noteable_id": 93, "noteable_type": "MergeRequest", "project_id": 14, "noteable_iid": 16, "resolvable": false, "confidential": false, "internal": false}
{"id": 1250, "type": null, "body": "posted", "author": {"id": 77, "username": "yardmaster", "name": "Yard Master", "state": "active"}, "created_at": "2026-11-02T10:05:00.000Z", "updated_at": "2026-11-02T10:05:00.000Z", "system": false, "noteable_id": 92, "noteable_type": "Issue", "project_id": 14, "noteable_iid": 7, "resolvable": false, "confidential": false, "internal": false}
[]
[
{"id": 1100, "body": "assigned to @Bob", "author": {"id": 51, "username": "Ada", "name": "Ada Lovelace"}, "created_at": "2026-11-02T09:15:44.000Z", "updated_at": "2026-11-02T09:15:44.000Z", "system": true, "noteable_type": "Issue", "noteable_iid": 7, "internal": false},
{"id": 1241, "body": "@Yardmaster can you look at this?", "author": {"id": 51, "username": "Ada", "name": "Ada Lovelace"}, "created_at": "2026-11-02T09:30:40.000Z", "updated_at": "2026-11-02T09:30:40.000Z", "system": false, "noteable_type": "Issue", "noteable_iid": 7, "internal": false},
{"id": 1242, "body": "closed", "author": {"id": 52, "username": "Bob", "name": "Bob Builder"}, "created_at": "2026-11-02T09:40:00.000Z", "updated_at": "2026-11-02T09:40:00.000Z", "system": true, "noteable_type": "Issue", "noteable_iid": 7, "internal": false},
{"id": 1250, "body": "On it.\n\n<!-- yardr-reply 0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11 -->", "author": {"id": 77, "username": "yardmaster", "name": "Yard Master"}, "created_at": "2026-11-02T10:05:00.000Z", "updated_at": "2026-11-02T10:05:00.000Z", "system": false, "noteable_type": "Issue", "noteable_iid": 7, "internal": false},
{"id": 1251, "body": "mentioned in merge request !16 <!-- yardr-reply 5d0a7c10-2b6e-4f43-8a61-7c2e9b9d1f22 -->", "author": {"id": 52, "username": "Bob", "name": "Bob Builder"}, "created_at": "2026-11-02T10:06:00.000Z", "updated_at": "2026-11-02T10:06:00.000Z", "system": true, "noteable_type": "Issue", "noteable_iid": 7, "internal": false}
]
[{"id":2001,"body":"note 1","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2002,"body":"note 2","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2003,"body":"note 3","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2004,"body":"note 4","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2005,"body":"note 5","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2006,"body":"note 6","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2007,"body":"note 7","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2008,"body":"note 8","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2009,"body":"note 9","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2010,"body":"note 10","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2011,"body":"note 11","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2012,"body":"note 12","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2013,"body":"note 13","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2014,"body":"note 14","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2015,"body":"note 15","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2016,"body":"note 16","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2017,"body":"note 17","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2018,"body":"note 18","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2019,"body":"note 19","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2020,"body":"note 20","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2021,"body":"note 21","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2022,"body":"note 22","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2023,"body":"note 23","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2024,"body":"note 24","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2025,"body":"note 25","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2026,"body":"note 26","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2027,"body":"note 27","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2028,"body":"note 28","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2029,"body":"note 29","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2030,"body":"note 30","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2031,"body":"note 31","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2032,"body":"note 32","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2033,"body":"note 33","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2034,"body":"note 34","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2035,"body":"note 35","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2036,"body":"note 36","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2037,"body":"note 37","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2038,"body":"note 38","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2039,"body":"note 39","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2040,"body":"note 40","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2041,"body":"note 41","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2042,"body":"note 42","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2043,"body":"note 43","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2044,"body":"note 44","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2045,"body":"note 45","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2046,"body":"note 46","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2047,"body":"note 47","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2048,"body":"note 48","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2049,"body":"note 49","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2050,"body":"note 50","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2051,"body":"note 51","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2052,"body":"note 52","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2053,"body":"note 53","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2054,"body":"note 54","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2055,"body":"note 55","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2056,"body":"note 56","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2057,"body":"note 57","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2058,"body":"note 58","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2059,"body":"note 59","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2060,"body":"note 60","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2061,"body":"note 61","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2062,"body":"note 62","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2063,"body":"note 63","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2064,"body":"note 64","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2065,"body":"note 65","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2066,"body":"note 66","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2067,"body":"note 67","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2068,"body":"note 68","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2069,"body":"note 69","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2070,"body":"note 70","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2071,"body":"note 71","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2072,"body":"note 72","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2073,"body":"note 73","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2074,"body":"note 74","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2075,"body":"note 75","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2076,"body":"note 76","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2077,"body":"note 77","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2078,"body":"note 78","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2079,"body":"note 79","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2080,"body":"note 80","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2081,"body":"note 81","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2082,"body":"note 82","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2083,"body":"note 83","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2084,"body":"note 84","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2085,"body":"note 85","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2086,"body":"note 86","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2087,"body":"note 87","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2088,"body":"note 88","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2089,"body":"note 89","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2090,"body":"note 90","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2091,"body":"note 91","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2092,"body":"note 92","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2093,"body":"note 93","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2094,"body":"note 94","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2095,"body":"note 95","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2096,"body":"note 96","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2097,"body":"note 97","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2098,"body":"note 98","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2099,"body":"note 99","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2100,"body":"note 100","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false}]
[{"id":2101,"body":"note 101","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2102,"body":"note 102","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false},{"id":2103,"body":"note 103","author":{"id":51,"username":"Ada"},"created_at":"2026-11-02T09:00:00.000Z","updated_at":"2026-11-02T09:00:00.000Z","system":false}]
[{"id": 52, "username": "Bob", "name": "Bob Builder", "state": "active", "web_url": "https://gitlab.com/Bob"}]
[]
200 _answers/users-bob.json 201 _answers/issue-created-assigned.json
{"in":"Acme/Shed","title":"For Bob","body":"","key":"8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05","assignee":"bob"}
{"id":"Acme/Shed#10","title":"For Bob","url":"https://gitlab.com/Acme/Shed/-/issues/10"}
GET https://gitlab.com/api/v4/users?username=bob
config: header = "PRIVATE-TOKEN: <token>"
POST https://gitlab.com/api/v4/projects/Acme%2FShed/issues
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"title":"For Bob","description":"<!-- yardr-thread 8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05 -->","assignee_id":52}
assignee bob&admin=true: want a GitLab username
{"in":"Acme/Shed","title":"x","body":"","key":"k","assignee":"bob&admin=true"}
200 _answers/users-bob.json 201 _answers/issue-created.json
jq: error (at <stdin>:1): issue was created, but GitLab did not assign bob; the account may not be allowed to assign in this project
{"in":"Acme/Shed","title":"For Bob","body":"","key":"8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05","assignee":"bob"}
GET https://gitlab.com/api/v4/users?username=bob
config: header = "PRIVATE-TOKEN: <token>"
POST https://gitlab.com/api/v4/projects/Acme%2FShed/issues
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"title":"For Bob","description":"<!-- yardr-thread 8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05 -->","assignee_id":52}
200 _answers/users-none.json
assignee nobody: GitLab knows no such user
{"in":"Acme/Shed","title":"For nobody","body":"","key":"8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05","assignee":"nobody"}
GET https://gitlab.com/api/v4/users?username=nobody config: header = "PRIVATE-TOKEN: <token>"
--in just-one: want group/project
{"in":"just-one","title":"x","body":"","key":"k"}
422 _answers/err-title.json
https://gitlab.com/api/v4/projects/Acme%2FShed/issues: HTTP 422: {"title":["can't be blank"]}
{"in":"Acme/Shed","title":"","body":"","key":"k"}
POST https://gitlab.com/api/v4/projects/Acme%2FShed/issues
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"title":"","description":"<!-- yardr-thread k -->"}
201 _answers/issue-created-nested.json
{"in":"acme/platform/shed","title":"Nested","body":"","key":"6b3f9a20-7c14-4e85-9d02-5a8e1c4f7b33"}
{"id":"acme/platform/shed#4","title":"Nested","url":"https://gitlab.com/acme/platform/shed/-/issues/4"}
POST https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/issues
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"title":"Nested","description":"<!-- yardr-thread 6b3f9a20-7c14-4e85-9d02-5a8e1c4f7b33 -->"}
201 _answers/issue-created.json
{"in":"Acme/Shed","title":"Check the gutters","body":"Before winter.\n/label ~bug","key":"4e1b7d3a-9c25-4f08-b6a1-3d5f0a7c8e90"}
{"id":"Acme/Shed#9","title":"Check the gutters","url":"https://gitlab.com/Acme/Shed/-/issues/9"}
POST https://gitlab.com/api/v4/projects/Acme%2FShed/issues
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"title":"Check the gutters","description":"Before winter.\n /label ~bug\n\n<!-- yardr-thread 4e1b7d3a-9c25-4f08-b6a1-3d5f0a7c8e90 -->"}
200 _answers/issues-found.json
{"in":"Acme/Shed","key":"8a2c6e41-0b7d-4d39-a5f2-1c3e9b7d6a05","since":"2026-11-02T10:00:00Z"}
{"id":"Acme/Shed#10","title":"For Bob","url":"https://gitlab.com/Acme/Shed/-/issues/10"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues?state=all&order_by=created_at&sort=desc&created_after=2026-11-02T09:59:00Z&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/issues-found.json
{"in":"Acme/Shed","key":"00000000-0000-4000-8000-000000000000","since":"yesterday"}
{}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues?state=all&order_by=created_at&sort=desc&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/notes-mixed.json
{"thread":"Acme/Shed#7","key":"0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11"}
{"id":"1250","url":"https://gitlab.com/Acme/Shed/-/issues/7#note_1250"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes?sort=asc&order_by=created_at&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/notes-mixed.json
{"thread":"acme/platform/shed!16","key":"00000000-0000-4000-8000-000000000000"}
{}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16/notes?sort=asc&order_by=created_at&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/notes-mixed.json
{"thread":"Acme/Shed#7","key":"5d0a7c10-2b6e-4f43-8a61-7c2e9b9d1f22"}
{}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes?sort=asc&order_by=created_at&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/notes-empty.json
{"thread":"Acme/Shed#7"}
{"messages":[]}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes?sort=asc&order_by=created_at&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/notes-mixed.json
{"thread":"Acme/Shed#7"}
{"messages":[{"id":"1241","parent":"","author":{"id":"ada","name":"Ada"},"at":"2026-11-02T09:30:40.000Z","body":"@Yardmaster can you look at this?","url":"https://gitlab.com/Acme/Shed/-/issues/7#note_1241"},{"id":"1250","parent":"","author":{"id":"yardmaster","name":"yardmaster"},"at":"2026-11-02T10:05:00.000Z","body":"On it.\n\n<!-- yardr-reply 0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11 -->","url":"https://gitlab.com/Acme/Shed/-/issues/7#note_1250"}]}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes?sort=asc&order_by=created_at&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/notes-mixed.json
{"thread":"acme/platform/shed!16"}
{"messages":[{"id":"1241","parent":"","author":{"id":"ada","name":"Ada"},"at":"2026-11-02T09:30:40.000Z","body":"@Yardmaster can you look at this?","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16#note_1241"},{"id":"1250","parent":"","author":{"id":"yardmaster","name":"yardmaster"},"at":"2026-11-02T10:05:00.000Z","body":"On it.\n\n<!-- yardr-reply 0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11 -->","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16#note_1250"}]}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16/notes?sort=asc&order_by=created_at&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/notes-page1.json 200 _answers/notes-page2.json
{"thread":"Acme/Shed#7"}
[103,"2001","2103"]
[(.messages | length), .messages[0].id, .messages[-1].id]
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes?sort=asc&order_by=created_at&per_page=100&page=1 config: header = "PRIVATE-TOKEN: <token>" GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes?sort=asc&order_by=created_at&per_page=100&page=2 config: header = "PRIVATE-TOKEN: <token>"
thread a/../b#7: want group/project#number for an issue or group/project!number for a merge request
{"thread":"a/../b#7","parent":"","body":"hello","key":"k"}
403 _answers/err-forbidden-echo.json
https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes: HTTP 403: 403 Forbidden - the token <token> may not write to this project
{"thread":"Acme/Shed#7","parent":"","body":"hello","key":"0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11"}
POST https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"body":"hello\n\n<!-- yardr-reply 0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11 -->"}
201 _answers/note-created.json
{"thread":"Acme/Shed#7","parent":"","body":"On it. The roof is next.","key":"0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11"}
{"id":"1250","url":"https://gitlab.com/Acme/Shed/-/issues/7#note_1250"}
POST https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"body":"On it. The roof is next.\n\n<!-- yardr-reply 0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11 -->"}
201 _answers/note-created-mr.json
{"thread":"acme/platform/shed!16","parent":"","body":"Looks right to me.","key":"0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11"}
{"id":"1251","url":"https://gitlab.com/acme/platform/shed/-/merge_requests/16#note_1251"}
POST https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16/notes
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"body":"Looks right to me.\n\n<!-- yardr-reply 0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11 -->"}
thread nonsense: want group/project#number for an issue or group/project!number for a merge request
{"thread":"nonsense","parent":"","body":"hello","key":"k"}
201 _answers/note-created.json
{"thread":"Acme/Shed#7","parent":"","key":"0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11","body":"Done.\r\n/close\n/assign @ada\n\n```\n/approve\n```\npath /usr/bin stays mid-line\n /indented is text\n\r/hidden behind a carriage return"}
{"id":"1250","url":"https://gitlab.com/Acme/Shed/-/issues/7#note_1250"}
POST https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7/notes
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"body":"Done.\n /close\n /assign @ada\n\n```\n /approve\n```\npath /usr/bin stays mid-line\n /indented is text\n /hidden behind a carriage return\n\n<!-- yardr-reply 0c9d3a52-6a0e-4a53-9f3e-0b1f4f2d8e11 -->"}
thread Acme/Shed#0: want group/project#number for an issue or group/project!number for a merge request
{"thread":"Acme/Shed#0","parent":"","body":"hello","key":"k"}
200 _answers/issue-closed.json
{"thread":"Acme/Shed#7","state":"started","forward":true}
{"status":"closed","moved":false}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/issue-opened.json 200 _answers/issue-put-closed.json
{"thread":"Acme/Shed#7","state":"done","forward":true}
{"status":"closed","moved":true,"was":"opened"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
PUT https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"state_event":"close"}
200 _answers/issue-opened.json
{"thread":"Acme/Shed#7","state":"started","forward":true}
{"status":"opened","moved":false}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/issue-opened.json 200 _answers/issue-put-closed.json
{"thread":"Acme/Shed#7","state":"done"}
{"status":"closed","moved":true,"was":"opened"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
PUT https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"state_event":"close"}
200 _answers/issue-closed.json 200 _answers/issue-put-opened.json
{"thread":"Acme/Shed#7","state":"started"}
{"status":"opened","moved":true,"was":"closed"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
PUT https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"state_event":"reopen"}
200 _answers/issue-closed.json
{"thread":"Acme/Shed#7","state":"done"}
{"status":"closed","moved":false}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/issue-opened.json 200 _answers/issue-put-labeled.json
{"thread":"Acme/Shed#7","state":"label:roof"}
{"status":"labeled roof","moved":true}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
PUT https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"add_labels":"roof"}
200 _answers/issue-opened.json
"a,b" is no label GitLab can add by name: it has a comma
{"thread":"Acme/Shed#7","state":"label:a,b"}
200 _answers/issue-opened.json
{"thread":"Acme/Shed#7","state":"label:BUG"}
{"status":"labeled bug","moved":false}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-opened.json 200 _answers/issue-put-labeled.json
{"thread":"acme/platform/shed!16","state":"label:roof"}
{"status":"labeled roof","moved":true}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16
config: header = "PRIVATE-TOKEN: <token>"
PUT https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"add_labels":"roof"}
200 _answers/issue-opened.json 200 _answers/issue-put-opened.json
GitLab answered, but Acme/Shed#7 is not closed
{"thread":"Acme/Shed#7","state":"done"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
PUT https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"state_event":"close"}
200 _answers/mr-opened.json 200 _answers/mr-put-closed.json
{"thread":"acme/platform/shed!16","state":"done"}
{"status":"closed","moved":true,"was":"opened"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16
config: header = "PRIVATE-TOKEN: <token>"
PUT https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16
config: header = "PRIVATE-TOKEN: <token>"
header: Content-Type: application/json
body: {"state_event":"close"}
200 _answers/mr-locked.json
{"thread":"acme/platform/shed!16","state":"done"}
{"status":"locked","moved":false}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-merged.json
{"thread":"acme/platform/shed!16","state":"done"}
{"status":"merged","moved":false}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/mr-merged.json
acme/platform/shed!16 is merged, and a merge request that is merged is not reopened
{"thread":"acme/platform/shed!16","state":"started"}
GET https://gitlab.com/api/v4/projects/acme%2Fplatform%2Fshed/merge_requests/16 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/nostate.json
the answer names no state for Acme/Shed#7
{"thread":"Acme/Shed#7","state":"done"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/7 config: header = "PRIVATE-TOKEN: <token>"
404 _answers/err-notfound.json
https://gitlab.com/api/v4/projects/Acme%2FShed/issues/99: HTTP 404: 404 Project Not Found
{"thread":"Acme/Shed#99","state":"done"}
GET https://gitlab.com/api/v4/projects/Acme%2FShed/issues/99 config: header = "PRIVATE-TOKEN: <token>"
200 _answers/issue-opened.json
an issue or merge request has no review status: the states it has are started and done
{"thread":"Acme/Shed#7","state":"review"}
200 _answers/issue-opened.json
"blocked" is no state here: want started, done
{"thread":"Acme/Shed#7","state":"blocked"}