#!/bin/bash
set -u
#==============================================================================
# Copyright and license info is available in the LICENSE file included with
# the Server Deployment Package (SDP), and also available online:
# https://swarm.workshop.perforce.com/projects/perforce-software-sdp/view/main/LICENSE
#------------------------------------------------------------------------------
# sync_jira_to_p4jobs.sh
#
# Sync JIRA Cloud issues (read-only) into the Perforce Public Depot P4 job
# database. JIRA is the source of truth; P4 jobs are a mirror only.
#
# For full documentation, run: sync_jira_to_p4jobs.sh -man
#==============================================================================
#==============================================================================
# Declarations and Environment
#==============================================================================
declare ThisScript="${0##*/}"
declare ThisHost=
declare ThisUser=
declare CmdLine="$0 $*"
# Script version. Versioning per standard omitted (pending Streams migration).
declare Version="1.0.0"
# Logging verbosity: 1=silent 2=errors 3=normal(default) 4=verbose 5=debug.
declare -i Verbosity=${VERBOSITY:-3}
# Error and warning counters.
declare -i ErrorCount=0
declare -i WarningCount=0
# NoOp: 1 = preview actions without applying them.
declare -i NoOp=0
# SilentMode: 1 = log only, no terminal output (for cron).
declare -i SilentMode=0
# Config file path (overridable with -C).
# Default is under $P4CSBIN (/p4/common/site/bin), the SDP-standard location
# for non-SDP scripts deployed in an SDP environment. $P4CBIN (/p4/common/bin)
# is reserved for scripts that are part of the SDP package itself.
declare ConfigFile="${P4CSBIN:-/p4/common/site/bin}/sync_jira_to_p4jobs.cfg"
# Log file. Conforms to SDP log naming standard: <Name>.<YYYY-MM-DD-HHMMSS>.log
# Overridable with -L; 'off' disables file logging.
declare LogTimestamp=
declare Log=
declare LogLink=
declare LogsDir="${LOGS:-/tmp}"
# Init mode: write example config and exit.
declare -i InitMode=0
# Optional project filter (restrict to one JIRA project key).
declare ProjectFilter=
#------------------------------------------------------------------------------
# Config defaults -- all overridden by the config file.
#
# Naming: Config-derived values that drive runtime behaviour use UpperCamelCase
# per the SDP global-variable convention. JIRA_* and P4* names that are also
# standard Perforce/SDP environment variables keep ALL_UPPERCASE.
#------------------------------------------------------------------------------
declare JiraUrl=
declare JiraUser=
declare JiraTokenFile=
declare JiraProjects=
declare -i JiraPageSize=50
declare -i JiraPollDelay=2
declare -i JiraRetryDelay=30
declare -i JiraMaxRetries=3
declare -i JiraCurlTimeout=30
declare -i InitialLookbackSeconds=2592000
declare P4PORT=
declare P4USER=
declare P4PasswdFile=
declare StateDir="${P4HOME:-/p4/1}/tmp/jira_sync"
# Associative array mapping JIRA project keys to Public Depot jobspec Project
# select values. Populated from the config file (JiraProjectMap lines).
# Example: JiraProjectMap[SDP]=perforce-software-sdp
declare -A JiraProjectMap
#------------------------------------------------------------------------------
# Runtime state -- not from config.
#------------------------------------------------------------------------------
declare JiraToken=
declare P4Passwd=
declare LockFile=
declare WatermarkFile=
declare CacheDir=
declare TmpJobFile=
declare -i SyncCount=0
declare -i FailCount=0
#==============================================================================
# Local Functions
#==============================================================================
#------------------------------------------------------------------------------
# Function: msg
#
# Write a message to stdout (and thus the log).
#
# Input:
# $* - Message text.
#
# Returns: 0
#------------------------------------------------------------------------------
function msg () { echo -e "$*"; }
#------------------------------------------------------------------------------
# Function: dbg
#
# Write a debug message (verbosity 5 only).
#------------------------------------------------------------------------------
function dbg () { [[ "$Verbosity" -ge 5 ]] && echo -e "DEBUG: $*"; return 0; }
#------------------------------------------------------------------------------
# Function: detail
#
# Write a verbose message (verbosity 4+).
#------------------------------------------------------------------------------
function detail () { [[ "$Verbosity" -ge 4 ]] && echo -e "$*"; return 0; }
#------------------------------------------------------------------------------
# Function: warnmsg
#
# Print a warning and increment WarningCount.
#------------------------------------------------------------------------------
function warnmsg () { echo -e "\\nWarning: $*\\n"; WarningCount+=1; }
#------------------------------------------------------------------------------
# Function: errmsg
#
# Print a non-fatal error and increment ErrorCount. Execution continues.
#------------------------------------------------------------------------------
function errmsg () { echo -e "\\nError: $*\\n" >&2; ErrorCount+=1; }
#------------------------------------------------------------------------------
# Function: bail
#
# Print a fatal error, increment ErrorCount, and exit.
#
# Input:
# $1 - Error message.
# $2 - (optional) Exit code; defaults to 1.
#
# Returns: Does not return (exits).
#------------------------------------------------------------------------------
function bail () {
errmsg "${1:-Fatal error.}"
cleanup
exit "${2:-1}"
}
#------------------------------------------------------------------------------
# Function: terminate
#
# Standard SDP terminate function. Called at end of main, or via trap.
# Exits with $ErrorCount so callers see a non-zero exit on any error.
#------------------------------------------------------------------------------
function terminate () {
local exitCode=$ErrorCount
msg "\\n${ThisScript} complete. Errors=$ErrorCount Warnings=$WarningCount"
[[ "$exitCode" -gt 255 ]] && exitCode=255
cleanup
exit "$exitCode"
}
#------------------------------------------------------------------------------
# Function: cleanup
#
# Remove temp files. Lock file is released automatically when fd 9 closes.
#------------------------------------------------------------------------------
function cleanup () {
[[ -n "${TmpJobFile:-}" && -f "${TmpJobFile:-}" ]] && rm -f "$TmpJobFile"
detail "Cleanup complete."
}
#------------------------------------------------------------------------------
# Function: usage
#
# Display usage message.
#
# Input:
# $1 - Style: '-h' (short), '-man' (full). Default: '-h'.
# $2 - (optional) Error message to display before usage.
#
# Returns: Exits with status 1.
#------------------------------------------------------------------------------
function usage () {
declare style="${1:--h}"
declare usageMsg="${2:-}"
[[ -n "$usageMsg" ]] && echo -e "\\nUsage Error: $usageMsg\\n"
echo "
${ThisScript} v${Version}
Sync JIRA Cloud issues into a Perforce Public Depot P4 job database.
JIRA is the source of truth; P4 jobs are a read-only mirror.
Usage:
${ThisScript} [-C <cfg>] [-p <project>] [-n] [-si]
[-L <log>] [-v <N>] [--init] [-V|--version] [-h|-man]
Options:
-C <cfg> Config file path (default: \$P4CSBIN/sync_jira_to_p4jobs.cfg).
-p <project> Restrict sync to one JIRA project key, e.g. SDP.
-n NoOp/preview mode; log actions without applying them.
-si Silent mode; output to log file only (for cron).
-L <log> Override log file path; 'off' disables file logging.
-v <N> Verbosity level 1-5 (default: 3). 4=verbose 5=debug.
--init Write an example config to the -C path, then exit.
-V|--version Print version and exit.
-h Display this short usage synopsis.
-man Display full documentation.
"
if [[ "$style" == "-man" ]]; then
echo "
DESCRIPTION:
This script polls JIRA Cloud for issues updated since the last run,
then upserts matching P4 jobs in the Public Depot. It is designed
to run periodically (e.g. every 30 minutes via cron or a systemd
timer) on the Public Depot server host.
JIRA is never written to. P4 jobs are never the source of truth.
DESIGN GOALS:
Idempotent Safe to run any number of times. 'p4 job -i -f' on an
existing job simply overwrites it with identical data.
Resilient Outages of JIRA or P4 leave a safe state. The watermark
is only advanced on a fully-successful run; the next run
retries from the same starting point.
Conservative JIRA is touched as little as possible. An incremental
JQL query (updated >= watermark) fetches only what changed.
A per-call delay further reduces load on Atlassian.
Non-invasive Never creates, edits, or comments on JIRA issues.
JobIncrement The JobIncrement trigger fires only when Job: is 'new'.
safe Since this script always supplies an explicit JIRA key as
the job name, the trigger is a no-op for synced jobs.
Other projects using JobIncrement are unaffected.
STATE FILES:
\$StateDir/last_sync_time ISO-8601 watermark of last successful poll.
\$StateDir/last_sync.lock flock advisory lock (prevents overlapping runs).
\$StateDir/jira_page_cache/ Raw JIRA JSON pages; rotated each run.
Delete last_sync_time to force a full re-sync on the next run.
JIRA API:
Endpoint: /rest/api/3/search/jql (current Atlassian Cloud API, 2025+).
Auth: HTTP Basic -- email + API token (never a password).
JQL: project in (A, B) AND updated >= 'YYYY-MM-DD HH:MM'
ORDER BY updated ASC
Pages: nextPageToken-based pagination (startAt is deprecated/removed).
Rate: JiraPollDelay seconds inserted after each successful API call.
HTTP 429 triggers back-off and retry.
P4 JOB FIELD MAPPING (Public Depot jobspec):
Job JIRA issue key (e.g. SDP-1234)
Status JIRA status name + category -> open/inprogress/blocked/review/
fixed/closed/punted/suspended/duplicate/obsolete
Project Mapped from JIRA project key via JiraProjectMap[] in config
Severity Fixed at 'C' (JIRA has no direct equivalent; adjust per job)
Type JIRA issue type -> Bug/Doc/Feature/Problem
ReportedBy Reporter email (or displayName if no email)
ReportedDate fields.created (YYYY/MM/DD)
ModifiedBy Reporter email (best available from search API)
ModifiedDate fields.updated (YYYY/MM/DD)
Description First line = JIRA summary (title), blank line, body text.
This is standard P4 convention: the summary doubles as the
description headline.
Fields NOT written (preserved from existing P4 job value):
OwnedBy, DevNotes, Component, Release
Written with 'p4 job -i -f' so 'always'/'once' date fields can be set.
The service account must have 'admin' access in 'p4 protect' for -f.
EXIT CODE:
Exits with \$ErrorCount. Zero = clean run; non-zero = at least one error.
EXAMPLES:
# Dry run -- no changes:
${ThisScript} -n -v 5
# Real run for SDP project only, verbose:
${ThisScript} -p SDP -v 4
# Crontab (every 30 min, silent, log to standard SDP logs dir):
*/30 * * * * ${ThisScript} -si
"
fi
exit 1
}
#------------------------------------------------------------------------------
# Function: write_example_config
#
# Write a commented example configuration file to \$ConfigFile.
# Bails if the file already exists.
#
# Returns: Exits (0 on success).
#------------------------------------------------------------------------------
function write_example_config () {
[[ -e "$ConfigFile" ]] && \
bail "Config file already exists: $ConfigFile. Remove it first."
cat > "$ConfigFile" <<'EOF'
# sync_jira_to_p4jobs.cfg -- configuration for sync_jira_to_p4jobs.sh
# Lines beginning with '#' are comments. No shell expansion here.
#-- JIRA Connection -----------------------------------------------------------
# Base URL of your Atlassian Cloud instance (no trailing slash).
JiraUrl=https://perforce.atlassian.net
# Email address of the JIRA account used for API access (read-only is fine).
JiraUser=svc-p4-jira-sync@example.com
# Path to a file containing ONLY the JIRA API token (no trailing newline needed).
# Generate at: https://id.atlassian.com/manage-profile/security/api-tokens
# chmod 600 this file.
JiraTokenFile=/p4/common/site/etc/.jira_api_token
# Space-separated list of JIRA project keys to sync.
JiraProjects=SDP
#-- JIRA Polling Behaviour ----------------------------------------------------
# Issues per JIRA API page (max 100; default 50 for politeness).
JiraPageSize=50
# Seconds to sleep between successive JIRA API calls.
JiraPollDelay=2
# Seconds to wait before retrying a failed JIRA call.
JiraRetryDelay=30
# Number of retry attempts before giving up on a single API page.
JiraMaxRetries=3
# Per-call curl timeout in seconds.
JiraCurlTimeout=30
# On the very first run, look back this many seconds.
# Default: 2592000 (30 days). Set 0 for all-issues full sync.
InitialLookbackSeconds=2592000
#-- Perforce Connection -------------------------------------------------------
# P4PORT of the Public Depot server.
P4PORT=ssl:workshop.perforce.com:1666
# P4USER that has admin access (required for 'p4 job -f').
P4USER=svc-jira-sync
# Path to a file containing ONLY the P4 password or long-form ticket.
# chmod 600 this file.
P4PasswdFile=/p4/common/site/etc/.p4_jira_sync_ticket
#-- State Directory -----------------------------------------------------------
# Directory for state files: watermark, lock, page cache.
# Must be writable by the user running this script.
StateDir=/p4/1/tmp/jira_sync
#-- JIRA Project to P4 Project Mapping ----------------------------------------
#
# The Public Depot jobspec 'Project' field is a select (enum) field.
# Map each JIRA project key to the corresponding jobspec select value.
# Add one line per JIRA project you are syncing.
#
# Format: JiraProjectMap[<JIRA_KEY>]=<jobspec-project-value>
#
# Example for the SDP project:
JiraProjectMap[SDP]=perforce-software-sdp
#
# To discover valid jobspec Project values, run:
# p4 jobspec -o | grep -A9999 '^Values:' | grep Project
EOF
msg "Example config written to: $ConfigFile"
msg "Edit it, then run without --init."
exit 0
}
#------------------------------------------------------------------------------
# Function: load_config
#
# Read the config file; populate global variables; validate required fields;
# read secret values from their respective files.
#
# Returns: 0 on success; calls bail on missing/unreadable config.
#------------------------------------------------------------------------------
function load_config () {
[[ -r "$ConfigFile" ]] || \
bail "Config file not found or not readable: $ConfigFile. Run --init."
while IFS='=' read -r key value; do
[[ "$key" =~ ^[[:space:]]*# ]] && continue
[[ -z "${key// /}" ]] && continue
key="${key%%[[:space:]]*}"
value="${value#"${value%%[! ]*}"}"
value="${value%"${value##*[! ]}"}"
case "$key" in
(JiraUrl) JiraUrl="$value";;
(JiraUser) JiraUser="$value";;
(JiraTokenFile) JiraTokenFile="$value";;
(JiraProjects) JiraProjects="$value";;
(JiraPageSize) JiraPageSize="$value";;
(JiraPollDelay) JiraPollDelay="$value";;
(JiraRetryDelay) JiraRetryDelay="$value";;
(JiraMaxRetries) JiraMaxRetries="$value";;
(JiraCurlTimeout) JiraCurlTimeout="$value";;
(InitialLookbackSeconds) InitialLookbackSeconds="$value";;
(P4PORT) P4PORT="$value";;
(P4USER) P4USER="$value";;
(P4PasswdFile) P4PasswdFile="$value";;
(StateDir) StateDir="$value";;
# JiraProjectMap entries have the form:
# JiraProjectMap[SDP]=perforce-software-sdp
(JiraProjectMap\[*\])
local mapKey="${key#JiraProjectMap[}"
mapKey="${mapKey%]}"
JiraProjectMap["$mapKey"]="$value"
;;
(*) warnmsg "Unknown config key '$key' -- ignored.";;
esac
done < "$ConfigFile"
local -i missing=0
local field=
for field in JiraUrl JiraUser JiraTokenFile JiraProjects \
P4PORT P4USER P4PasswdFile; do
[[ -n "${!field:-}" ]] || { errmsg "Required config field '$field' not set."; (( missing += 1 )); }
done
[[ "$missing" -gt 0 ]] && bail "Fix config file and retry: $ConfigFile"
[[ -r "$JiraTokenFile" ]] || bail "JIRA token file not readable: $JiraTokenFile"
JiraToken=$(<"$JiraTokenFile")
[[ -n "$JiraToken" ]] || bail "JIRA token file is empty: $JiraTokenFile"
[[ -r "$P4PasswdFile" ]] || bail "P4 password file not readable: $P4PasswdFile"
P4Passwd=$(<"$P4PasswdFile")
[[ -n "$P4Passwd" ]] || bail "P4 password file is empty: $P4PasswdFile"
[[ -n "$ProjectFilter" ]] && JiraProjects="$ProjectFilter"
}
#------------------------------------------------------------------------------
# Function: init_logging
#
# Establish the timestamped log file and symlink per the SDP logging standard.
# Redirect stdout and stderr to the log (with tee in interactive mode).
#
# Returns: 0, or calls bail on log setup failure.
#------------------------------------------------------------------------------
function init_logging () {
[[ "$Log" == "off" ]] && return 0
if [[ -n "$Log" ]]; then
# -L override supplied; use it directly with no symlink.
touch "$Log" || bail "Cannot write to log: $Log"
else
# Standard timestamped log in $LogsDir.
LogTimestamp=$(date +'%Y-%m-%d-%H%M%S')
local baseName="${LogsDir}/${ThisScript%.sh}"
Log="${baseName}.${LogTimestamp}.log"
# Use noclobber to ensure uniqueness on concurrent starts.
local -i suffix=0
set -C
until (echo -n > "$Log") 2>/dev/null; do
suffix+=1
Log="${baseName}.${LogTimestamp}.${suffix}.log"
done
set +C
# Maintain a stable symlink pointing to the most recent log.
LogLink="${baseName}.log"
if [[ -f "$LogLink" && ! -L "$LogLink" ]]; then
# Migrate old plain-file log to a timestamped name.
mv "$LogLink" "${baseName}.pre-symlink.log" 2>/dev/null || true
fi
ln -sf "$Log" "$LogLink" 2>/dev/null || \
warnmsg "Could not create log symlink: $LogLink"
fi
if [[ "$SilentMode" -eq 1 ]]; then
exec >"$Log" 2>&1
else
exec > >(tee -a "$Log") 2>&1
fi
}
#------------------------------------------------------------------------------
# Function: init_state
#
# Create state directory, set derived path variables, acquire flock lock.
#
# Returns: 0, or calls bail on failure.
#------------------------------------------------------------------------------
function init_state () {
mkdir -p "$StateDir" || bail "Cannot create StateDir: $StateDir"
LockFile="${StateDir}/last_sync.lock"
WatermarkFile="${StateDir}/last_sync_time"
CacheDir="${StateDir}/jira_page_cache"
mkdir -p "$CacheDir" || bail "Cannot create CacheDir: $CacheDir"
TmpJobFile=$(mktemp) || bail "Cannot create temp file."
exec 9>"$LockFile"
flock -n 9 || bail "Another instance is running (lock: $LockFile)."
detail "Lock acquired: $LockFile"
}
#------------------------------------------------------------------------------
# Function: get_watermark
#
# Return a JQL-safe 'updated >=' timestamp string. If no watermark file
# exists (first run), compute one from InitialLookbackSeconds.
#
# Output: Timestamp string of the form 'YYYY-MM-DD HH:MM' on stdout.
#
# Returns: 0
#------------------------------------------------------------------------------
function get_watermark () {
if [[ -r "$WatermarkFile" ]]; then
cat "$WatermarkFile"
else
local lookbackTs=$(( $(date +%s) - InitialLookbackSeconds ))
date -d "@${lookbackTs}" '+%Y-%m-%d %H:%M' 2>/dev/null || \
date -r "$lookbackTs" '+%Y-%m-%d %H:%M'
fi
}
#------------------------------------------------------------------------------
# Function: save_watermark
#
# Persist a new watermark timestamp. Called only after a fully-clean run.
#
# Input:
# $1 - Timestamp string.
#
# Returns: 0 (warns on write failure).
#------------------------------------------------------------------------------
function save_watermark () {
echo "$1" > "$WatermarkFile" || \
warnmsg "Could not save watermark; next run will refetch from: $1"
detail "Watermark saved: $1"
}
#------------------------------------------------------------------------------
# Function: p4_cmd
#
# Wrapper around p4 that injects connection parameters.
# Never passes the password on the command line; uses -P flag (ticket/passwd).
#
# Input:
# $* - Arguments forwarded to p4.
#
# Returns: Exit code of the p4 command.
#------------------------------------------------------------------------------
function p4_cmd () {
P4PORT="$P4PORT" P4USER="$P4USER" p4 -P "$P4Passwd" "$@"
}
#------------------------------------------------------------------------------
# Function: jira_get
#
# Perform a JIRA REST GET with retry/back-off.
#
# Input:
# $1 - Fully-formed, URL-encoded request URL.
#
# Output: Raw JSON body on stdout (on success).
#
# Returns: 0 on success, 1 after all retries exhausted.
#------------------------------------------------------------------------------
function jira_get () {
local url="$1"
local -i attempt=0
local httpCode response body curlRc
while [[ "$attempt" -lt "$JiraMaxRetries" ]]; do
attempt+=1
detail " JIRA GET (attempt ${attempt}/${JiraMaxRetries}): $url"
response=$(curl --silent --show-error \
--write-out "\\n%{http_code}" \
--max-time "$JiraCurlTimeout" \
--user "${JiraUser}:${JiraToken}" \
--header "Accept: application/json" \
"$url" 2>&1)
curlRc=$?
httpCode=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
if [[ "$curlRc" -ne 0 ]]; then
warnmsg "curl failed (rc=${curlRc}) attempt ${attempt}: $url"
elif [[ "$httpCode" == "200" ]]; then
echo "$body"
sleep "$JiraPollDelay"
return 0
elif [[ "$httpCode" == "429" ]]; then
warnmsg "JIRA rate-limited (429), attempt ${attempt}. Sleeping ${JiraRetryDelay}s."
elif [[ "$httpCode" =~ ^5 ]]; then
warnmsg "JIRA server error (${httpCode}), attempt ${attempt}. Sleeping ${JiraRetryDelay}s."
else
errmsg "JIRA API non-retryable error (HTTP ${httpCode}): $url"
errmsg "Response: $body"
return 1
fi
[[ "$attempt" -lt "$JiraMaxRetries" ]] && sleep "$JiraRetryDelay"
done
errmsg "All ${JiraMaxRetries} JIRA API attempts failed: $url"
return 1
}
#------------------------------------------------------------------------------
# Function: jira_status_to_p4
#
# Map a JIRA status category name to a P4 job Status value.
#
# JIRA status categories: To Do, In Progress, Done.
# Public Depot Status values (from jobspec):
# open inprogress blocked review fixed closed punted suspended duplicate obsolete
#
# Input:
# $1 - JIRA status category name (e.g. 'To Do', 'In Progress', 'Done').
# $2 - JIRA status name (more specific, e.g. 'In Review', 'Blocked').
#
# Output: P4 status string on stdout.
#
# Returns: 0
#------------------------------------------------------------------------------
function jira_status_to_p4 () {
local cat="${1,,}"
local status="${2,,}"
# Check the specific JIRA status name first for finer-grained mapping.
case "$status" in
(*block*) echo "blocked"; return 0;;
(*review*|*"in review"*) echo "review"; return 0;;
(*duplicate*) echo "duplicate"; return 0;;
(*obsolete*|*"won't fix"|*wontfix*) echo "obsolete"; return 0;;
(*punt*) echo "punted"; return 0;;
(*done*|*resolved*|*complete*) echo "closed"; return 0;;
esac
# Fall back to the coarser status category.
case "$cat" in
("to do"|"new") echo "open";;
("in progress") echo "inprogress";;
("done") echo "closed";;
(*) echo "open";;
esac
}
#------------------------------------------------------------------------------
# Function: jira_type_to_p4
#
# Map a JIRA issue type name to a P4 job Type value.
#
# Public Depot Type values (from jobspec): Bug Doc Feature Problem
#
# Input:
# $1 - JIRA issue type name (e.g. 'Bug', 'Story', 'Task').
#
# Output: P4 type string on stdout.
#
# Returns: 0
#------------------------------------------------------------------------------
function jira_type_to_p4 () {
local itype="${1,,}"
case "$itype" in
("bug") echo "Bug";;
("doc"|"documentation") echo "Doc";;
("story"|"epic"|"task"|"new feature"*) echo "Feature";;
(*) echo "Problem";;
esac
}
#------------------------------------------------------------------------------
# Function: json_field
#
# Extract a scalar JSON field value using python3.
# Uses python3 (always available on SDP-managed hosts) to avoid jq dependency.
#
# Input:
# $1 - JSON string.
# $2 - Dot-separated key path, e.g. 'fields.status.statusCategory.name'.
#
# Output: Field value on stdout; empty string if not found.
#
# Returns: 0
#------------------------------------------------------------------------------
function json_field () {
local json="$1"
local path="$2"
python3 - "$json" "$path" <<'PYEOF'
import sys, json
try:
d = json.loads(sys.argv[1])
for p in sys.argv[2].split('.'):
d = d.get(p, '') if isinstance(d, dict) else ''
print('' if d is None else d)
except Exception:
print('')
PYEOF
}
#------------------------------------------------------------------------------
# Function: extract_description
#
# Extract plain-text from a JIRA Atlassian Document Format description.
# Falls back gracefully if the description is already a plain string or null.
#
# Input:
# $1 - Full JSON string for a single JIRA issue object.
#
# Output: Plain text (up to 4000 chars) on stdout.
#
# Returns: 0
#------------------------------------------------------------------------------
function extract_description () {
local issueJson="$1"
python3 - "$issueJson" <<'PYEOF'
import sys, json
def extract_text(node):
if not isinstance(node, dict):
return ''
if node.get('type') == 'text':
return node.get('text', '')
sep = '\n' if node.get('type') in (
'paragraph', 'heading', 'bulletList',
'orderedList', 'listItem') else ''
return sep.join(
t for t in (extract_text(c) for c in node.get('content', [])) if t)
try:
d = json.loads(sys.argv[1])
desc = d.get('fields', {}).get('description')
if desc is None:
print('')
elif isinstance(desc, str):
print(desc[:4000])
elif isinstance(desc, dict):
print(extract_text(desc)[:4000])
else:
print('')
except Exception:
print('')
PYEOF
}
#------------------------------------------------------------------------------
# Function: map_jira_project_to_p4
#
# Translate a JIRA project key (e.g. SDP) to the Public Depot jobspec
# Project select value (e.g. perforce-software-sdp).
#
# The mapping is configured via JiraProjectMap[KEY]=value entries in the
# config file. If no mapping is found, the key is lowercased and returned
# as-is, which may or may not be a valid jobspec Project value. A warning
# is emitted on a miss so the operator can add the missing mapping.
#
# Input:
# $1 - JIRA project key (uppercase, e.g. SDP).
#
# Output: Jobspec Project value on stdout.
#
# Returns: 0
#------------------------------------------------------------------------------
function map_jira_project_to_p4 () {
local jiraKey="$1"
if [[ -n "${JiraProjectMap[$jiraKey]:-}" ]]; then
echo "${JiraProjectMap[$jiraKey]}"
else
warnmsg "No JiraProjectMap entry for JIRA project '$jiraKey'; using lowercase key."
echo "${jiraKey,,}"
fi
}
#------------------------------------------------------------------------------
# Function: upsert_p4_job
#
# Create or update a single P4 job from a JIRA issue JSON object.
# Uses 'p4 job -i -f' so read-only date fields can be populated.
#
# Description field convention: the first line is the issue summary/title
# (JIRA summary), followed by a blank line, then the description body.
# This is a standard P4 job convention on the Public Depot.
#
# Public Depot jobspec fields written by this function:
# Job JIRA issue key
# Status Mapped from JIRA status (see jira_status_to_p4)
# Type Mapped from JIRA issue type (see jira_type_to_p4)
# Project JIRA project key, lowercased to match jobspec select values
# Severity Defaulted to 'C'; JIRA has no direct equivalent
# ReportedBy JIRA reporter email (or displayName)
# ReportedDate JIRA created date (YYYY/MM/DD)
# ModifiedBy JIRA reporter (best available; JIRA updater not in search API)
# ModifiedDate JIRA updated date (YYYY/MM/DD)
# Description Summary line + blank line + description body (tab-indented)
#
# Fields left at their existing values (not overwritten):
# OwnedBy, DevNotes, Component, Release
#
# Input:
# $1 - JSON string for a single JIRA issue.
#
# Returns: 0 on success, 1 on failure (failure logged; FailCount incremented).
#------------------------------------------------------------------------------
function upsert_p4_job () {
local issueJson="$1"
local key summary statusCat statusName itype reporterEmail reporterName
local created updated p4Status p4Type p4Desc p4Created p4Modified
local reporter project descText p4Out
local -i p4Rc=0
key=$(json_field "$issueJson" "key")
[[ -n "$key" ]] || { errmsg "Issue has no key field; skipping."; return 1; }
summary=$(json_field "$issueJson" "fields.summary")
statusCat=$(json_field "$issueJson" "fields.status.statusCategory.name")
statusName=$(json_field "$issueJson" "fields.status.name")
itype=$(json_field "$issueJson" "fields.issuetype.name")
reporterEmail=$(json_field "$issueJson" "fields.reporter.emailAddress")
reporterName=$(json_field "$issueJson" "fields.reporter.displayName")
created=$(json_field "$issueJson" "fields.created")
updated=$(json_field "$issueJson" "fields.updated")
# Derive the P4 project value from the JIRA project key portion of the
# issue key (e.g. 'SDP' from 'SDP-1234'), mapped to the jobspec select
# list format used on the Public Depot (e.g. 'perforce-software-sdp').
# This mapping must be configured in the config file; see JiraProjectMap.
project=$(map_jira_project_to_p4 "${key%%-*}")
reporter="${reporterEmail:-${reporterName:-unknown}}"
descText=$(extract_description "$issueJson")
p4Status=$(jira_status_to_p4 "$statusCat" "$statusName")
p4Type=$(jira_type_to_p4 "$itype")
# Date conversion: ISO-8601 YYYY-MM-DD -> P4 YYYY/MM/DD.
p4Created="${created:0:10}"
p4Created="${p4Created//-//}"
p4Modified="${updated:0:10}"
p4Modified="${p4Modified//-//}"
# Description convention: first line = JIRA summary (issue title),
# blank line, then description body. All lines tab-indented for P4
# form syntax. sed inserts the tab; shellcheck SC2001 suppressed
# because bash ${var//^/TAB} cannot insert a literal tab reliably.
p4Desc=$(printf '%s\n\n%s' "$summary" "$descText")
# shellcheck disable=SC2001
local indentedDesc
indentedDesc=$(echo "$p4Desc" | sed 's/^/\t/')
detail " Upserting: $key status=${p4Status} type=${p4Type} project=${project}"
if [[ "$NoOp" -eq 1 ]]; then
msg "[NoOp] Would upsert: $key (${p4Status}/${p4Type}/${project})"
SyncCount+=1
return 0
fi
# Build the P4 job form for 'p4 job -i -f'.
# Fields marked 'always' in the jobspec (ModifiedBy, ModifiedDate) and
# 'once' (ReportedDate) require -f to be set explicitly.
# OwnedBy, DevNotes, Component, Release are omitted here; p4 job -i -f
# preserves existing values for fields not present in the input form.
cat > "$TmpJobFile" <<JOB_EOF
Job: ${key}
Status: ${p4Status}
Project: ${project}
Severity: C
Type: ${p4Type}
ReportedBy: ${reporter}
ReportedDate: ${p4Created}
ModifiedBy: ${reporter}
ModifiedDate: ${p4Modified}
Description:
${indentedDesc}
JOB_EOF
p4Out=$(p4_cmd job -i -f < "$TmpJobFile" 2>&1)
p4Rc=$?
if [[ "$p4Rc" -eq 0 ]]; then
detail " P4 OK: $p4Out"
SyncCount+=1
return 0
else
errmsg "Failed to upsert P4 job $key (rc=${p4Rc}): $p4Out"
FailCount+=1
return 1
fi
}
#------------------------------------------------------------------------------
# Function: sync_project
#
# Fetch all JIRA issues for one project updated since the watermark and
# upsert each as a P4 job.
#
# Input:
# $1 - JIRA project key (e.g. SDP).
# $2 - JQL 'updated >=' timestamp string.
#
# Returns: 0 on full success, 1 if any page or job fails.
#------------------------------------------------------------------------------
function sync_project () {
local project="$1"
local since="$2"
local jql="project = ${project} AND updated >= \"${since}\" ORDER BY updated ASC"
local jqlEncoded pageJson nextPageToken url
local -i page=0 pageCount=0 totalFetched=0 i=0
jqlEncoded=$(python3 -c \
"import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" \
"$jql")
local fields="summary,status,issuetype,reporter,created,updated,description"
nextPageToken=""
msg " Fetching ${project} issues updated since: ${since}"
while true; do
page+=1
url="${JiraUrl}/rest/api/3/search/jql"
url+="?jql=${jqlEncoded}"
url+="&maxResults=${JiraPageSize}"
url+="&fields=${fields}"
[[ -n "$nextPageToken" ]] && url+="&nextPageToken=${nextPageToken}"
detail " Page ${page}: $url"
pageJson=$(jira_get "$url") || {
errmsg "Failed fetching page ${page} for ${project}; aborting project."
return 1
}
echo "$pageJson" > "${CacheDir}/${project}_page${page}.json"
pageCount=$(python3 -c \
"import sys,json; d=json.loads(sys.argv[1]); print(len(d.get('issues',[])))" \
"$pageJson" 2>/dev/null || echo 0)
detail " Page ${page}: ${pageCount} issues."
totalFetched=$(( totalFetched + pageCount ))
[[ "$pageCount" -eq 0 ]] && break
i=0
while [[ "$i" -lt "$pageCount" ]]; do
local issueJson
issueJson=$(python3 -c \
"import sys,json; d=json.loads(sys.argv[1]); \
print(json.dumps(d['issues'][int(sys.argv[2])]))" \
"$pageJson" "$i" 2>/dev/null || echo "{}")
upsert_p4_job "$issueJson" || true
i=$(( i + 1 ))
done
nextPageToken=$(python3 -c \
"import sys,json; d=json.loads(sys.argv[1]); \
print(d.get('nextPageToken',''))" \
"$pageJson" 2>/dev/null || echo "")
[[ -z "$nextPageToken" ]] && break
done
msg " ${project}: fetched=${totalFetched} synced=${SyncCount} failed=${FailCount}"
return 0
}
#==============================================================================
# Command Line Processing
#==============================================================================
declare -i shiftArgs=0
set +u
while [[ $# -gt 0 ]]; do
case "$1" in
(-C) ConfigFile="$2"; shiftArgs=1;;
(-p) ProjectFilter="$2"; shiftArgs=1;;
(-n) NoOp=1;;
(-si) SilentMode=1;;
(-L) Log="$2"; shiftArgs=1;;
(-v) Verbosity="$2"; shiftArgs=1;;
(--init) InitMode=1;;
(-V|--version) echo "${ThisScript} v${Version}"; exit 0;;
(-h) usage -h;;
(-man) usage -man;;
(-*) usage -h "Unknown option '$1'.";;
(*) usage -h "Unexpected argument '$1'.";;
esac
shift
while [[ "$shiftArgs" -gt 0 ]]; do
shift
shiftArgs=$(( shiftArgs - 1 ))
done
done
set -u
#==============================================================================
# Command Line Verification
#==============================================================================
[[ "$SilentMode" -eq 1 && "$Log" == "off" ]] && \
bail "-si (silent) and '-L off' are mutually exclusive."
#==============================================================================
# Main Program
#==============================================================================
# Handle --init before logging or config loading.
[[ "$InitMode" -eq 1 ]] && write_example_config
ThisUser=$(whoami)
ThisHost=$(hostname -s)
init_logging
msg "Started ${ThisScript} v${Version} as ${ThisUser}@${ThisHost} on $(date)"
msg "CmdLine: ${CmdLine}"
[[ "$Log" != "off" ]] && msg "Log: ${Log}"
[[ "$NoOp" -eq 1 ]] && msg "*** NoOp/Preview mode -- no changes will be made. ***"
load_config
init_state
trap terminate EXIT SIGINT SIGTERM
# Clean stale page cache files (older than 2 days).
find "$CacheDir" -name '*.json' -mtime +2 -delete 2>/dev/null || true
# Record run start time BEFORE fetching so any issue updated during the run
# is captured on the next run (using start time rather than end time as the
# new watermark avoids a gap between poll-start and poll-end).
declare RunStartTime
RunStartTime=$(date '+%Y-%m-%d %H:%M')
declare Watermark
Watermark=$(get_watermark)
msg "Watermark (last run): ${Watermark}"
msg "New watermark will be: ${RunStartTime}"
# Preflight: verify P4 connectivity.
msg "Verifying P4 connection: ${P4PORT} ..."
p4_cmd -ztag info > /dev/null 2>&1 || \
bail "Cannot connect to P4 at ${P4PORT}. Check P4PORT and P4PasswdFile."
msg "P4 connection OK."
# Sync each configured project.
msg "Projects to sync: ${JiraProjects}"
declare project=
for project in $JiraProjects; do
msg "Syncing project: ${project}"
sync_project "$project" "$Watermark"
done
# Advance watermark only on a fully clean run.
if [[ "$ErrorCount" -eq 0 && "$FailCount" -eq 0 ]]; then
save_watermark "$RunStartTime"
msg "Watermark advanced to: ${RunStartTime}"
else
warnmsg "Watermark NOT advanced (ErrorCount=${ErrorCount} FailCount=${FailCount}). Next run will retry."
fi
msg ""
msg "=========================================="
msg "Synced: ${SyncCount}"
msg "Failed: ${FailCount}"
msg "Warnings: ${WarningCount}"
msg "Errors: ${ErrorCount}"
msg "=========================================="
terminate