Setting the file. One moment.
Gather Atlassian · Daily Recap · bitwarden/ai-plugins · Skills Docs
ContentsBack to the top of the page 46.10
README
Previous
Script Find Sessions
scripts/ gather-atlassian.sh
Shell · 639 lines · 28 KB
# DAILY_RECAP_CUTOFF_HOUR Local hour (0-23) at which a workday begins. Default: 7.
16 # TZ Standard timezone override, e.g. America/Los_Angeles.
17 # ATLASSIAN_CLOUD_ID Atlassian Cloud ID, used to build the gateway base URLs.
18 # ATLASSIAN_EMAIL Atlassian account email, used for basic auth.
19 # ATLASSIAN_JIRA_READ_ONLY_TOKEN Atlassian API token scoped for Jira read access.
20 # ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN Atlassian API token scoped for Confluence read access.
21 #
22 # Output (stdout): a JSON object with "jira" and "confluence" keys. The two are entirely
23 # independent: each is null (with its own stderr notice) when its credentials are unset
24 # or its identity check fails, regardless of whether the other succeeded -- the recap
25 # must not fail, or lose one service's data, because the other is unavailable.
26 #
27 # "jira" holds account_id, site_url, window, complete, and issues (each issue carries a
28 # returned flag when the user moved it backward in status within the window).
29 # "confluence" holds account_id, site_url, window, complete, and pages (each page: title,
30 # space_key, space_id, created, version_count, url) when present. The window line for
31 # both goes to stderr before either credential check, so a recap built without Atlassian
32 # still has it. "complete" is false, with a matching stderr warning, when the
33 # MAX_SEARCH_PAGES safety cap truncated that service's search before exhausting all pages.
34 #
35 # Requires: curl, jq, and GNU or BSD date. Never prints a token: auth for each service
36 # goes through its own private, mode-600 netrc file in a temp directory removed on exit,
37 # rather than on the curl command line where `ps` could see it.
38
39 # cspell:ignore issuekey strptime todateiso lastmodified tonumber
40
41 set -euo pipefail
42
43 SCRIPT_DIR = "$( cd "$( dirname "${ BASH_SOURCE [0]}")" && pwd )"
44 # shellcheck source=SCRIPTDIR/date-utils.sh
45 source "${ SCRIPT_DIR }/date-utils.sh"
46
47 die () {
48 echo "gather-atlassian: $* " >&2
49 exit 1
50 }
51
52 # Pages of search results, for either service, beyond this are not collected (safety cap
53 # against unbounded pagination on a very busy window). Overridable only so tests can force
54 # the cap without needing ten-plus pages of fixture data; not documented as a user-facing
55 # knob.
56 readonly MAX_SEARCH_PAGES = "${ DAILY_RECAP_MAX_SEARCH_PAGES :- 10 }"
57
58 command -v jq > /dev/null 2>&1 || die "'jq' is required but not installed"
59
60 cutoff_raw = "${ DAILY_RECAP_CUTOFF_HOUR :- 7 }"
61 if ! [[ " $cutoff_raw " =~ ^[0-9]{1,2}$ ]] || (( 10 #$cutoff_raw > 23 )); then
62 die "DAILY_RECAP_CUTOFF_HOUR must be a whole hour from 0 to 23 (got '${ cutoff_raw }')"
63 fi
64 CUTOFF_HOUR = $(( 10#$cutoff_raw ))
65 CUTOFF_HH = "$( printf '%02d' " $CUTOFF_HOUR ")"
66
67 TODAY = "$( date +%Y-%m-%d)"
68 if [[ $# -ge 1 ]]; then
69 DATE = " $1 "
70 is_valid_date " $DATE " || die "date must be a real calendar date in YYYY-MM-DD form (got '${ DATE }')"
71 elif (( 10 #$(date +% H) >= CUTOFF_HOUR)); then
72 DATE = "$( add_days " $TODAY " -1 )"
73 else
74 DATE = "$( add_days " $TODAY " -2 )"
75 fi
76
77 NEXT_DATE = "$( add_days " $DATE " 1 )"
78 START_EPOCH = "$( local_to_epoch "${ DATE } ${ CUTOFF_HH }:00:00")" \
79 || die "could not compute the window start for ${ DATE } ${ CUTOFF_HH }:00 local time"
80 END_EPOCH = "$( local_to_epoch "${ NEXT_DATE } ${ CUTOFF_HH }:00:00")" \
81 || die "could not compute the window end for ${ NEXT_DATE } ${ CUTOFF_HH }:00 local time"
82
83 START_UTC = "$( epoch_fmt_utc " $START_EPOCH " '%Y-%m-%dT%H:%M:%SZ')"
84 END_UTC = "$( epoch_fmt_utc " $END_EPOCH " '%Y-%m-%dT%H:%M:%SZ')"
85 TZ_LABEL = "$( epoch_fmt " $START_EPOCH " '%Z')"
86
87 echo "# Window: ${ START_UTC } -> ${ END_UTC } (${ CUTOFF_HH }:00 ${ TZ_LABEL } boundary)" >&2
88
89 # A day-widened JQL/CQL window, shared in spirit by both services: Atlassian query
90 # languages' date literals are evaluated in a tenant/user timezone that may not match TZ
91 # here, so both collectors widen their query bounds by a day on each side and then apply
92 # an exact epoch filter in jq afterward.
93 WIDE_START = "$( add_days " $DATE " -1 ) 00:00"
94 WIDE_END = "$( add_days " $NEXT_DATE " 1 ) 23:59"
95
96 # report_api_error LABEL RESPONSE_FILE -> prints the response's errorMessages (or
97 # errors) to stderr, if any, so a future failure is diagnosable without printing the
98 # whole body (which could echo back request content). Never touches credentials: the
99 # Atlassian error payload is JSON describing the request, not the auth headers. Jira's
100 # error shape (errorMessages array, errors object) is covered directly; Confluence v1/v2
101 # error shapes (message/errors array of objects) are not fully covered by this yet --
102 # failures still surface the HTTP status even when this extracts no message.
103 report_api_error () {
104 local label = " $1 " resp_file = " $2 "
105 local messages
106 messages = "$( jq -r '
107 [ (.errorMessages // [])[],
108 ((.errors // {}) | to_entries[]? | "\(.key): \(.value)")
109 ] | join("; ")
110 ' " $resp_file " 2> /dev/null || true )"
111 if [[ -n " $messages " ]]; then
112 echo "gather-atlassian: ${ label }: ${ messages }" >&2
113 fi
114 }
115
116 # write_netrc FILE TOKEN -> writes a mode-600 netrc entry for api.atlassian.com with the
117 # given token as the password, so the token never appears on a curl command line (where
118 # `ps` could see it) or in this script's own logging.
119 write_netrc () {
120 local file = " $1 " token = " $2 "
121 (
122 umask 077
123 {
124 echo "machine api.atlassian.com"
125 echo "login ${ ATLASSIAN_EMAIL }"
126 echo "password ${ token }"
127 } > " $file "
128 )
129 }
130
131 # curl_api NETRC BASE METHOD PATH OUT_FILE [DATA_FILE] -> prints the HTTP status code to
132 # stdout, writes the response body to OUT_FILE. METHOD is GET or POST. NETRC and BASE
133 # are passed explicitly so Jira and Confluence calls -- different tokens, different
134 # gateway paths, same host -- never share a netrc entry.
135 curl_api () {
136 local netrc = " $1 " base = " $2 " method = " $3 " path = " $4 " out = " $5 " data = " ${6 :- } "
137 local url = "${ base }${ path }"
138 if [[ -n " $data " ]]; then
139 curl -sS --netrc-file " $netrc " \
140 -H "Accept: application/json" -H "Content-Type: application/json" \
141 -X " $method " --data-binary "@${ data }" \
142 -o " $out " -w '%{http_code}' " $url "
143 else
144 curl -sS --netrc-file " $netrc " \
145 -H "Accept: application/json" \
146 -X " $method " \
147 -o " $out " -w '%{http_code}' " $url "
148 fi
149 }
150
151 WORK_DIR = "$( mktemp -d "${ TMPDIR :-/ tmp }/daily-recap-atlassian.XXXXXX")"
152 trap 'rm -rf "$WORK_DIR"' EXIT INT TERM HUP
153
154 # jira_ts_to_epoch "2026-10-20T10:00:00.000+0000" -> epoch seconds.
155 # Jira's own timestamps carry milliseconds and a numeric (non-colon) UTC offset, which
156 # neither GNU nor BSD date parses with the Z-only helpers in date-utils.sh.
157 jira_ts_to_epoch () {
158 local ts = " $1 "
159 date -u -d " $ts " +%s 2> /dev/null && return
160 date -j -f '%Y-%m-%dT%H:%M:%S%z' "$( sed -E 's/\.[0-9]+//' <<< " $ts ")" +%s 2> /dev/null
161 }
162
163 # confluence_ts_to_epoch "2026-10-20T10:00:00.000Z" -> epoch seconds.
164 # Confluence's v2 timestamps carry milliseconds before the Z, which BSD date's strict
165 # formatter cannot parse directly.
166 confluence_ts_to_epoch () {
167 local ts = " $1 "
168 date -u -d " $ts " +%s 2> /dev/null && return
169 date -j -u -f '%Y-%m-%dT%H:%M:%S' "$( sed -E 's/\.[0-9]+Z$//' <<< " $ts ")" +%s 2> /dev/null
170 }
171
172 # =============================================================================
173 # Jira
174 # =============================================================================
175 collect_jira () {
176 if [[ -z "${ ATLASSIAN_CLOUD_ID :- }" || -z "${ ATLASSIAN_EMAIL :- }" || -z "${ ATLASSIAN_JIRA_READ_ONLY_TOKEN :- }" ]]; then
177 echo "gather-atlassian: Jira credentials not set; skipping Jira" >&2
178 echo 'null'
179 return 0
180 fi
181
182 command -v curl > /dev/null 2>&1 || die "'curl' is required but not installed"
183
184 skip_jira () {
185 echo "gather-atlassian: $* ; skipping Jira" >&2
186 echo 'null'
187 exit 0
188 }
189
190 local BASE_URL = "https://api.atlassian.com/ex/jira/${ ATLASSIAN_CLOUD_ID }"
191 local NETRC_FILE = "${ WORK_DIR }/jira-netrc"
192 write_netrc " $NETRC_FILE " " $ATLASSIAN_JIRA_READ_ONLY_TOKEN "
193
194 # ---------------------------------------------------------------------------
195 # Identity check. /search/jql can return 200 on bad credentials, so verify who we are
196 # first via /myself.
197 # ---------------------------------------------------------------------------
198 local MYSELF_FILE = "${ WORK_DIR }/myself.json"
199 local status
200 status = "$( curl_api " $NETRC_FILE " " $BASE_URL " GET "/rest/api/3/myself" " $MYSELF_FILE ")" || status = "000"
201 if [[ ! " $status " =~ ^2 ]]; then
202 report_api_error "identity check" " $MYSELF_FILE "
203 skip_jira "Jira identity check failed (HTTP ${ status })"
204 fi
205 local ACCOUNT_ID
206 ACCOUNT_ID = "$( jq -r '.accountId // empty' " $MYSELF_FILE " 2> /dev/null || true )"
207 if [[ -z " $ACCOUNT_ID " ]]; then
208 skip_jira "Jira identity check returned no accountId"
209 fi
210
211 # Best-effort tenant hostname for human-readable browse URLs. Not fatal if it fails;
212 # issues just carry a null url.
213 local SITE_URL = ""
214 local SERVER_INFO_FILE = "${ WORK_DIR }/server-info.json"
215 if status = "$( curl_api " $NETRC_FILE " " $BASE_URL " GET "/rest/api/3/serverInfo" " $SERVER_INFO_FILE " 2> /dev/null)" && [[ " $status " =~ ^2 ]]; then
216 SITE_URL = "$( jq -r '.baseUrl // empty' " $SERVER_INFO_FILE " 2> /dev/null || true )"
217 fi
218
219 # ---------------------------------------------------------------------------
220 # Search for candidate issues: assigned to or reported by the user, status-transitioned
221 # by the user, or otherwise updated (which includes commenting) by the user, within a
222 # day-widened window (Jira JQL dates are in the user's Jira timezone, which may not
223 # match TZ here -- widened bounds plus an exact epoch filter in jq below cover that).
224 #
225 # `status CHANGED BY ... DURING (...)` and `issuekey IN updatedBy(...)` are what
226 # surface issues the user transitioned or commented on without being assignee or
227 # reporter -- validated live against Jira Cloud's /search/jql gateway endpoint.
228 # updatedBy() is broader than comments alone (any field edit by the user), but the
229 # per-issue comment fetch below filters precisely to the user's own in-window comments,
230 # so the extra candidates it pulls in just cost a comment fetch, not a false positive.
231 # ---------------------------------------------------------------------------
232 local JQL = "(assignee = \" ${ ACCOUNT_ID } \" OR reporter = \" ${ ACCOUNT_ID } \" OR status CHANGED BY \" ${ ACCOUNT_ID } \" DURING ( \" ${ WIDE_START } \" , \" ${ WIDE_END } \" ) OR issuekey IN updatedBy( \" ${ ACCOUNT_ID } \" , \" ${ WIDE_START } \" , \" ${ WIDE_END } \" )) AND updated >= \" ${ WIDE_START } \" ORDER BY updated DESC"
233
234 local FIELDS_JSON = '["summary","status","created","updated","reporter","assignee"]'
235
236 local ISSUES_PAGES_DIR = "${ WORK_DIR }/pages"
237 mkdir -p " $ISSUES_PAGES_DIR "
238
239 local page = 1 next_token = "" body_file resp_file is_last complete = true
240 while ((page <= MAX_SEARCH_PAGES)); do
241 body_file = "${ WORK_DIR }/search-body-${ page }.json"
242 # `expand` must be the comma-joined string form ("changelog"), not a JSON array, on
243 # POST /rest/api/3/search/jql -- an array there is rejected with a bare HTTP 400
244 # "Invalid request payload" (confirmed live against the gateway endpoint).
245 if [[ -z " $next_token " ]]; then
246 jq -n --arg jql " $JQL " --argjson fields " $FIELDS_JSON " \
247 '{jql: $jql, fields: $fields, expand: "changelog", maxResults: 50}' > " $body_file "
248 else
249 jq -n --arg jql " $JQL " --argjson fields " $FIELDS_JSON " --arg token " $next_token " \
250 '{jql: $jql, fields: $fields, expand: "changelog", maxResults: 50, nextPageToken: $token}' > " $body_file "
251 fi
252
253 resp_file = "${ WORK_DIR }/search-resp-${ page }.json"
254 status = "$( curl_api " $NETRC_FILE " " $BASE_URL " POST "/rest/api/3/search/jql" " $resp_file " " $body_file ")" || status = "000"
255 if [[ ! " $status " =~ ^2 ]]; then
256 report_api_error "search" " $resp_file "
257 skip_jira "Jira search failed (HTTP ${ status })"
258 fi
259
260 jq '.issues // []' " $resp_file " > "${ ISSUES_PAGES_DIR }/page-${ page }.json"
261
262 is_last = "$( jq -r 'if .isLast == false then "false" else "true" end' " $resp_file ")"
263 next_token = "$( jq -r '.nextPageToken // empty' " $resp_file ")"
264 if [[ " $is_last " == "true" || -z " $next_token " ]]; then
265 break
266 fi
267 page = $(( page + 1 ))
268 done
269
270 if [[ " $is_last " == "false" && -n " $next_token " ]]; then
271 echo "gather-atlassian: the Jira search hit the ${ MAX_SEARCH_PAGES }-page safety cap with more results remaining; some issues may be missing." >&2
272 complete = false
273 fi
274
275 local ISSUES_ALL = "${ WORK_DIR }/issues-all.json"
276 jq -s 'add // []' "${ ISSUES_PAGES_DIR }"/page- * .json > " $ISSUES_ALL "
277
278 # ---------------------------------------------------------------------------
279 # Attach the user's comments per candidate issue (single page per issue is enough for
280 # one workday's worth of activity, since we request newest-first ordering so the
281 # first page always holds the most recent comments).
282 # ---------------------------------------------------------------------------
283 local MERGED = "${ WORK_DIR }/merged.json"
284 jq -c '.[]' " $ISSUES_ALL " | while IFS = read -r issue ; do
285 key = "$( jq -r '.key' <<< " $issue ")"
286 comment_file = "${ WORK_DIR }/comments-${ key }.json"
287 c_status = "$( curl_api " $NETRC_FILE " " $BASE_URL " GET "/rest/api/3/issue/${ key }/comment?maxResults=100&orderBy=-created" " $comment_file ")" || c_status = "000"
288 # Slim the comment payload down to the two fields the final jq pass below actually
289 # consumes (created, author.accountId) and pass it through a file via --slurpfile
290 # rather than argv: a busy issue's full comment history can exceed Linux's
291 # MAX_ARG_STRLEN (131072 bytes) for a single argv string, which fails execve with
292 # E2BIG -- silently, since this loop runs inside collect_jira's $(...) subshell
293 # where errexit is disabled.
294 comment_slim_file = "${ WORK_DIR }/comments-slim-${ key }.json"
295 if [[ " $c_status " =~ ^2 ]]; then
296 jq -c '[.comments[]? | {created, author: {accountId: .author.accountId}}]' " $comment_file " \
297 > " $comment_slim_file " || echo '[]' > " $comment_slim_file "
298 else
299 echo '[]' > " $comment_slim_file "
300 fi
301 jq -c --slurpfile c " $comment_slim_file " '. + {comments: $c[0]}' <<< " $issue "
302 done > "${ WORK_DIR }/merged.ndjson"
303 jq -s '.' "${ WORK_DIR }/merged.ndjson" > " $MERGED "
304
305 # ---------------------------------------------------------------------------
306 # Convert every timestamp we might need (changelog entries, issue created/updated,
307 # comment created) to epoch seconds once, in bash, so window comparisons do not rely
308 # on jq's platform-dependent strptime.
309 # ---------------------------------------------------------------------------
310 local TIMESTAMPS_FILE = "${ WORK_DIR }/timestamps.txt"
311 jq -r '
312 [ .[]
313 | .fields.created, .fields.updated,
314 (.changelog.histories[]?.created), (.comments[]?.created)
315 ] | map(select(. != null)) | unique | .[]
316 ' " $MERGED " > " $TIMESTAMPS_FILE "
317
318 local TS_MAP_FILE = "${ WORK_DIR }/ts_map.json"
319 local TS_EPOCH_PAIRS = "${ WORK_DIR }/ts_epoch_pairs.tsv"
320 : > " $TS_EPOCH_PAIRS "
321 local ts epoch
322 while IFS = read -r ts ; do
323 [[ -z " $ts " ]] && continue
324 epoch = "$( jira_ts_to_epoch " $ts " 2> /dev/null || true )"
325 printf '%s\t%s\n' " $ts " " $epoch " >> " $TS_EPOCH_PAIRS "
326 done < " $TIMESTAMPS_FILE "
327
328 jq -Rn '
329 [ inputs
330 | select(. != "")
331 | split("\t")
332 | {key: .[0], value: (if (.[1] // "") == "" then null else (.[1] | tonumber) end)}
333 ] | from_entries
334 ' " $TS_EPOCH_PAIRS " > " $TS_MAP_FILE "
335
336 local FINAL_JQ = "${ WORK_DIR }/final.jq"
337 cat > " $FINAL_JQ " << 'JQ_PROGRAM'
338 # Coarse status-category ranking used only to detect a backward ("returned") status
339 # transition from changelog fromString/toString names, which carry no category info
340 # of their own. Unrecognized names are treated as an in-progress-like middle rank.
341 def rank(name):
342 if name == null then 1
343 elif (name == "To Do" or name == "Open" or name == "Backlog" or name == "New" or name == "Blocked") then 0
344 elif (name == "In Review" or name == "In QA" or name == "QA" or name == "Ready for Review" or name == "In Testing" or name == "Code Review") then 2
345 elif (name == "Done" or name == "Closed" or name == "Resolved") then 3
346 else 1
347 end;
348
349 def epoch_of(ts): if ts == null then null else ($ts_map_file[0][ts] // null) end;
350
351 def in_window(ts): (epoch_of(ts)) as $e | ($e != null and $e >= $start_epoch and $e < $end_epoch);
352
353 ( [ .[]
354 | . as $issue
355 | ($issue.changelog.histories // []) as $histories
356 | ($issue.comments // []) as $comments
357 | {
358 key: $issue.key,
359 summary: ($issue.fields.summary // ""),
360 status: ($issue.fields.status.name // null),
361 status_category: ($issue.fields.status.statusCategory.name // null),
362 url: (if $site_url != "" then ($site_url + "/browse/" + $issue.key) else null end),
363 reporter_id: ($issue.fields.reporter.accountId // null),
364 assignee_id: ($issue.fields.assignee.accountId // null),
365 created_in_window: in_window($issue.fields.created),
366 updated_in_window: in_window($issue.fields.updated),
367 transitions: [
368 $histories[]
369 | select(.author.accountId == $account_id)
370 | select(in_window(.created))
371 | . as $h
372 | .items[]?
373 | select(.field == "status")
374 | {from: .fromString, to: .toString, at: (epoch_of($h.created) | todateiso8601)}
375 ],
376 comment_count: ([
377 $comments[]
378 | select(.author.accountId == $account_id)
379 | select(in_window(.created))
380 ] | length)
381 }
382 ]
383 # Transitions TO "Blocked" never count as "returned": blocking your own ticket
384 # isn't the same as a reviewer bouncing it back, regardless of rank.
385 | map(. + {returned: ([.transitions[] | select(rank(.to) < rank(.from) and .to != "Blocked")] | length > 0)})
386 ) as $enriched
387 | {
388 account_id: $account_id,
389 site_url: (if $site_url != "" then $site_url else null end),
390 window: { start_utc: $start_utc, end_utc: $end_utc },
391 complete: $complete,
392 issues: [
393 $enriched[]
394 | {
395 key: .key,
396 summary: .summary,
397 status: .status,
398 status_category: .status_category,
399 url: .url,
400 involvement: (
401 [ (if (.created_in_window and .reporter_id == $account_id) then "created" else empty end),
402 (if (.transitions | length) > 0 then "transitioned" else empty end),
403 (if .comment_count > 0 then "commented" else empty end),
404 (if (.assignee_id == $account_id and .updated_in_window) then "assigned" else empty end)
405 ]
406 ),
407 transitions: .transitions,
408 comment_count: .comment_count,
409 returned: .returned
410 }
411 | select((.involvement | length) > 0)
412 ]
413 }
414 JQ_PROGRAM
415
416 jq -f " $FINAL_JQ " \
417 --slurpfile ts_map_file " $TS_MAP_FILE " \
418 --arg account_id " $ACCOUNT_ID " \
419 --arg site_url "${ SITE_URL }" \
420 --argjson start_epoch " $START_EPOCH " \
421 --argjson end_epoch " $END_EPOCH " \
422 --arg start_utc " $START_UTC " \
423 --arg end_utc " $END_UTC " \
424 --argjson complete " $complete " \
425 " $MERGED "
426 }
427
428 # =============================================================================
429 # Confluence
430 # =============================================================================
431 collect_confluence () {
432 if [[ -z "${ ATLASSIAN_CLOUD_ID :- }" || -z "${ ATLASSIAN_EMAIL :- }" || -z "${ ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN :- }" ]]; then
433 echo "gather-atlassian: Confluence credentials not set; skipping Confluence" >&2
434 echo 'null'
435 return 0
436 fi
437
438 command -v curl > /dev/null 2>&1 || die "'curl' is required but not installed"
439
440 skip_confluence () {
441 echo "gather-atlassian: $* ; skipping Confluence" >&2
442 echo 'null'
443 exit 0
444 }
445
446 # The gateway's Confluence path includes the "/wiki" segment the product's own REST
447 # API lives under, same as a self-managed site's https://<site>/wiki.
448 local BASE_URL = "https://api.atlassian.com/ex/confluence/${ ATLASSIAN_CLOUD_ID }/wiki"
449 local NETRC_FILE = "${ WORK_DIR }/confluence-netrc"
450 write_netrc " $NETRC_FILE " " $ATLASSIAN_CONFLUENCE_READ_ONLY_TOKEN "
451
452 # ---------------------------------------------------------------------------
453 # Identity check, applying the same precaution as Jira's /myself: verify who we are
454 # before trusting any search results, in case a bad-credentials request still returns
455 # a usable-looking 200.
456 # ---------------------------------------------------------------------------
457 local MYSELF_FILE = "${ WORK_DIR }/confluence-myself.json"
458 local status
459 status = "$( curl_api " $NETRC_FILE " " $BASE_URL " GET "/rest/api/user/current" " $MYSELF_FILE ")" || status = "000"
460 if [[ ! " $status " =~ ^2 ]]; then
461 report_api_error "Confluence identity check" " $MYSELF_FILE "
462 skip_confluence "Confluence identity check failed (HTTP ${ status })"
463 fi
464 local ACCOUNT_ID
465 ACCOUNT_ID = "$( jq -r '.accountId // empty' " $MYSELF_FILE " 2> /dev/null || true )"
466 if [[ -z " $ACCOUNT_ID " ]]; then
467 skip_confluence "Confluence identity check returned no accountId"
468 fi
469
470 # ---------------------------------------------------------------------------
471 # CQL search (v1 content search) for candidate pages the user contributed to in a
472 # day-widened window. "contributor" covers both the page creator and anyone who has
473 # edited it, so each candidate is confirmed against its version history below before
474 # being counted as this user's activity.
475 # ---------------------------------------------------------------------------
476 local CQL = "contributor = \" ${ ACCOUNT_ID } \" and type = page and lastmodified >= \" ${ WIDE_START } \" order by lastmodified desc"
477 local CQL_ENCODED
478 CQL_ENCODED = "$( jq -rn --arg c " $CQL " '$c | @uri')"
479
480 local CONFLUENCE_PAGES_DIR = "${ WORK_DIR }/confluence-pages"
481 mkdir -p " $CONFLUENCE_PAGES_DIR "
482
483 local SITE_BASE = ""
484 local page = 1 next_path resp_file next_href complete = true
485 next_path = "/rest/api/search?cql=${ CQL_ENCODED }&limit=25&expand=content.version,content.space,content.history"
486 while ((page <= MAX_SEARCH_PAGES)) && [[ -n " $next_path " ]]; do
487 resp_file = "${ WORK_DIR }/confluence-search-${ page }.json"
488 # The expand value here is a comma-joined query-string value, not a JSON array, by
489 # construction -- GET query parameters are always strings -- so the array-vs-string
490 # 400 Jira's POST /search/jql hits does not apply to this GET call.
491 status = "$( curl_api " $NETRC_FILE " " $BASE_URL " GET " $next_path " " $resp_file ")" || status = "000"
492 if [[ ! " $status " =~ ^2 ]]; then
493 report_api_error "Confluence search" " $resp_file "
494 skip_confluence "Confluence search failed (HTTP ${ status })"
495 fi
496
497 if [[ " $page " -eq 1 ]]; then
498 SITE_BASE = "$( jq -r '._links.base // empty' " $resp_file " 2> /dev/null || true )"
499 fi
500
501 jq '.results // []' " $resp_file " > "${ CONFLUENCE_PAGES_DIR }/page-${ page }.json"
502
503 next_href = "$( jq -r '._links.next // empty' " $resp_file " 2> /dev/null || true )"
504 if [[ -z " $next_href " ]]; then
505 next_path = ""
506 else
507 next_path = " $next_href "
508 fi
509 page = $(( page + 1 ))
510 done
511
512 if [[ -n " $next_path " ]]; then
513 echo "gather-atlassian: the Confluence search hit the ${ MAX_SEARCH_PAGES }-page safety cap with more results remaining; some pages may be missing." >&2
514 complete = false
515 fi
516
517 local CONFLUENCE_ALL = "${ WORK_DIR }/confluence-all.json"
518 jq -s 'add // []' "${ CONFLUENCE_PAGES_DIR }"/page- * .json > " $CONFLUENCE_ALL "
519
520 # ---------------------------------------------------------------------------
521 # spaceId resolution. Confluence v2 endpoints (the page version-history call below)
522 # key on the numeric spaceId, not the space key the v1 CQL search above returns, so
523 # each distinct space key found among the candidates is resolved once via
524 # GET /api/v2/spaces?keys=<key> and cached for the rest of this run.
525 # ---------------------------------------------------------------------------
526 local SPACE_ID_MAP_FILE = "${ WORK_DIR }/space-id-map.json"
527 echo '{}' > " $SPACE_ID_MAP_FILE "
528
529 resolve_space_id () {
530 local key = " $1 "
531 [[ -z " $key " ]] && return 0
532 local cached
533 cached = "$( jq -r --arg k " $key " '.[$k] // empty' " $SPACE_ID_MAP_FILE " 2> /dev/null || true )"
534 if [[ -n " $cached " ]]; then
535 echo " $cached "
536 return 0
537 fi
538 local resp = "${ WORK_DIR }/confluence-space-${ key }.json"
539 local s id = ""
540 s = "$( curl_api " $NETRC_FILE " " $BASE_URL " GET "/api/v2/spaces?keys=${ key }" " $resp ")" || s = "000"
541 if [[ " $s " =~ ^2 ]]; then
542 id = "$( jq -r '.results[0].id // empty' " $resp " 2> /dev/null || true )"
543 else
544 report_api_error "Confluence space lookup for ${ key }" " $resp "
545 fi
546 jq --arg k " $key " --arg v " $id " '.[$k] = $v' " $SPACE_ID_MAP_FILE " > "${ SPACE_ID_MAP_FILE }.tmp" \
547 && mv "${ SPACE_ID_MAP_FILE }.tmp" " $SPACE_ID_MAP_FILE "
548 echo " $id "
549 }
550
551 # fetch_version_matches PAGE_ID -> the page's v2 version-history results (JSON array),
552 # or "[]" on a fetch failure for that page. A single page's version fetch failing does
553 # not fail the whole collector, matching the reference collector's per-page tolerance.
554 fetch_version_matches () {
555 local page_id = " $1 "
556 local resp = "${ WORK_DIR }/confluence-versions-${ page_id }.json"
557 local s
558 s = "$( curl_api " $NETRC_FILE " " $BASE_URL " GET "/api/v2/pages/${ page_id }/versions?limit=50" " $resp ")" || s = "000"
559 if [[ ! " $s " =~ ^2 ]]; then
560 echo "gather-atlassian: Confluence version fetch failed for page ${ page_id } (HTTP ${ s }); skipping page" >&2
561 echo "[]"
562 return 0
563 fi
564 jq -c '.results // []' " $resp " 2> /dev/null || echo "[]"
565 }
566
567 # ---------------------------------------------------------------------------
568 # Per-candidate confirmation: only pages where this account authored at least one
569 # version inside the window are included, same confirm-via-version-history gate the
570 # reference Python collector uses to filter the CQL search's broader "contributor"
571 # match down to this user's actual in-window edits.
572 # ---------------------------------------------------------------------------
573 jq -c '.[]' " $CONFLUENCE_ALL " | while IFS = read -r item ; do
574 page_id = "$( jq -r '.content.id // empty' <<< " $item ")"
575 [[ -z " $page_id " ]] && continue
576 title = "$( jq -r '.content.title // ""' <<< " $item ")"
577 space_key = "$( jq -r '.content.space.key // ""' <<< " $item ")"
578 webui = "$( jq -r '.content._links.webui // ""' <<< " $item ")"
579
580 space_id = "$( resolve_space_id " $space_key ")"
581 versions_json = "$( fetch_version_matches " $page_id ")"
582
583 in_window_matches = "[]"
584 while IFS = read -r v ; do
585 [[ -z " $v " ]] && continue
586 author_id = "$( jq -r '.authorId // empty' <<< " $v ")"
587 [[ " $author_id " != " $ACCOUNT_ID " ]] && continue
588 created_at = "$( jq -r '.createdAt // empty' <<< " $v ")"
589 epoch = "$( confluence_ts_to_epoch " $created_at " 2> /dev/null || true )"
590 if [[ -n " $epoch " ]] && ((epoch >= START_EPOCH && epoch < END_EPOCH)); then
591 in_window_matches = "$( jq -c --argjson v " $v " '. + [$v]' <<< " $in_window_matches ")"
592 fi
593 done < <( jq -c '.[]' <<< " $versions_json ")
594
595 version_count = "$( jq 'length' <<< " $in_window_matches ")"
596 # Not confirmed as this user's edit within the window: the CQL "contributor" match
597 # was stale, out-of-window, or belonged to someone else despite the field name.
598 ((version_count == 0 )) && continue
599
600 created_flag = "$( jq '[ .[] | select(.number == 1) ] | length > 0' <<< " $in_window_matches ")"
601
602 page_url = " $webui "
603 if [[ -n " $SITE_BASE " && " $webui " != http * ]]; then
604 page_url = "${ SITE_BASE }${ webui }"
605 fi
606
607 jq -n --arg title " $title " --arg space_key " $space_key " --arg space_id " $space_id " \
608 --argjson created " $created_flag " --argjson version_count " $version_count " \
609 --arg url " $page_url " \
610 '{
611 title: $title,
612 space_key: $space_key,
613 space_id: (if $space_id == "" then null else $space_id end),
614 created: $created,
615 version_count: $version_count,
616 url: (if $url == "" then null else $url end)
617 }'
618 done > "${ WORK_DIR }/confluence-result.ndjson"
619
620 local PAGES_JSON
621 PAGES_JSON = "$( jq -s '.' "${ WORK_DIR }/confluence-result.ndjson")"
622
623 jq -n --arg account_id " $ACCOUNT_ID " --arg site_url "${ SITE_BASE }" \
624 --arg start_utc " $START_UTC " --arg end_utc " $END_UTC " --argjson pages " $PAGES_JSON " \
625 --argjson complete " $complete " \
626 '{
627 account_id: $account_id,
628 site_url: (if $site_url == "" then null else $site_url end),
629 window: { start_utc: $start_utc, end_utc: $end_utc },
630 complete: $complete,
631 pages: $pages
632 }'
633 }
634
635 JIRA_VALUE = "$( collect_jira )"
636 CONFLUENCE_VALUE = "$( collect_confluence )"
637
638 jq -n -c --argjson jira " $JIRA_VALUE " --argjson confluence " $CONFLUENCE_VALUE " \
639 '{jira: $jira, confluence: $confluence}'