#!/usr/bin/env sh # Scenario Book for Jira — pipeline sync with nothing but curl and the POSIX shell. # # Use this when you cannot run Node in the job. It does less than scenario-book-sync.mjs on purpose: pushing # the repo and uploading a report, which are the two directions that are one HTTP call each. **It does not # pull.** Pulling means reading a paged index out of Jira project properties, reassembling files from parts # and checking each one's hash before writing it — that is a real program, and a shell script that got any of # it slightly wrong would commit a quietly wrong feature file. Run the Node script for pull. # # Environment (see the Node script's header for where each comes from): # JIRA_BASE_URL JIRA_EMAIL JIRA_API_TOKEN JIRA_PROJECT — to read the answer afterwards # SB_SYNC_KEY SB_PUSH_URL SB_RESULTS_URL — the sync key and the two trigger URLs # SB_DIR optional, the folder holding your .feature files (default: the whole repo) # SB_LABEL optional, names this pipeline in the results (default "default") # # Usage: # ./scenario-book-sync.sh push # ./scenario-book-sync.sh results reports/cucumber.json # # set -u so a missing variable stops the job instead of sending an empty key; set -e so a failed curl does. set -eu say() { printf 'scenario-book: %s\n' "$1"; } die() { printf 'scenario-book: %s\n' "$1" >&2; exit 1; } need() { eval "value=\${$1:-}" [ -n "$value" ] || die "$1 is not set." } # jq does the JSON. Building JSON with sed would mean escaping feature-file text by hand, and a stray quote in # a scenario name would produce a body Scenario Book refuses with no idea why. command -v jq >/dev/null 2>&1 || die 'jq is needed. Install it, or use the Node script instead.' command -v curl >/dev/null 2>&1 || die 'curl is needed.' DIR="${SB_DIR:-.}" # The answer to any call is a project property, because a Forge static web trigger cannot return a body of its # own. Read with your Jira credentials, which is also why they are needed for a push. answer() { need JIRA_BASE_URL; need JIRA_EMAIL; need JIRA_API_TOKEN; need JIRA_PROJECT curl --silent --show-error --fail-with-body \ --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \ "$JIRA_BASE_URL/rest/api/3/project/$JIRA_PROJECT/properties/$1" 2>/dev/null \ | jq -r '.value | if .ok == false then "refused: " + (.error // "no reason given") else "written \(.written // 0), deleted \(.deleted // 0), unchanged \(.unchanged // 0)" + (if (.conflicts // []) | length > 0 then "\nconflicts: " + ((.conflicts | map(.name)) | join(", ")) else "" end) + (if (.unreadable // []) | length > 0 then "\nJira could not read: " + (.unreadable | join(", ")) else "" end) + (if (.needed // []) | length > 0 then "\nsend next time: " + (.needed | join(", ")) else "" end) end' 2>/dev/null || say "could not read the answer property $1" } # Every .feature file in the repo, as paths relative to SB_DIR. Sorted, so two runs send the same list. feature_list() { ( cd "$DIR" && find . -name '*.feature' -type f -not -path './.git/*' -not -path './node_modules/*' \ | sed 's|^\./||' | LC_ALL=C sort ) } push() { need SB_SYNC_KEY; need SB_PUSH_URL list=$(feature_list) if [ -z "$list" ]; then # Not a push. An empty path list tells Scenario Book the repo holds no feature files, and it would delete # every one of them in Jira — which is what pointing SB_DIR at the wrong folder would otherwise do. say "no .feature files found under $DIR. Nothing sent." return 0 fi count=$(printf '%s\n' "$list" | wc -l | tr -d ' ') [ "$count" -le 100 ] || die "$count feature files is more than one call carries (100). Use the Node script, which sends them in batches." commit=$(git rev-parse HEAD 2>/dev/null || echo '') # One jq invocation builds the whole body: the files with their text, and allPaths as the complete list of # what the repo holds. allPaths is what tells Scenario Book which files have been deleted, so it is never # left off. body=$( printf '%s\n' "$list" | jq -R . | jq -s --arg dir "$DIR" --arg commit "$commit" ' { commit: $commit, allPaths: ., files: [ .[] | { path: ., text: ($dir + "/" + .) } ] }' ) # jq cannot read files, so the text is substituted here, one file at a time, still through jq. # # `while read`, not `for path in $list`: an unquoted list word-splits on whitespace, so a feature file whose # path contains a space — which is legal, and which somebody will have — would be sent as two paths, both # of them wrong. The list is newline-separated for exactly this reason. while IFS= read -r path; do [ -n "$path" ] || continue body=$(printf '%s' "$body" | jq --arg p "$path" --rawfile t "$DIR/$path" ' .files = [ .files[] | if .path == $p then .text = $t else . end ]') done < Apps -> Scenario Book.' ;; 413) die 'that call was too big. Send fewer files, or use the Node script.' ;; 429) die 'too many calls. One call per key every five seconds.' ;; *) answer scenarioBook.lastPush; die "Scenario Book answered $status." ;; esac } results() { need SB_SYNC_KEY; need SB_RESULTS_URL file="${1:-}" [ -n "$file" ] || die 'results needs a report file: ./scenario-book-sync.sh results reports/cucumber.json' [ -r "$file" ] || die "cannot read $file." commit=$(git rev-parse HEAD 2>/dev/null || echo '') body=$(jq -n --arg label "${SB_LABEL:-default}" --arg commit "$commit" --rawfile report "$file" \ '{ label: $label, commit: $commit, report: $report }') status=$(printf '%s' "$body" | curl --silent --output /dev/null --write-out '%{http_code}' \ --request POST "$SB_RESULTS_URL" \ --header 'content-type: application/json' \ --header "x-scenario-book-key: $SB_SYNC_KEY" \ --data-binary @-) case "$status" in 200|202) need JIRA_BASE_URL say 'results: uploaded.' curl --silent --user "$JIRA_EMAIL:$JIRA_API_TOKEN" \ "$JIRA_BASE_URL/rest/api/3/project/$JIRA_PROJECT/properties/scenarioBook.lastResults" \ | jq -r '.value | if .ok == false then "refused: " + (.error // "") else "matched \(.matched), unmatched \(.unmatched)" + (if (.unmatchedNames // []) | length > 0 then "\nunmatched: " + (.unmatchedNames | join(", ")) else "" end) end' || true ;; 403) die 'the sync key is wrong, revoked, or for another project — or the license is not active.' ;; 409) die 'pipeline sync is off for this project.' ;; 413) die 'that report is too big. Send at most 4 MB.' ;; 429) die 'too many calls. One call per key every five seconds.' ;; *) die "Scenario Book answered $status." ;; esac } case "${1:-}" in push) push ;; results) results "${2:-}" ;; pull) die 'pull needs the Node script: node scenario-book-sync.mjs pull' ;; *) die 'Usage: scenario-book-sync.sh push | results ' ;; esac