A CLI and API tool that evaluates engineering teams on their adherence to the Sankey planning framework by analyzing Jira issue data and producing scorecard reports.
Sankey Scorecard fetches Jira issues for configured teams, measures how consistently teams categorize their work using the Activity Type custom field, and scores how well each team's work distribution aligns with target allocations across six Sankey categories.
Scorecards are produced at three levels of an organizational hierarchy: Organization > Pillar > Team. Scores range from 0-100 with letter grades (A-F).
Each team receives a composite score from two dimensions:
Categorization Rate (60 points) -- The percentage of scored issues that have the Activity Type field populated.
score = (categorized_count / total_count) * 60
Distribution Alignment (40 points) -- How closely the team's actual work distribution across six Sankey categories matches the target distribution. Deviations are penalized asymmetrically: over-allocating to high-priority categories incurs less penalty than over-allocating to low-priority ones.
The six categories, in priority order:
| Rank | Category | Target |
|---|---|---|
| 1 | Associate Wellness & Development | 12% |
| 2 | Incidents & Support | 12% |
| 3 | Security & Compliance | 12% |
| 4 | Quality / Stability / Reliability | 22% |
| 5 | Future Sustainability | 21% |
| 6 | Product / Portfolio Work | 21% |
Grade Scale:
| Grade | Score Range |
|---|---|
| A | 90-100 |
| B | 75-89 |
| C | 60-74 |
| D | 45-59 |
| F | 0-44 |
| - | nil (no data) |
Pillar and organization scores are issue-count-weighted averages of their children. Teams with no scored issues receive a nil score (grade "-") and are excluded from aggregation.
make build
# Binary is produced at ./sankey-scorecard
make install
# Installs to $GOPATH/binVersion, commit hash, and build date are injected via -ldflags at build time.
The organizational hierarchy, team ownership rules, sprint calendar, and scoring parameters are all defined in a single YAML config file (config/sankey-scorecard.yaml). The binary loads it at runtime from one of these locations in priority order:
--config/-cflagRESOURCE_MAP_PATHenvironment variable/etc/sankey-scorecard/sankey-scorecard.yaml(default for container deployments)
See config/sankey-scorecard.yaml.example for a fully annotated template.
sprint_reference_date: "2026-01-01"
sprint_duration_days: 21
jira:
activity_type_field: customfield_XXXXXXXX
scored_issue_types:
- Story
- Bug
- Task
request_delay_ms: 100
organizations:
- name: Example Organization
identifier: example-org
pillars:
- name: Example Pillar
identifier: example-pillar
teams:
- name: Example Team B
identifier: example-team-b
ownership:
method: team_field
project: PROJ
team_field_value: "TeamFieldValue"Each team defines an ownership method for resolving its Jira issues:
| Method | Identifies issues by | Required fields |
|---|---|---|
component |
Project + component list | project, components |
team_field |
Project + Team custom field value | project, team_field_value |
sprint_board |
Project + Jira Agile board sprints | project, boards |
jql |
Arbitrary JQL query | jql |
All identifiers must be globally unique, lowercase alphanumeric with hyphens, and not conflict with reserved words (serve, refresh-data, version, help).
Sprint boundaries are calculated from sprint_reference_date (a known sprint start date) and sprint_duration_days. The scorecard evaluates two periods: the current sprint and the previous sprint. No Jira sprint API calls are made.
Sensitive and instance-specific values are provided at invocation time (not in the config file):
--jira-url-- Jira instance URL--jira-api-token-- Jira API token for authentication (falls back toJIRA_API_TOKENenv var)--activity-type-field-- Override the Activity Type custom field ID from the config file (e.g.,customfield_XXXXXXXX)
Fetch Jira issue data for all configured teams. This must be run before querying scorecards.
sankey-scorecard refresh-data \
--jira-url https://issues.redhat.com \
--jira-api-token $JIRA_API_TOKENOptionally override the sprint calendar with a custom start date:
sankey-scorecard refresh-data \
--jira-url https://issues.redhat.com \
--jira-api-token $JIRA_API_TOKEN \
--since 2026-01-01After refreshing data, query scorecards by identifier:
# Show scorecard for a team
sankey-scorecard example-team-b
# Show scorecard for a pillar
sankey-scorecard example-pillar
# Show scorecard for an organization
sankey-scorecard example-org
# Output as JSON or YAML
sankey-scorecard example-team-b -o json
sankey-scorecard example-pillar -o yaml
# Disambiguate if an identifier matches multiple entities
sankey-scorecard example-pillar/example-team-b
# Use a custom config file
sankey-scorecard example-org --config ./my-config.yamlExample plaintext output:
Example Pillar Scorecard
Generated: 2026-01-15 14:30 UTC | Issues: 523
Pillar Score: 71.0 (C)
Categorization Rate: 43.2 / 60
Distribution Alignment: 27.8 / 40
Teams:
TEAM SCORE GRADE ISSUES CAT.RATE DIST.ALIGN
example-team-a 82.5 B 89 50.4 32.1
example-team-b 72.5 C 47 45.0 27.5
sankey-scorecard serve \
--jira-url https://issues.redhat.com \
--jira-api-token $JIRA_API_TOKEN
# Custom bind address
sankey-scorecard serve --bind-address :9090 \
--jira-url https://issues.redhat.com \
--jira-api-token $JIRA_API_TOKENThe server starts with no data loaded. A refresh must be initiated via the API before scorecard endpoints return data.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | General error (bad config, Jira auth failure, etc.) |
| 2 | Identifier not found |
| 3 | No data available (refresh-data has not been run) |
The REST API is served under the /api prefix. All responses are JSON.
| Method | Path | Description |
|---|---|---|
GET |
/healthz |
Health check (returns {"status": "ok"}) |
GET |
/api/ |
Returns the OpenAPI specification |
GET |
/api/scorecard |
Returns scorecards, with optional org, pillar, team, start_date, end_date, status query params |
POST |
/api/refresh_data |
Initiates async Jira data refresh (returns 202). Optional mode, start_date, end_date, status query params |
GET |
/api/refresh_status |
Returns refresh status (idle/running/completed/failed) |
# Get full scorecard
curl http://localhost:8080/api/scorecard
# Filter by team
curl http://localhost:8080/api/scorecard?team=example-team-b
# Filter by organization
curl http://localhost:8080/api/scorecard?org=example-org
# Filter scorecard by date range and issue status
curl http://localhost:8080/api/scorecard?start_date=2026-01-01&end_date=2026-02-28
curl http://localhost:8080/api/scorecard?status=closed
# Initiate a full data refresh
curl -X POST http://localhost:8080/api/refresh_data
# Scoped refresh: only closed issues in a date range
curl -X POST "http://localhost:8080/api/refresh_data?status=closed&start_date=2026-01-01&end_date=2026-02-28"
# Upsert mode: merge new data without replacing existing
curl -X POST http://localhost:8080/api/refresh_data?mode=upsert
# Check refresh status
curl http://localhost:8080/api/refresh_statusErrors use a consistent structure:
{
"error": "not_found",
"message": "Specified entity not found"
}| Status | Meaning |
|---|---|
| 400 | Invalid parameters (bad date format, invalid status, or mode=replace with scope params) |
| 404 | Entity not found |
| 409 | Refresh already running, or ambiguous identifier |
| 503 | No data available (refresh has not been run) |
The full OpenAPI 3.0.3 specification is served at GET /api/ and embedded in the binary.
The application is packaged as a container using a multi-stage build (Go builder + UBI 10 minimal runtime). The binary runs as non-root (USER 1001) and listens on port 8080.
# Build the container image
make build-image
# Run locally
podman run --rm -e JIRA_API_TOKEN=$JIRA_API_TOKEN \
sankey-scorecard:$(git describe --tags --always) \
serve --jira-url https://issues.redhat.comThe application deploys to OpenShift using the internal image registry. Images are pushed via the registry's exposed route and referenced internally by the deployment.
ocCLI logged into the target cluster- A namespace for the deployment
- The internal image registry enabled with its default route exposed
- A secret containing Jira credentials:
oc create secret generic sankey-scorecard-jira \
--from-literal=jira-url=https://issues.redhat.com \
--from-literal=api-token=$JIRA_API_TOKEN \
-n my-namespace# Build and deploy (build-image must run first)
make build-image
NAMESPACE=my-namespace make deployThe deploy script handles registry login, image push, manifest application, and rollout. On completion it prints the application route URL.
NAMESPACE=my-namespace make deploy-teardownThis removes the deployment, service, and route but preserves the Jira secret.