-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathomarchy-gitlab-fetch
More file actions
executable file
·291 lines (269 loc) · 19.1 KB
/
Copy pathomarchy-gitlab-fetch
File metadata and controls
executable file
·291 lines (269 loc) · 19.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
#!/usr/bin/env bash
# Aggregate GitLab dashboard data for the Omarchy GitLab widget.
# Authentication is delegated entirely to the glab CLI credential store.
set -uo pipefail
include_archived=false
include_forks=false
include_archived_reviews=false
include_draft_reviews=false
project_scope="owned"
action_scan="recent"
action_limit=15
concurrency=6
failed_days=7
failed_limit=20
hostname="${GLAB_HOSTNAME:-gitlab.com}"
web_scheme="https"
mark_todo=""
mark_todos_done=""
usage() {
cat <<'EOF'
Usage: omarchy-gitlab-fetch [options]
--include-archived BOOL Include archived projects (default: false)
--include-forks BOOL Include projects that are forks (default: false)
--include-archived-reviews BOOL
Include review requests and assigned issues in
archived projects (default: false)
--include-draft-reviews BOOL Include review requests on draft merge requests
(default: false)
--project-scope MODE owned, or membership to also list the projects
you reach through groups (default: owned)
--action-scan MODE off, recent, or all (default: recent)
--action-repo-limit N Projects scanned in recent mode (default: 15)
--concurrency N Concurrent pipeline requests, 1-12 (default: 6)
--failed-days N Show failed pipelines from the last N days, 1-30 (default: 7)
--failed-limit N Maximum recent failed pipelines, 1-100 (default: 20)
--mark-todo-done ID Mark one todo done and exit
--mark-todos-done IDS Mark each listed todo done by ID and exit
(comma-separated IDs)
EOF
}
bool() { [[ ${1,,} == true || $1 == 1 || ${1,,} == yes || ${1,,} == on ]]; }
integer() { [[ $1 =~ ^[0-9]+$ ]]; }
while (($#)); do
case "$1" in
--include-archived) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; include_archived=$2; shift 2 ;;
--include-forks) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; include_forks=$2; shift 2 ;;
--include-archived-reviews) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; include_archived_reviews=$2; shift 2 ;;
--include-draft-reviews) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; include_draft_reviews=$2; shift 2 ;;
--project-scope) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; project_scope=${2,,}; shift 2 ;;
--action-scan) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; action_scan=${2,,}; shift 2 ;;
--action-repo-limit) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; action_limit=$2; shift 2 ;;
--concurrency) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; concurrency=$2; shift 2 ;;
--failed-days) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; failed_days=$2; shift 2 ;;
--failed-limit) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; failed_limit=$2; shift 2 ;;
--mark-todo-done) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; mark_todo=$2; shift 2 ;;
--mark-todos-done) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; mark_todos_done=$2; shift 2 ;;
--hostname) [[ $# -ge 2 ]] || { echo "missing value for $1" >&2; exit 2; }; hostname=$2; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
raw_hostname=$hostname
case "$raw_hostname" in
http://*) web_scheme="http" ;;
https://*) web_scheme="https" ;;
esac
hostname="${raw_hostname#http://}"
hostname="${hostname#https://}"
hostname="${hostname%/}"
if [[ "$hostname" == */* || -z "$hostname" || "$hostname" =~ [[:space:]] || ! "$hostname" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*(:[0-9]+)?$ ]]; then
jq -n --arg message "Invalid GitLab hostname. Use gitlab.example.com or https://gitlab.example.com/." \
'{schemaVersion:1,state:"error",message:$message,hostname:"",webBaseUrl:"",notifications:[],reviewRequests:[],assignedIssues:[],myPullRequests:[],myPullRequestsTotal:0,actions:[],failedActions:[],repositories:[],warnings:[]}'
exit 2
fi
web_base_url="$web_scheme://$hostname"
integer "$action_limit" && ((action_limit >= 1 && action_limit <= 500)) || { echo "invalid --action-repo-limit" >&2; exit 2; }
integer "$concurrency" && ((concurrency >= 1 && concurrency <= 12)) || { echo "invalid --concurrency" >&2; exit 2; }
integer "$failed_days" && ((failed_days >= 1 && failed_days <= 30)) || { echo "invalid --failed-days" >&2; exit 2; }
integer "$failed_limit" && ((failed_limit >= 1 && failed_limit <= 100)) || { echo "invalid --failed-limit" >&2; exit 2; }
[[ $action_scan == off || $action_scan == recent || $action_scan == all ]] || { echo "invalid --action-scan" >&2; exit 2; }
[[ $project_scope == owned || $project_scope == membership ]] || { echo "invalid --project-scope" >&2; exit 2; }
emit_state() {
jq -n --arg state "$1" --arg message "$2" --arg scope "$project_scope" --arg hostname "$hostname" --arg webBaseUrl "$web_base_url" '{schemaVersion:1,state:$state,message:$message,login:"",hostname:$hostname,webBaseUrl:$webBaseUrl,repositoryScope:$scope,fetchedAt:(now|todateiso8601),notifications:[],reviewRequests:[],assignedIssues:[],myPullRequests:[],myPullRequestsTotal:0,actions:[],failedActions:[],repositories:[],warnings:[],rateLimit:null}'
}
command -v jq >/dev/null 2>&1 || { printf '%s\n' '{"schemaVersion":1,"state":"error","message":"jq is required.","notifications":[],"reviewRequests":[],"assignedIssues":[],"myPullRequests":[],"myPullRequestsTotal":0,"actions":[],"failedActions":[],"repositories":[],"warnings":[]}' ; exit 0; }
if [[ -n $mark_todo && ! $mark_todo =~ ^[0-9]+$ ]]; then
jq -n --arg id "$mark_todo" '{state:"error",message:"Invalid todo id.",notificationId:$id}'
exit 2
fi
if [[ -n $mark_todo && -n $mark_todos_done ]]; then
jq -n '{state:"error",message:"Choose one todo marking operation."}'
exit 2
fi
command -v glab >/dev/null 2>&1 || { emit_state glab-not-installed "GitLab CLI is not installed or not on PATH."; exit 0; }
glab auth status --hostname "$hostname" >/dev/null 2>&1 || { emit_state logged-out "Sign in with glab auth login --hostname $hostname to load GitLab data."; exit 0; }
error_detail() {
tr '\n' ' ' <"$1" |
sed -E 's/(glpat-[A-Za-z0-9_=-]{20,})/[REDACTED]/g; s/(Authorization: *(token|Bearer) +)[^ ]+/\1[REDACTED]/Ig; s/[[:space:]]+/ /g; s/^ //; s/ $//' |
cut -c1-240
}
if [[ -n $mark_todo ]]; then
if ! error=$(mktemp); then
jq -n --arg id "$mark_todo" '{state:"error",message:"Could not prepare the todo request.",notificationId:$id}'
exit 1
fi
if glab api --hostname "$hostname" --method POST "todos/$mark_todo/mark_as_done" >/dev/null 2>"$error"; then
rm -f "$error"
jq -n --arg id "$mark_todo" '{state:"ready",message:"Todo marked done.",notificationId:$id}'
exit 0
fi
detail=$(error_detail "$error")
rm -f "$error"
jq -n --arg id "$mark_todo" --arg message "${detail:-Could not mark todo done.}" '{state:"error",message:$message,notificationId:$id}'
exit 1
fi
# GitLab has no bulk mark-as-done with a last-read watermark, so each displayed
# todo is marked individually by ID. Undisplayed and newly arrived todos are
# never touched. Failures are counted so a partial success is reported honestly
# instead of being presented as a clean success.
if [[ -n $mark_todos_done ]]; then
marked=0
failed=0
skipped=0
IFS=',' read -r -a ids <<< "$mark_todos_done"
for id in "${ids[@]}"; do
id="${id//[[:space:]]/}"
[[ -n $id ]] || continue
if [[ ! $id =~ ^[0-9]+$ ]]; then
skipped=$((skipped + 1))
continue
fi
if glab api --hostname "$hostname" --method POST "todos/$id/mark_as_done" >/dev/null 2>&1; then
marked=$((marked + 1))
else
failed=$((failed + 1))
fi
done
if [[ $failed -eq 0 && $skipped -eq 0 ]]; then
jq -n --argjson n "$marked" '{state:"ready",message:(if $n == 1 then "Todo marked done." else "All displayed todos marked done." end)}'
exit 0
fi
jq -n --argjson marked "$marked" --argjson failed "$failed" --argjson skipped "$skipped" '{state:"error",message:("Marked " + ($marked|tostring) + " of " + (($marked+$failed+$skipped)|tostring) + " todos done.")}'
exit 1
fi
if ! tmp=$(mktemp -d); then
emit_state error "Could not prepare temporary storage for the GitLab refresh."
exit 1
fi
trap 'rm -rf "$tmp"' EXIT
warnings="$tmp/warnings"
: >"$warnings"
api_error() {
local label=$1 err=$2 detail
detail=$(error_detail "$err")
[[ -n $detail ]] || detail="request failed"
printf '%s: %s\n' "$label" "$detail" >>"$warnings"
}
fetch_paginated() {
local label=$1 endpoint=$2 output=$3
local err="$tmp/$label.err"
if glab api --hostname "$hostname" --paginate "$endpoint" >"$output" 2>"$err"; then return 0; fi
api_error "$label" "$err"
return 1
}
# Canonicalise GitLab's ISO timestamp (with milliseconds) to the exact
# `YYYY-MM-DDTHH:MM:SSZ` form the panel's mark-all confirmation expects.
canonical_time() { jq -r '($in // "") | sub("\\.\\d+Z$"; "Z")' --arg in "$1"; }
# ---------------------------------------------------------------- login
login=$(glab api --hostname "$hostname" user 2>/dev/null | jq -r '.username // empty')
[[ -n $login ]] || login=""
# ---------------------------------------------------------------- todos
if fetch_paginated todos "todos?state=pending&per_page=100" "$tmp/todos.pages"; then
jq -s --arg base "$web_base_url" '[.[][] | select(.state == "pending") |
{id:(.id|tostring),reason:(.action_name // ""),updatedAt:(.created_at // "" | sub("\\.\\d+Z$"; "Z")),
repository:(.project.path_with_namespace // ""),title:(.target.title // (.body // "")),
type:(.target_type // ""),url:(.target.web_url // (.project.web_url // ($base + "/dashboard/todos")))}]
| sort_by(.updatedAt) | reverse' "$tmp/todos.pages" >"$tmp/notifications.json" 2>/dev/null || { printf '[]\n' >"$tmp/notifications.json"; printf '%s\n' 'todos: invalid API response' >>"$warnings"; }
else printf '[]\n' >"$tmp/notifications.json"; fi
# ---------------------------------------------------------------- review requests
# Merge requests where the current user is a reviewer. The project path is
# derived from references.full ("group/project!iid") so no per-MR project
# lookup is required.
review_query="merge_requests?scope=all&state=opened&reviewer_username=$login&per_page=100"
if fetch_paginated review-requests "$review_query" "$tmp/reviews.pages"; then
jq -s --argjson drafts "$(bool "$include_draft_reviews" && echo true || echo false)" '
[.[][] | select(($drafts or (.draft|not))) |
{id:(.id|tostring),number:(.iid // 0),title:(.title // ""),
repository:(.references.full // "" | sub("![0-9]+$"; "")),url:(.web_url // ""),
updatedAt:(.updated_at // ""),user:(.author.username // ""),draft:(.draft // false)}]
| sort_by(.updatedAt) | reverse' "$tmp/reviews.pages" >"$tmp/reviews.json" 2>/dev/null || { printf '[]\n' >"$tmp/reviews.json"; printf '%s\n' 'review-requests: invalid API response' >>"$warnings"; }
else printf '[]\n' >"$tmp/reviews.json"; fi
# ---------------------------------------------------------------- assigned issues
if fetch_paginated assigned-issues "issues?scope=all&state=opened&assignee_username=$login&per_page=100" "$tmp/issues.pages"; then
jq -s '[.[][] | {id:(.id|tostring),number:(.iid // 0),title:(.title // ""),
repository:(.references.full // "" | sub("#[0-9]+$"; "")),url:(.web_url // ""),
updatedAt:(.updated_at // ""),user:(.author.username // ""),draft:false}]
| sort_by(.updatedAt) | reverse' "$tmp/issues.pages" >"$tmp/issues.json" 2>/dev/null || { printf '[]\n' >"$tmp/issues.json"; printf '%s\n' 'assigned-issues: invalid API response' >>"$warnings"; }
else printf '[]\n' >"$tmp/issues.json"; fi
# ---------------------------------------------------------------- my merge requests
# The head pipeline's status is the GitLab analogue of the check rollup. Its
# status vocabulary is mapped to the same SUCCESS/FAILURE/PENDING/NONE states
# the panel already understands, so the check glyph and alarming logic are
# shared with the GitHub widget.
my_mr_query="merge_requests?scope=all&state=opened&author_username=$login&per_page=100"
if fetch_paginated my-merge-requests "$my_mr_query" "$tmp/mymrs.pages"; then
jq -s '[.[][] |
{id:(.id|tostring),number:(.iid // 0),title:(.title // ""),
repository:(.references.full // "" | sub("![0-9]+$"; "")),url:(.web_url // ""),
updatedAt:(.updated_at // ""),draft:(.draft // false),
checks:(if (.head_pipeline.status // "") == "success" then "SUCCESS"
elif (.head_pipeline.status // "") == "failed" then "FAILURE"
elif ([.head_pipeline.status // ""] | inside(["running","pending","created","waiting_for_resource","preparing"])) then "PENDING"
else "NONE" end)}]
| sort_by(.updatedAt) | reverse' "$tmp/mymrs.pages" >"$tmp/mypulls.json" 2>/dev/null || { printf '[]\n' >"$tmp/mypulls.json"; printf '%s\n' 'my-merge-requests: invalid API response' >>"$warnings"; }
else printf '[]\n' >"$tmp/mypulls.json"; fi
jq 'length' "$tmp/mypulls.json" >"$tmp/mypulls-total.json" 2>/dev/null || printf '0\n' >"$tmp/mypulls-total.json"
# ---------------------------------------------------------------- projects
# GitLab's REST project list carries open issue and star counts but not an
# open-merge-request count, so `prs` is left at zero (a known simplification).
projects_query="projects?per_page=100&order_by=updated_at&sort=desc"
if [[ $project_scope == owned ]]; then projects_query+="&owned=true"; else projects_query+="&membership=true"; fi
# GitLab treats `archived=true` as an archived-only filter. To include both
# active and archived projects, omit the parameter; only constrain the query
# when the user explicitly excludes archived projects.
if ! bool "$include_archived"; then projects_query+="&archived=false"; fi
if fetch_paginated projects "$projects_query" "$tmp/projects.pages"; then
jq -s '[.[][] | {id,name:(.name // ""),nameWithOwner:(.path_with_namespace // ""),url:(.web_url // ""),
archived:(.archived // false),fork:(.forked_from_project != null),stars:(.star_count // 0),
issues:(.open_issues_count // 0),prs:0,updatedAt:(.last_activity_at // .updated_at // ""),activeActions:0}]
| sort_by(.updatedAt) | reverse' "$tmp/projects.pages" >"$tmp/repos.json" 2>/dev/null || { printf '[]\n' >"$tmp/repos.json"; printf '%s\n' 'projects: invalid API response' >>"$warnings"; }
else printf '[]\n' >"$tmp/repos.json"; fi
if bool "$include_forks"; then fork_filter=true; else fork_filter=false; fi
jq --argjson forks "$fork_filter" '[.[]|select($forks or (.fork|not))]' "$tmp/repos.json" >"$tmp/repos.filtered" && mv "$tmp/repos.filtered" "$tmp/repos.json"
# ---------------------------------------------------------------- pipelines
# Running/pending pipelines are queried per project so a busy project cannot
# hide an active pipeline. Failed pipelines are server-bounded to the configured
# window. GitLab's pipeline object has no workflow name, so the ref stands in.
printf '[]\n' >"$tmp/actions.json"; printf '[]\n' >"$tmp/failed.json"
if [[ $action_scan != off && $(jq 'length' "$tmp/repos.json") -gt 0 ]]; then
if [[ $action_scan == recent ]]; then jq -r --argjson limit "$action_limit" 'sort_by(.updatedAt)|reverse|.[:$limit]|.[].nameWithOwner' "$tmp/repos.json" >"$tmp/scan-repos"; else jq -r '.[].nameWithOwner' "$tmp/repos.json" >"$tmp/scan-repos"; fi
cutoff_date=$(date -u -d "-$failed_days days" +%Y-%m-%d)
scan_one() {
local repo=$1 key prefix failed=false
key=$(printf '%s' "$repo"|sha256sum|cut -d' ' -f1); prefix="$tmp/action-$key"
: >"$prefix.active.pages"; : >"$prefix.completed.pages"; : >"$prefix.err"
local proj
proj=$(jq -r --arg p "$repo" '.[] | select(.nameWithOwner == $p) | .id' "$tmp/projects-ids.json" 2>/dev/null)
[[ -n $proj ]] || return 0
for scope in running pending; do
if ! glab api --hostname "$hostname" --paginate "projects/$proj/pipelines?scope=$scope&per_page=100" >>"$prefix.active.pages" 2>>"$prefix.err"; then failed=true; fi
done
if ! glab api --hostname "$hostname" --paginate "projects/$proj/pipelines?status=failed&updated_after=$cutoff_date&per_page=100" >"$prefix.completed.pages" 2>>"$prefix.err"; then failed=true; fi
jq -s --arg repo "$repo" '[.[][]|select(.status=="running" or .status=="pending" or .status=="created" or .status=="waiting_for_resource" or .status=="preparing")|{id:(.id|tostring),repository:$repo,name:"Pipeline",title:(.ref // ""),status:(.status // ""),conclusion:"",event:(.source // ""),branch:(.ref // ""),url:(.web_url // ""),createdAt:(.created_at // ""),updatedAt:(.updated_at // "")}]|unique_by(.id)' "$prefix.active.pages" >"$prefix.active.json" 2>/dev/null || printf '[]\n' >"$prefix.active.json"
jq -s --arg repo "$repo" '[.[][]|select(.status=="failed")|{id:(.id|tostring),repository:$repo,name:"Pipeline",title:(.ref // ""),status:"completed",conclusion:"failed",event:(.source // ""),branch:(.ref // ""),url:(.web_url // ""),createdAt:(.created_at // ""),updatedAt:(.updated_at // "")}]|unique_by(.id)' "$prefix.completed.pages" >"$prefix.completed.json" 2>/dev/null || printf '[]\n' >"$prefix.completed.json"
if [[ $failed == true ]]; then api_error "pipelines $repo" "$prefix.err"; fi
}
# Keep a name→id map for the project scan.
jq '[.[] | {id, nameWithOwner}]' "$tmp/repos.json" >"$tmp/projects-ids.json"
export tmp warnings cutoff_date hostname; export -f scan_one api_error error_detail
xargs -r -P "$concurrency" -I{} bash -c 'scan_one "$1"' _ {} <"$tmp/scan-repos"
jq -s 'add // []|unique_by(.id)|sort_by(.createdAt)|reverse' "$tmp"/action-*.active.json >"$tmp/actions.json" 2>/dev/null || printf '[]\n' >"$tmp/actions.json"
jq -s --argjson limit "$failed_limit" 'add // []|unique_by(.id)|sort_by(.updatedAt)|reverse|.[:$limit]' "$tmp"/action-*.completed.json >"$tmp/failed.json" 2>/dev/null || printf '[]\n' >"$tmp/failed.json"
jq --slurpfile actions "$tmp/actions.json" 'map(. as $repo|.activeActions=([$actions[0][]|select(.repository==$repo.nameWithOwner)]|length))' "$tmp/repos.json" >"$tmp/repos.next" && mv "$tmp/repos.next" "$tmp/repos.json"
fi
warnings_json=$(sort -u "$warnings"|jq -Rsc 'split("\n")|map(select(length>0))')
repo_count=$(jq 'length' "$tmp/repos.json")
state=ready; message=""
if [[ $repo_count -eq 0 && -s "$warnings" ]]; then state=error; message="GitLab projects could not be loaded."; fi
jq -n --arg state "$state" --arg message "$message" --arg login "${login:-}" --arg hostname "$hostname" --arg webBaseUrl "$web_base_url" --arg scope "$project_scope" --slurpfile notifications "$tmp/notifications.json" --slurpfile reviews "$tmp/reviews.json" --slurpfile issues "$tmp/issues.json" --slurpfile mypulls "$tmp/mypulls.json" --slurpfile mypullstotal "$tmp/mypulls-total.json" --slurpfile actions "$tmp/actions.json" --slurpfile failed "$tmp/failed.json" --slurpfile repositories "$tmp/repos.json" --argjson warnings "$warnings_json" '{schemaVersion:1,state:$state,message:$message,login:$login,hostname:$hostname,webBaseUrl:$webBaseUrl,repositoryScope:$scope,fetchedAt:(now|todateiso8601),notifications:$notifications[0],reviewRequests:$reviews[0],assignedIssues:$issues[0],myPullRequests:$mypulls[0],myPullRequestsTotal:$mypullstotal[0],actions:$actions[0],failedActions:$failed[0],repositories:$repositories[0],warnings:$warnings,rateLimit:null}'