From 31d270667d3ea19f586d56b559ebc42fc8a5aab8 Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Wed, 10 Jun 2026 13:38:23 +0000 Subject: [PATCH 1/2] Add atlassian-org-license-optimization CodeBundle Identifies Atlassian Cloud license waste through inactive billable users, product overlap, and stale pending invites, with read-only reclamation recommendations and an in-repo SLI for organization-level health scoring. Co-authored-by: Cursor --- .../atlassian-org-license-optimization.yaml | 22 ++ ...tlassian-org-license-optimization-sli.yaml | 56 ++++ ...tlassian-org-license-optimization-slx.yaml | 33 +++ ...sian-org-license-optimization-taskset.yaml | 49 ++++ .../.test/README.md | 29 ++ .../.test/Taskfile.yaml | 23 ++ .../fixtures/inactive_jira_inventory.json | 68 +++++ .../no_reclamation_directory_users.json | 16 + .../fixtures/no_reclamation_inventory.json | 19 ++ .../overlap_and_invites_directory_users.json | 72 +++++ .../overlap_and_invites_inventory.json | 68 +++++ .../.test/terraform/backend.tf | 5 + .../.test/terraform/main.tf | 6 + .../.test/terraform/outputs.tf | 4 + .../.test/terraform/providers.tf | 3 + .../.test/terraform/terraform.tfvars | 1 + .../.test/terraform/variables.tf | 5 + .../.test/validate-all-tests.sh | 70 +++++ .../.test/validate-bundle-structure.sh | 36 +++ .../README.md | 75 +++++ .../analyze-atlassian-product-overlap.sh | 105 +++++++ .../atlassian-api-helpers.sh | 257 +++++++++++++++++ .../atlassian_directory_users.json | 72 +++++ .../atlassian_inactive_billable_data.json | 1 + .../atlassian_inactive_billable_issues.json | 1 + .../atlassian_inactive_billable_summary.txt | 8 + .../atlassian_license_reclamation_report.md | 66 +++++ .../atlassian_pending_invite_data.json | 74 +++++ .../atlassian_pending_invite_issues.json | 8 + .../atlassian_pending_invite_summary.txt | 13 + .../atlassian_product_overlap_data.json | 87 ++++++ .../atlassian_product_overlap_issues.json | 8 + .../atlassian_product_overlap_summary.txt | 12 + .../atlassian_reclamation_issues.json | 14 + .../atlassian_user_inventory.json | 68 +++++ ...ntify-atlassian-inactive-billable-users.sh | 113 ++++++++ ...recommend-atlassian-license-reclamation.sh | 199 +++++++++++++ .../runbook.robot | 273 ++++++++++++++++++ ...li-atlassian-license-reclamation-health.sh | 114 ++++++++ .../sli.robot | 145 ++++++++++ .../surface-atlassian-pending-invites.sh | 118 ++++++++ 41 files changed, 2416 insertions(+) create mode 100644 codebundles/atlassian-org-license-optimization/.runwhen/generation-rules/atlassian-org-license-optimization.yaml create mode 100644 codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-sli.yaml create mode 100644 codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-slx.yaml create mode 100644 codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-taskset.yaml create mode 100644 codebundles/atlassian-org-license-optimization/.test/README.md create mode 100644 codebundles/atlassian-org-license-optimization/.test/Taskfile.yaml create mode 100644 codebundles/atlassian-org-license-optimization/.test/fixtures/inactive_jira_inventory.json create mode 100644 codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_directory_users.json create mode 100644 codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_inventory.json create mode 100644 codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_directory_users.json create mode 100644 codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_inventory.json create mode 100644 codebundles/atlassian-org-license-optimization/.test/terraform/backend.tf create mode 100644 codebundles/atlassian-org-license-optimization/.test/terraform/main.tf create mode 100644 codebundles/atlassian-org-license-optimization/.test/terraform/outputs.tf create mode 100644 codebundles/atlassian-org-license-optimization/.test/terraform/providers.tf create mode 100644 codebundles/atlassian-org-license-optimization/.test/terraform/terraform.tfvars create mode 100644 codebundles/atlassian-org-license-optimization/.test/terraform/variables.tf create mode 100755 codebundles/atlassian-org-license-optimization/.test/validate-all-tests.sh create mode 100755 codebundles/atlassian-org-license-optimization/.test/validate-bundle-structure.sh create mode 100644 codebundles/atlassian-org-license-optimization/README.md create mode 100755 codebundles/atlassian-org-license-optimization/analyze-atlassian-product-overlap.sh create mode 100755 codebundles/atlassian-org-license-optimization/atlassian-api-helpers.sh create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_directory_users.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json create mode 100644 codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json create mode 100755 codebundles/atlassian-org-license-optimization/identify-atlassian-inactive-billable-users.sh create mode 100755 codebundles/atlassian-org-license-optimization/recommend-atlassian-license-reclamation.sh create mode 100644 codebundles/atlassian-org-license-optimization/runbook.robot create mode 100755 codebundles/atlassian-org-license-optimization/sli-atlassian-license-reclamation-health.sh create mode 100644 codebundles/atlassian-org-license-optimization/sli.robot create mode 100755 codebundles/atlassian-org-license-optimization/surface-atlassian-pending-invites.sh diff --git a/codebundles/atlassian-org-license-optimization/.runwhen/generation-rules/atlassian-org-license-optimization.yaml b/codebundles/atlassian-org-license-optimization/.runwhen/generation-rules/atlassian-org-license-optimization.yaml new file mode 100644 index 000000000..2a2af93be --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.runwhen/generation-rules/atlassian-org-license-optimization.yaml @@ -0,0 +1,22 @@ +apiVersion: runwhen.com/v1 +kind: GenerationRules +spec: + platform: atlassian + generationRules: + - resourceTypes: + - atlassian_organization + matchRules: + - type: pattern + pattern: ".+" + properties: ["name"] + mode: substring + slxs: + - baseName: atlassian-org-license-optimization + qualifiers: ["organization"] + baseTemplateName: atlassian-org-license-optimization + levelOfDetail: basic + outputItems: + - type: slx + - type: sli + - type: runbook + templateName: atlassian-org-license-optimization-taskset.yaml diff --git a/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-sli.yaml b/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-sli.yaml new file mode 100644 index 000000000..1d1546de0 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-sli.yaml @@ -0,0 +1,56 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelIndicator +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + displayUnitsLong: Health Score + displayUnitsShort: score + locations: + - {{default_location}} + description: Measures Atlassian organization license reclamation health for {{ match_resource.name }}. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/atlassian-org-license-optimization/sli.robot + intervalStrategy: intermezzo + intervalSeconds: 300 + configProvided: + - name: ATLASSIAN_ORG_ID + value: "{{ match_resource.id }}" + - name: ATLASSIAN_ORG_NAME + value: "{{ match_resource.name }}" + - name: ATLASSIAN_DIRECTORY_ID + value: "{{ custom.atlassian_directory_id | default('') }}" + - name: INACTIVE_DAYS_THRESHOLD + value: "{{ custom.inactive_days_threshold | default('90') }}" + - name: PENDING_INVITE_DAYS_THRESHOLD + value: "{{ custom.pending_invite_days_threshold | default('30') }}" + - name: MIN_OVERLAP_PRODUCTS + value: "{{ custom.min_overlap_products | default('2') }}" + - name: RECLAMATION_MIN_SEATS + value: "{{ custom.reclamation_min_seats | default('5') }}" + - name: SLI_MAX_PAGES + value: "{{ custom.sli_max_pages | default('2') }}" + secretsProvided: + {% if wb_version %} + {% include "atlassian-auth.yaml" ignore missing %} + {% else %} + - name: atlassian_org_api_key + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} + alertConfig: + tasks: + persona: eager-edgar + sessionTTL: 10m diff --git a/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-slx.yaml b/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-slx.yaml new file mode 100644 index 000000000..067128391 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-slx.yaml @@ -0,0 +1,33 @@ +apiVersion: runwhen.com/v1 +kind: ServiceLevelX +metadata: + name: {{ slx_name }} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + imageURL: https://storage.googleapis.com/runwhen-nonprod-shared-images/icons/atlassian/atlassian.svg + alias: {{ match_resource.name }} Atlassian License Optimization + asMeasuredBy: License reclamation health score based on inactive users, product overlap, and stale invites. + configProvided: + - name: SLX_PLACEHOLDER + value: SLX_PLACEHOLDER + owners: + - {{ workspace.owner_email }} + statement: Atlassian organization {{ match_resource.name }} should minimize reclaimable license waste before renewal. + additionalContext: + {% include "atlassian-hierarchy.yaml" ignore missing %} + qualified_name: "{{ match_resource.qualified_name }}" + tags: + {% include "atlassian-tags.yaml" ignore missing %} + - name: cloud + value: atlassian + - name: service + value: organization-admin + - name: scope + value: organization + - name: access + value: read-only + - name: category + value: license-optimization diff --git a/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-taskset.yaml b/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-taskset.yaml new file mode 100644 index 000000000..fb4d71c80 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.runwhen/templates/atlassian-org-license-optimization-taskset.yaml @@ -0,0 +1,49 @@ +apiVersion: runwhen.com/v1 +kind: Runbook +metadata: + name: {{slx_name}} + labels: + {% include "common-labels.yaml" %} + annotations: + {% include "common-annotations.yaml" %} +spec: + location: {{default_location}} + description: Analyze Atlassian organization {{ match_resource.name }} for license waste and reclamation opportunities. + codeBundle: + {% if repo_url %} + repoUrl: {{repo_url}} + {% else %} + repoUrl: https://github.com/runwhen-contrib/rw-cli-codecollection.git + {% endif %} + {% if ref %} + ref: {{ref}} + {% else %} + ref: main + {% endif %} + pathToRobot: codebundles/atlassian-org-license-optimization/runbook.robot + configProvided: + - name: ATLASSIAN_ORG_ID + value: "{{ match_resource.id }}" + - name: ATLASSIAN_ORG_NAME + value: "{{ match_resource.name }}" + - name: ATLASSIAN_DIRECTORY_ID + value: "{{ custom.atlassian_directory_id | default('') }}" + - name: INACTIVE_DAYS_THRESHOLD + value: "{{ custom.inactive_days_threshold | default('90') }}" + - name: PENDING_INVITE_DAYS_THRESHOLD + value: "{{ custom.pending_invite_days_threshold | default('30') }}" + - name: MIN_OVERLAP_PRODUCTS + value: "{{ custom.min_overlap_products | default('2') }}" + - name: PRODUCTS + value: "{{ custom.products | default('All') }}" + - name: RECLAMATION_MIN_SEATS + value: "{{ custom.reclamation_min_seats | default('5') }}" + - name: TIMEOUT_SECONDS + value: "{{ custom.timeout_seconds | default('900') }}" + secretsProvided: + {% if wb_version %} + {% include "atlassian-auth.yaml" ignore missing %} + {% else %} + - name: atlassian_org_api_key + workspaceKey: AUTH DETAILS NOT FOUND + {% endif %} diff --git a/codebundles/atlassian-org-license-optimization/.test/README.md b/codebundles/atlassian-org-license-optimization/.test/README.md new file mode 100644 index 000000000..e10f5fbc2 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/README.md @@ -0,0 +1,29 @@ +# Test Infrastructure + +This bundle uses mock JSON fixtures instead of live Atlassian API calls for CI-friendly validation. + +## Scenarios + +| Scenario | Description | Expected issues | +|----------|-------------|-----------------| +| `no_reclamation_candidates` | All billable users active; no stale invites | 0 | +| `inactive_jira_users` | 5+ inactive Jira billable users | ≥1 | +| `overlap_and_invites` | Product overlap + 8 stale pending invites | ≥2 | + +## Run Tests + +```bash +cd .test +task +``` + +Or run individual scripts: + +```bash +./validate-bundle-structure.sh +./validate-all-tests.sh +``` + +## Terraform + +Live Atlassian organizations cannot be provisioned via Terraform. Mock fixtures in `fixtures/` provide deterministic test data aligned with the design spec scenarios. diff --git a/codebundles/atlassian-org-license-optimization/.test/Taskfile.yaml b/codebundles/atlassian-org-license-optimization/.test/Taskfile.yaml new file mode 100644 index 000000000..ec91c5c56 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/Taskfile.yaml @@ -0,0 +1,23 @@ +version: "3" + +tasks: + default: + desc: "Run complete test suite" + cmds: + - task: validate-structure + - task: test-all-scenarios + + validate-structure: + desc: "Static structure checks" + cmds: + - ./validate-bundle-structure.sh + + test-all-scenarios: + desc: "Run mock-based scenario tests" + cmds: + - ./validate-all-tests.sh + + clean: + desc: "Remove local test artifacts" + cmds: + - rm -f ../atlassian_*.json ../atlassian_*.md ../atlassian_user_inventory.json ../atlassian_directory_users.json diff --git a/codebundles/atlassian-org-license-optimization/.test/fixtures/inactive_jira_inventory.json b/codebundles/atlassian-org-license-optimization/.test/fixtures/inactive_jira_inventory.json new file mode 100644 index 000000000..1307df7c1 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/fixtures/inactive_jira_inventory.json @@ -0,0 +1,68 @@ +{ + "org_id": "test-org-002", + "org_name": "Beta Org", + "fetched_at": "2026-06-10T00:00:00Z", + "partial": false, + "users": [ + { + "account_id": "inactive-1", + "name": "Inactive Jira User 1", + "email": "inactive1@beta.example", + "access_billable": true, + "department": "Sales", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2025-01-01T00:00:00.000Z"} + ] + }, + { + "account_id": "inactive-2", + "name": "Inactive Jira User 2", + "email": "inactive2@beta.example", + "access_billable": true, + "department": "Sales", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2025-02-01T00:00:00.000Z"} + ] + }, + { + "account_id": "inactive-3", + "name": "Inactive Jira User 3", + "email": "inactive3@beta.example", + "access_billable": true, + "department": "Marketing", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2025-03-01T00:00:00.000Z"} + ] + }, + { + "account_id": "inactive-4", + "name": "Inactive Jira User 4", + "email": "inactive4@beta.example", + "access_billable": true, + "department": "Marketing", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2025-04-01T00:00:00.000Z"} + ] + }, + { + "account_id": "inactive-5", + "name": "Inactive Jira User 5", + "email": "inactive5@beta.example", + "access_billable": true, + "department": "Support", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2025-05-01T00:00:00.000Z"} + ] + }, + { + "account_id": "active-1", + "name": "Active User", + "email": "active@beta.example", + "access_billable": true, + "department": "Engineering", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-09T00:00:00.000Z"} + ] + } + ] +} diff --git a/codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_directory_users.json b/codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_directory_users.json new file mode 100644 index 000000000..6c9533762 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_directory_users.json @@ -0,0 +1,16 @@ +{ + "org_id": "test-org-001", + "directory_id": "dir-001", + "fetched_at": "2026-06-10T00:00:00Z", + "partial": false, + "users": [ + { + "accountId": "user-active-1", + "email": "active@acme.example", + "name": "Active User", + "membershipStatus": "active", + "accountStatus": "active", + "addedToOrg": "2025-01-01T00:00:00.000Z" + } + ] +} diff --git a/codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_inventory.json b/codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_inventory.json new file mode 100644 index 000000000..72ee33c9e --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/fixtures/no_reclamation_inventory.json @@ -0,0 +1,19 @@ +{ + "org_id": "test-org-001", + "org_name": "Acme Corp", + "fetched_at": "2026-06-10T00:00:00Z", + "partial": false, + "users": [ + { + "account_id": "user-active-1", + "name": "Active User", + "email": "active@acme.example", + "access_billable": true, + "department": "Engineering", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-09T10:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2026-06-08T10:00:00.000Z"} + ] + } + ] +} diff --git a/codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_directory_users.json b/codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_directory_users.json new file mode 100644 index 000000000..e929f126f --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_directory_users.json @@ -0,0 +1,72 @@ +{ + "org_id": "test-org-003", + "directory_id": "dir-003", + "fetched_at": "2026-06-10T00:00:00Z", + "partial": false, + "users": [ + { + "accountId": "pending-1", + "email": "pending1@gamma.example", + "name": "Pending User 1", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-12-01T00:00:00.000Z" + }, + { + "accountId": "pending-2", + "email": "pending2@gamma.example", + "name": "Pending User 2", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-11-15T00:00:00.000Z" + }, + { + "accountId": "pending-3", + "email": "pending3@gamma.example", + "name": "Pending User 3", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-10-01T00:00:00.000Z" + }, + { + "accountId": "pending-4", + "email": "pending4@gamma.example", + "name": "Pending User 4", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-09-01T00:00:00.000Z" + }, + { + "accountId": "pending-5", + "email": "pending5@gamma.example", + "name": "Pending User 5", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-08-01T00:00:00.000Z" + }, + { + "accountId": "pending-6", + "email": "pending6@gamma.example", + "name": "Pending User 6", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-07-01T00:00:00.000Z" + }, + { + "accountId": "pending-7", + "email": "pending7@gamma.example", + "name": "Pending User 7", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-06-01T00:00:00.000Z" + }, + { + "accountId": "pending-8", + "email": "pending8@gamma.example", + "name": "Pending User 8", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-05-01T00:00:00.000Z" + } + ] +} diff --git a/codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_inventory.json b/codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_inventory.json new file mode 100644 index 000000000..ea17d0398 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/fixtures/overlap_and_invites_inventory.json @@ -0,0 +1,68 @@ +{ + "org_id": "test-org-003", + "org_name": "Gamma Org", + "fetched_at": "2026-06-10T00:00:00Z", + "partial": false, + "users": [ + { + "account_id": "overlap-1", + "name": "Overlap User 1", + "email": "overlap1@gamma.example", + "access_billable": true, + "department": "Engineering", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-09T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-01-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-01-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-2", + "name": "Overlap User 2", + "email": "overlap2@gamma.example", + "access_billable": true, + "department": "Engineering", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-08T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-02-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-03-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-3", + "name": "Overlap User 3", + "email": "overlap3@gamma.example", + "access_billable": true, + "department": "Product", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-07T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-04-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-05-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-4", + "name": "Overlap User 4", + "email": "overlap4@gamma.example", + "access_billable": true, + "department": "Product", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-06T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-06-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-07-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-5", + "name": "Overlap User 5", + "email": "overlap5@gamma.example", + "access_billable": true, + "department": "Design", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-05T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-08-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-09-01T00:00:00.000Z"} + ] + } + ] +} diff --git a/codebundles/atlassian-org-license-optimization/.test/terraform/backend.tf b/codebundles/atlassian-org-license-optimization/.test/terraform/backend.tf new file mode 100644 index 000000000..3c533e6bf --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/terraform/backend.tf @@ -0,0 +1,5 @@ +terraform { + backend "local" { + path = "terraform.tfstate" + } +} diff --git a/codebundles/atlassian-org-license-optimization/.test/terraform/main.tf b/codebundles/atlassian-org-license-optimization/.test/terraform/main.tf new file mode 100644 index 000000000..53b5bf945 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/terraform/main.tf @@ -0,0 +1,6 @@ +# Atlassian Cloud organizations cannot be provisioned via Terraform in this test harness. +# Mock fixtures in ../fixtures/ provide deterministic scenario data instead. + +output "test_mode" { + value = "mock-fixtures" +} diff --git a/codebundles/atlassian-org-license-optimization/.test/terraform/outputs.tf b/codebundles/atlassian-org-license-optimization/.test/terraform/outputs.tf new file mode 100644 index 000000000..ccb3b2621 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/terraform/outputs.tf @@ -0,0 +1,4 @@ +output "fixture_path" { + description = "Path to mock fixture directory" + value = "${path.module}/../fixtures" +} diff --git a/codebundles/atlassian-org-license-optimization/.test/terraform/providers.tf b/codebundles/atlassian-org-license-optimization/.test/terraform/providers.tf new file mode 100644 index 000000000..7117131f4 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/terraform/providers.tf @@ -0,0 +1,3 @@ +terraform { + required_version = ">= 1.0" +} diff --git a/codebundles/atlassian-org-license-optimization/.test/terraform/terraform.tfvars b/codebundles/atlassian-org-license-optimization/.test/terraform/terraform.tfvars new file mode 100644 index 000000000..f6f614be8 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/terraform/terraform.tfvars @@ -0,0 +1 @@ +atlassian_org_id = "test-org-001" diff --git a/codebundles/atlassian-org-license-optimization/.test/terraform/variables.tf b/codebundles/atlassian-org-license-optimization/.test/terraform/variables.tf new file mode 100644 index 000000000..f44f5b810 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/terraform/variables.tf @@ -0,0 +1,5 @@ +variable "atlassian_org_id" { + description = "Placeholder — live org provisioning is not supported in test infra" + type = string + default = "test-org-001" +} diff --git a/codebundles/atlassian-org-license-optimization/.test/validate-all-tests.sh b/codebundles/atlassian-org-license-optimization/.test/validate-all-tests.sh new file mode 100755 index 000000000..caa994aac --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/validate-all-tests.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Run mock-based scenario tests without a live Atlassian organization. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIXTURES="${ROOT}/.test/fixtures" +cd "$ROOT" + +export ATLASSIAN_ORG_API_KEY="mock-key-not-used" +export ATLASSIAN_DIRECTORY_ID="dir-mock" +export INACTIVE_DAYS_THRESHOLD=90 +export PENDING_INVITE_DAYS_THRESHOLD=30 +export MIN_OVERLAP_PRODUCTS=2 +export RECLAMATION_MIN_SEATS=5 +export PRODUCTS=All + +run_scenario() { + local name="$1" + local inventory="$2" + local directory_users="${3:-}" + local expected_min_issues="${4:-0}" + + echo "=== Scenario: ${name} ===" + rm -f atlassian_user_inventory.json atlassian_directory_users.json \ + atlassian_inactive_billable_issues.json atlassian_product_overlap_issues.json \ + atlassian_pending_invite_issues.json atlassian_reclamation_issues.json \ + atlassian_inactive_billable_data.json atlassian_product_overlap_data.json \ + atlassian_pending_invite_data.json atlassian_license_reclamation_report.md + + export ATLASSIAN_MOCK_INVENTORY="${FIXTURES}/${inventory}" + if [[ -n "$directory_users" ]]; then + export ATLASSIAN_MOCK_DIRECTORY_USERS="${FIXTURES}/${directory_users}" + else + unset ATLASSIAN_MOCK_DIRECTORY_USERS || true + fi + + export ATLASSIAN_ORG_ID="test-org" + export ATLASSIAN_ORG_NAME="Test Org" + + ./identify-atlassian-inactive-billable-users.sh >/dev/null + ./analyze-atlassian-product-overlap.sh >/dev/null + ./surface-atlassian-pending-invites.sh >/dev/null + ./recommend-atlassian-license-reclamation.sh >/dev/null + + local total_issues=0 + for f in atlassian_inactive_billable_issues.json atlassian_product_overlap_issues.json \ + atlassian_pending_invite_issues.json atlassian_reclamation_issues.json; do + if [[ -f "$f" ]]; then + local count + count=$(jq 'length' "$f") + total_issues=$((total_issues + count)) + fi + done + + if [[ "$total_issues" -lt "$expected_min_issues" ]]; then + echo "FAIL: expected at least ${expected_min_issues} issues, got ${total_issues}" >&2 + exit 1 + fi + echo "PASS: ${total_issues} issue(s) emitted (min expected: ${expected_min_issues})" +} + +run_scenario "no_reclamation_candidates" "no_reclamation_inventory.json" "no_reclamation_directory_users.json" 0 + +# For inactive scenario, use inactive inventory with empty directory (no pending) +export ATLASSIAN_MOCK_DIRECTORY_USERS="${FIXTURES}/no_reclamation_directory_users.json" +run_scenario "inactive_jira_users" "inactive_jira_inventory.json" "no_reclamation_directory_users.json" 1 + +run_scenario "overlap_and_invites" "overlap_and_invites_inventory.json" "overlap_and_invites_directory_users.json" 2 + +echo "All mock scenarios passed." diff --git a/codebundles/atlassian-org-license-optimization/.test/validate-bundle-structure.sh b/codebundles/atlassian-org-license-optimization/.test/validate-bundle-structure.sh new file mode 100755 index 000000000..061d3d6e9 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.test/validate-bundle-structure.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +need=( + runbook.robot + sli.robot + README.md + atlassian-api-helpers.sh + identify-atlassian-inactive-billable-users.sh + analyze-atlassian-product-overlap.sh + surface-atlassian-pending-invites.sh + recommend-atlassian-license-reclamation.sh + sli-atlassian-license-reclamation-health.sh + .runwhen/generation-rules/atlassian-org-license-optimization.yaml + .runwhen/templates/atlassian-org-license-optimization-slx.yaml + .runwhen/templates/atlassian-org-license-optimization-taskset.yaml + .runwhen/templates/atlassian-org-license-optimization-sli.yaml +) + +for f in "${need[@]}"; do + if [[ ! -e "$f" ]]; then + echo "missing: $f" >&2 + exit 1 + fi +done + +for f in *.sh; do + if [[ ! -x "$f" ]]; then + echo "not executable: $f" >&2 + exit 1 + fi +done + +echo "atlassian-org-license-optimization bundle structure OK" diff --git a/codebundles/atlassian-org-license-optimization/README.md b/codebundles/atlassian-org-license-optimization/README.md new file mode 100644 index 000000000..37eda212e --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/README.md @@ -0,0 +1,75 @@ +# Atlassian Organization License Optimization + +Identifies Atlassian Cloud license waste and rightsizing opportunities across inactive billable users, overlapping product entitlements, and stale pending invites. Produces prioritized, read-only reclamation recommendations with estimated seat savings for IT and finance teams before renewal. + +## Overview + +- **Inactive billable users**: Flags users where `access_billable` is true but no monitored product shows activity within `INACTIVE_DAYS_THRESHOLD` days. +- **Product overlap**: Highlights users licensed on multiple products (Jira, Confluence, Loom, etc.) who are active on only a subset. +- **Pending invites**: Surfaces invited-but-not-accepted accounts and stale invitations that still consume tier capacity. +- **Reclamation recommendations**: Synthesizes findings into suspend/remove/consolidate guidance and a markdown handoff report. + +All tasks are strictly read-only. Scripts never call suspend, revoke, remove, or invite endpoints. + +## Configuration + +### Required Variables + +- `ATLASSIAN_ORG_ID`: Atlassian Cloud organization UUID. +- `ATLASSIAN_ORG_NAME`: Human-readable organization name for reports and task titles. + +### Optional Variables + +- `ATLASSIAN_DIRECTORY_ID`: Primary user directory ID (default: auto-discover via `GET /v2/orgs/{orgId}/directories`). +- `INACTIVE_DAYS_THRESHOLD`: Days without product activity before flagging a billable user as inactive (default: `90`). +- `PENDING_INVITE_DAYS_THRESHOLD`: Days an outstanding invite may sit before it is flagged as stale (default: `30`). +- `MIN_OVERLAP_PRODUCTS`: Minimum licensed products before overlap analysis applies (default: `2`). +- `PRODUCTS`: Comma-separated product keys to analyze or `All` (default: `All`). +- `RECLAMATION_MIN_SEATS`: Minimum reclaimable seats per category before emitting a recommendation issue (default: `5`). +- `TIMEOUT_SECONDS`: Per-task timeout for large organizations (default: `900`). +- `SLI_MAX_PAGES`: Maximum API pages fetched per SLI run for speed (default: `2`). + +### Secrets + +- `atlassian_org_api_key`: Organization Admin API key used as a Bearer token. Create at [Atlassian Administration](https://admin.atlassian.com/) with organization read permissions. + +### Platform Setup + +1. Create an Organization Admin API key with read access to user and directory data. +2. Bind the key as workspace secret `atlassian_org_api_key`. +3. Provide `ATLASSIAN_ORG_ID` and `ATLASSIAN_ORG_NAME` from your Atlassian organization settings. + +API reference: [Atlassian Organization REST API](https://developer.atlassian.com/cloud/admin/organization/rest/intro/) + +## Tasks Overview + +### Identify Inactive Billable Users Across Atlassian Products + +Pages managed accounts via `GET /v1/orgs/{orgId}/users`, evaluates per-product `last_active` dates, and groups inactive billable users by product and department metadata when available. Emits severity 2–3 issues when inactive counts are detected. + +### Analyze Overlapping Product Entitlements + +Finds users with `MIN_OVERLAP_PRODUCTS` or more licensed products who are inactive on one or more assignments. Explains Teamwork Collection licensing nuances where duplicate product rows may not imply duplicate billing. + +### Surface Pending Invites and Unaccepted Seats + +Queries `GET /v2/orgs/{orgId}/directories/{directoryId}/users` for pending membership status and flags stale invites older than `PENDING_INVITE_DAYS_THRESHOLD` days. + +### Recommend License Reclamation Actions + +Synthesizes prior task outputs into prioritized recommendations (suspend before remove, revoke stale invites, consolidate product access) with estimated seat savings and a consolidated `atlassian_license_reclamation_report.md` for handoff. + +## SLI + +The in-repo `sli.robot` produces a 0–1 health score across four dimensions: + +- Inactive billable users below `RECLAMATION_MIN_SEATS` +- Product overlap candidates below threshold +- No stale pending invites +- API reachability + +The SLI is healthy (score near 1) when zero severity-3+ reclamation signals are present. + +## Related Bundles + +- `atlassian-org-license-utilization` — utilization reporting; run together for full cost-management coverage. diff --git a/codebundles/atlassian-org-license-optimization/analyze-atlassian-product-overlap.sh b/codebundles/atlassian-org-license-optimization/analyze-atlassian-product-overlap.sh new file mode 100755 index 000000000..7ffbf866e --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/analyze-atlassian-product-overlap.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# ATLASSIAN_ORG_ID +# ATLASSIAN_ORG_NAME +# +# OPTIONAL: +# INACTIVE_DAYS_THRESHOLD (default 90) +# MIN_OVERLAP_PRODUCTS (default 2) +# PRODUCTS (default All) +# ----------------------------------------------------------------------------- + +: "${ATLASSIAN_ORG_ID:?Must set ATLASSIAN_ORG_ID}" +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" +: "${INACTIVE_DAYS_THRESHOLD:=90}" +: "${MIN_OVERLAP_PRODUCTS:=2}" +: "${PRODUCTS:=All}" + +OUTPUT_FILE="atlassian_product_overlap_issues.json" +SUMMARY_FILE="atlassian_product_overlap_summary.txt" +issues_json='[]' + +source "$(dirname "$0")/atlassian-api-helpers.sh" + +echo "Analyzing overlapping product entitlements for: ${ATLASSIAN_ORG_NAME}" +echo "Minimum licensed products for overlap: ${MIN_OVERLAP_PRODUCTS}" + +if ! ensure_user_inventory; then + issues_json=$(append_api_access_issue "$issues_json" "Failed to fetch managed user inventory.") + echo "$issues_json" > "$OUTPUT_FILE" + exit 0 +fi + +overlap_report=$(jq \ + --argjson threshold "$INACTIVE_DAYS_THRESHOLD" \ + --argjson min_products "$MIN_OVERLAP_PRODUCTS" \ + --arg products "$PRODUCTS" \ + 'def days_since_iso($iso): + if ($iso // "") == "" then 999999 + else (($iso | sub("\\.[0-9]+"; "") | fromdateiso8601?) as $ts | + if $ts == null then 999999 else (((now - $ts) / 86400) | floor) end) end; + def product_allowed($key): + if $products == "All" or $products == "" then true + else ($products | split(",") | map(gsub(" "; "")) | index($key)) != null end; + [.users[] | select(.access_billable == true) | . as $user | + ($user.product_access // []) | map(select(.key != null and product_allowed(.key))) as $pa | + select(($pa | length) >= $min_products) | + ($pa | map({ + key: .key, + days: days_since_iso(.last_active), + active: (days_since_iso(.last_active) < $threshold) + })) as $stats | + { + account_id: ($user.account_id // $user.accountId), + name: ($user.name // $user.email), + email: ($user.email // ""), + licensed: ($stats | map(.key)), + active_on: ($stats | map(select(.active)) | map(.key)), + redundant: ($stats | map(select(.active | not)) | map(.key)) + } | + select((.redundant | length) > 0) + ]' "$INVENTORY_CACHE_FILE") + +overlap_count=$(echo "$overlap_report" | jq 'length') + +{ + echo "Product Overlap Analysis — ${ATLASSIAN_ORG_NAME}" + echo "=================================================" + echo "Users with ${MIN_OVERLAP_PRODUCTS}+ licensed products but inactive on some: ${overlap_count}" + echo "" + echo "Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing." + echo " Consolidate access when users are active on only a subset of assigned products." + echo "" + echo "$overlap_report" | jq -r 'limit(15; .[]) | "- \(.name) <\(.email)>: licensed=\(.licensed | join(",")) active_on=\(.active_on | join(",")) redundant=\(.redundant | join(","))"' +} > "$SUMMARY_FILE" +cat "$SUMMARY_FILE" + +if [[ "$overlap_count" -ge 5 ]]; then + severity=3 +elif [[ "$overlap_count" -ge 1 ]]; then + severity=2 +else + severity=0 +fi + +if [[ "$severity" -ge 2 ]]; then + details=$(cat "$SUMMARY_FILE") + issues_json=$(echo "$issues_json" | jq \ + --arg title "Overlapping Product Entitlements in Atlassian Organization \`${ATLASSIAN_ORG_NAME}\`" \ + --arg details "$details" \ + --argjson severity "$severity" \ + --arg next_steps "In Atlassian Administration > Directory, review users with multiple product assignments. Revoke redundant product access for users active on fewer products. For Teamwork Collection, confirm whether seats are counted per unique user before removing access." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') +fi + +echo "$overlap_report" | jq '.' > atlassian_product_overlap_data.json +echo "$issues_json" > "$OUTPUT_FILE" +echo "Analysis completed. Results saved to ${OUTPUT_FILE}" diff --git a/codebundles/atlassian-org-license-optimization/atlassian-api-helpers.sh b/codebundles/atlassian-org-license-optimization/atlassian-api-helpers.sh new file mode 100755 index 000000000..9add661fb --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian-api-helpers.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +# Shared read-only helpers for Atlassian Organization Admin API. +set -euo pipefail + +ATLASSIAN_API_BASE="${ATLASSIAN_API_BASE:-https://api.atlassian.com/admin}" +INVENTORY_CACHE_FILE="${INVENTORY_CACHE_FILE:-atlassian_user_inventory.json}" +DIRECTORY_USERS_CACHE_FILE="${DIRECTORY_USERS_CACHE_FILE:-atlassian_directory_users.json}" +CACHE_MAX_AGE_SECONDS="${CACHE_MAX_AGE_SECONDS:-3600}" + +_atlassian_api_key() { + local key="${ATLASSIAN_ORG_API_KEY:-${atlassian_org_api_key:-}}" + if [[ -z "$key" ]]; then + echo "ERROR: ATLASSIAN_ORG_API_KEY (or atlassian_org_api_key secret) is required." >&2 + return 1 + fi + printf '%s' "$key" +} + +_atlassian_curl() { + local method="$1" + local url="$2" + local api_key + api_key="$(_atlassian_api_key)" || return 1 + curl -sS -X "$method" \ + -H "Authorization: Bearer ${api_key}" \ + -H "Accept: application/json" \ + "$url" +} + +_cache_is_fresh() { + local cache_file="$1" + [[ -f "$cache_file" ]] || return 1 + local age + age=$(( $(date +%s) - $(stat -c %Y "$cache_file" 2>/dev/null || stat -f %m "$cache_file") )) + [[ "$age" -le "$CACHE_MAX_AGE_SECONDS" ]] +} + +discover_directory_id() { + if [[ -n "${ATLASSIAN_DIRECTORY_ID:-}" ]]; then + printf '%s' "$ATLASSIAN_DIRECTORY_ID" + return 0 + fi + + local response + if ! response="$(_atlassian_curl GET "${ATLASSIAN_API_BASE}/v2/orgs/${ATLASSIAN_ORG_ID}/directories")"; then + return 1 + fi + + local dir_id + dir_id=$(echo "$response" | jq -r '.data[0].directoryId // .data[0].id // empty') + if [[ -z "$dir_id" || "$dir_id" == "null" ]]; then + echo "ERROR: Could not auto-discover directory ID for org ${ATLASSIAN_ORG_ID}." >&2 + return 1 + fi + printf '%s' "$dir_id" +} + +_load_mock_inventory() { + local mock_file="${ATLASSIAN_MOCK_INVENTORY:-}" + if [[ -n "$mock_file" && -f "$mock_file" ]]; then + cp "$mock_file" "$INVENTORY_CACHE_FILE" + return 0 + fi + return 1 +} + +_load_mock_directory_users() { + local mock_file="${ATLASSIAN_MOCK_DIRECTORY_USERS:-}" + if [[ -n "$mock_file" && -f "$mock_file" ]]; then + cp "$mock_file" "$DIRECTORY_USERS_CACHE_FILE" + return 0 + fi + return 1 +} + +fetch_managed_users_page() { + local cursor="${1:-}" + local url="${ATLASSIAN_API_BASE}/v1/orgs/${ATLASSIAN_ORG_ID}/users" + if [[ -n "$cursor" ]]; then + url="${url}?cursor=${cursor}" + fi + _atlassian_curl GET "$url" +} + +ensure_user_inventory() { + if _cache_is_fresh "$INVENTORY_CACHE_FILE"; then + return 0 + fi + if _load_mock_inventory; then + return 0 + fi + + local all_users='[]' + local cursor="" + local page=0 + local max_pages="${ATLASSIAN_MAX_PAGES:-0}" + local start_ts + start_ts=$(date +%s) + + while :; do + page=$((page + 1)) + if [[ "$max_pages" -gt 0 && "$page" -gt "$max_pages" ]]; then + echo "WARNING: Stopped pagination at page limit (${max_pages}); inventory may be partial." >&2 + break + fi + if [[ -n "${TIMEOUT_SECONDS:-}" ]]; then + local elapsed=$(( $(date +%s) - start_ts )) + if [[ "$elapsed" -ge "$TIMEOUT_SECONDS" ]]; then + echo "WARNING: TIMEOUT_SECONDS (${TIMEOUT_SECONDS}) exceeded during user inventory fetch." >&2 + break + fi + fi + + local response + if ! response="$(fetch_managed_users_page "$cursor")"; then + echo "ERROR: Failed to fetch managed users page ${page}." >&2 + return 1 + fi + + if echo "$response" | jq -e '.message? // .errorMessage? // empty' >/dev/null 2>&1; then + local err + err=$(echo "$response" | jq -r '.message // .errorMessage // "unknown API error"') + echo "ERROR: Atlassian API error: ${err}" >&2 + return 1 + fi + + local page_users + page_users=$(echo "$response" | jq -c '.data // []') + all_users=$(jq -s 'add' <(echo "$all_users") <(echo "$page_users")) + + cursor=$(echo "$response" | jq -r '.links.next // empty') + [[ -n "$cursor" && "$cursor" != "null" ]] || break + done + + jq -n \ + --arg org_id "$ATLASSIAN_ORG_ID" \ + --arg org_name "${ATLASSIAN_ORG_NAME:-}" \ + --arg fetched_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson users "$all_users" \ + '{ + org_id: $org_id, + org_name: $org_name, + fetched_at: $fetched_at, + partial: false, + users: $users + }' > "$INVENTORY_CACHE_FILE" +} + +fetch_directory_users_page() { + local directory_id="$1" + local cursor="${2:-}" + local url="${ATLASSIAN_API_BASE}/v2/orgs/${ATLASSIAN_ORG_ID}/directories/${directory_id}/users?limit=100" + if [[ -n "$cursor" ]]; then + url="${url}&cursor=${cursor}" + fi + _atlassian_curl GET "$url" +} + +ensure_directory_users() { + if _cache_is_fresh "$DIRECTORY_USERS_CACHE_FILE"; then + return 0 + fi + if _load_mock_directory_users; then + return 0 + fi + + local directory_id + directory_id="$(discover_directory_id)" || return 1 + + local all_users='[]' + local cursor="" + local page=0 + local max_pages="${ATLASSIAN_MAX_PAGES:-0}" + + while :; do + page=$((page + 1)) + if [[ "$max_pages" -gt 0 && "$page" -gt "$max_pages" ]]; then + echo "WARNING: Stopped directory user pagination at page limit (${max_pages})." >&2 + break + fi + + local response + if ! response="$(fetch_directory_users_page "$directory_id" "$cursor")"; then + echo "ERROR: Failed to fetch directory users page ${page}." >&2 + return 1 + fi + + local page_users + page_users=$(echo "$response" | jq -c '.data // []') + all_users=$(jq -s 'add' <(echo "$all_users") <(echo "$page_users")) + + cursor=$(echo "$response" | jq -r '.links.next // empty') + [[ -n "$cursor" && "$cursor" != "null" ]] || break + done + + jq -n \ + --arg org_id "$ATLASSIAN_ORG_ID" \ + --arg directory_id "$directory_id" \ + --arg fetched_at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --argjson users "$all_users" \ + '{ + org_id: $org_id, + directory_id: $directory_id, + fetched_at: $fetched_at, + partial: false, + users: $users + }' > "$DIRECTORY_USERS_CACHE_FILE" +} + +days_since() { + local iso_date="$1" + if [[ -z "$iso_date" || "$iso_date" == "null" ]]; then + printf '%s' "999999" + return 0 + fi + local now epoch then_epoch + now=$(date +%s) + then_epoch=$(date -d "$iso_date" +%s 2>/dev/null || date -j -f "%Y-%m-%dT%H:%M:%S" "${iso_date%%.*}" +%s 2>/dev/null || echo 0) + if [[ "$then_epoch" -eq 0 ]]; then + printf '%s' "999999" + return 0 + fi + echo $(( (now - then_epoch) / 86400 )) +} + +product_filter_active() { + local products="${PRODUCTS:-All}" + if [[ "$products" == "All" || -z "$products" ]]; then + return 0 + fi + local key="$1" + IFS=',' read -ra wanted <<< "$products" + for p in "${wanted[@]}"; do + p="${p// /}" + if [[ "$p" == "$key" ]]; then + return 0 + fi + done + return 1 +} + +append_api_access_issue() { + local issues_json="$1" + local org_name="${ATLASSIAN_ORG_NAME:-$ATLASSIAN_ORG_ID}" + local details="$2" + echo "$issues_json" | jq \ + --arg title "Cannot Access Atlassian Organization \`${org_name}\`" \ + --arg details "$details" \ + --arg severity "4" \ + --arg next_steps "Verify the Organization Admin API key has org-admin read permissions. Confirm ATLASSIAN_ORG_ID is correct. Re-run after updating the atlassian_org_api_key secret." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]' +} diff --git a/codebundles/atlassian-org-license-optimization/atlassian_directory_users.json b/codebundles/atlassian-org-license-optimization/atlassian_directory_users.json new file mode 100644 index 000000000..e929f126f --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_directory_users.json @@ -0,0 +1,72 @@ +{ + "org_id": "test-org-003", + "directory_id": "dir-003", + "fetched_at": "2026-06-10T00:00:00Z", + "partial": false, + "users": [ + { + "accountId": "pending-1", + "email": "pending1@gamma.example", + "name": "Pending User 1", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-12-01T00:00:00.000Z" + }, + { + "accountId": "pending-2", + "email": "pending2@gamma.example", + "name": "Pending User 2", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-11-15T00:00:00.000Z" + }, + { + "accountId": "pending-3", + "email": "pending3@gamma.example", + "name": "Pending User 3", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-10-01T00:00:00.000Z" + }, + { + "accountId": "pending-4", + "email": "pending4@gamma.example", + "name": "Pending User 4", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-09-01T00:00:00.000Z" + }, + { + "accountId": "pending-5", + "email": "pending5@gamma.example", + "name": "Pending User 5", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-08-01T00:00:00.000Z" + }, + { + "accountId": "pending-6", + "email": "pending6@gamma.example", + "name": "Pending User 6", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-07-01T00:00:00.000Z" + }, + { + "accountId": "pending-7", + "email": "pending7@gamma.example", + "name": "Pending User 7", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-06-01T00:00:00.000Z" + }, + { + "accountId": "pending-8", + "email": "pending8@gamma.example", + "name": "Pending User 8", + "membershipStatus": "pending", + "accountStatus": "pending", + "addedToOrg": "2025-05-01T00:00:00.000Z" + } + ] +} diff --git a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json @@ -0,0 +1 @@ +[] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json new file mode 100644 index 000000000..fe51488c7 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json @@ -0,0 +1 @@ +[] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt new file mode 100644 index 000000000..a9170464b --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt @@ -0,0 +1,8 @@ +Inactive Billable Users Analysis — Test Org +============================================================ +Billable users scanned: 5 +Inactive billable users (>90d): 0 + +By product: + +Sample inactive users (up to 10): diff --git a/codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md b/codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md new file mode 100644 index 000000000..cb92d8d80 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md @@ -0,0 +1,66 @@ +# Atlassian License Reclamation Report + +**Organization:** Test Org (`test-org`) +**Generated:** 2026-06-10T13:38:05Z + +## Executive Summary + +| Category | Reclaimable signal | Estimated seats | +|----------|-------------------|-----------------| +| Inactive billable users (>90d) | 0 issue(s) | 0 | +| Redundant product access | 1 issue(s) | 10 | +| Stale pending invites (>=30d) | 1 issue(s) | 8 | + +## Prioritized Recommendations + +1. **Suspend before remove** — suspend inactive users first to stop billing while preserving group memberships for easy restore. +2. **Revoke stale invites** — pending invitations consume tier capacity until revoked in Atlassian Administration. +3. **Consolidate product access** — remove redundant product licenses for users active on a subset of products. +4. **Teamwork Collection note** — duplicate product rows may not imply duplicate billing; confirm licensing model before bulk removal. + +## Administration Paths + +- Managed accounts: https://admin.atlassian.com/o/test-org/users +- Suspend (directory API, operator action): POST /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}/suspend +- Remove from directory (operator action): DELETE /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId} + +## Findings Detail + +### Inactive Billable Users + Inactive Billable Users Analysis — Test Org + ============================================================ + Billable users scanned: 5 + Inactive billable users (>90d): 0 + + By product: + + Sample inactive users (up to 10): + +### Product Overlap + Product Overlap Analysis — Test Org + ================================================= + Users with 2+ licensed products but inactive on some: 5 + + Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing. + Consolidate access when users are active on only a subset of assigned products. + + - Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom + - Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom + - Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom + - Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom + - Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom + +### Pending Invites + Pending Invites Analysis — Test Org + =============================================== + Pending / unaccepted users: 8 + Stale invites (>=30 days): 8 + + - pending1@gamma.example status=pending days_pending=191 stale=true + - pending2@gamma.example status=pending days_pending=207 stale=true + - pending3@gamma.example status=pending days_pending=252 stale=true + - pending4@gamma.example status=pending days_pending=282 stale=true + - pending5@gamma.example status=pending days_pending=313 stale=true + - pending6@gamma.example status=pending days_pending=344 stale=true + - pending7@gamma.example status=pending days_pending=374 stale=true + - pending8@gamma.example status=pending days_pending=405 stale=true diff --git a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json new file mode 100644 index 000000000..8e87d36e8 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json @@ -0,0 +1,74 @@ +[ + { + "account_id": "pending-1", + "email": "pending1@gamma.example", + "name": "Pending User 1", + "membership_status": "pending", + "added_to_org": "2025-12-01T00:00:00.000Z", + "days_pending": 191, + "stale": true + }, + { + "account_id": "pending-2", + "email": "pending2@gamma.example", + "name": "Pending User 2", + "membership_status": "pending", + "added_to_org": "2025-11-15T00:00:00.000Z", + "days_pending": 207, + "stale": true + }, + { + "account_id": "pending-3", + "email": "pending3@gamma.example", + "name": "Pending User 3", + "membership_status": "pending", + "added_to_org": "2025-10-01T00:00:00.000Z", + "days_pending": 252, + "stale": true + }, + { + "account_id": "pending-4", + "email": "pending4@gamma.example", + "name": "Pending User 4", + "membership_status": "pending", + "added_to_org": "2025-09-01T00:00:00.000Z", + "days_pending": 282, + "stale": true + }, + { + "account_id": "pending-5", + "email": "pending5@gamma.example", + "name": "Pending User 5", + "membership_status": "pending", + "added_to_org": "2025-08-01T00:00:00.000Z", + "days_pending": 313, + "stale": true + }, + { + "account_id": "pending-6", + "email": "pending6@gamma.example", + "name": "Pending User 6", + "membership_status": "pending", + "added_to_org": "2025-07-01T00:00:00.000Z", + "days_pending": 344, + "stale": true + }, + { + "account_id": "pending-7", + "email": "pending7@gamma.example", + "name": "Pending User 7", + "membership_status": "pending", + "added_to_org": "2025-06-01T00:00:00.000Z", + "days_pending": 374, + "stale": true + }, + { + "account_id": "pending-8", + "email": "pending8@gamma.example", + "name": "Pending User 8", + "membership_status": "pending", + "added_to_org": "2025-05-01T00:00:00.000Z", + "days_pending": 405, + "stale": true + } +] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json new file mode 100644 index 000000000..be2333931 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json @@ -0,0 +1,8 @@ +[ + { + "title": "Stale Pending Invites in Atlassian Organization `Test Org`", + "details": "Pending Invites Analysis — Test Org\n===============================================\nPending / unaccepted users: 8\nStale invites (>=30 days): 8\n\n- pending1@gamma.example status=pending days_pending=191 stale=true\n- pending2@gamma.example status=pending days_pending=207 stale=true\n- pending3@gamma.example status=pending days_pending=252 stale=true\n- pending4@gamma.example status=pending days_pending=282 stale=true\n- pending5@gamma.example status=pending days_pending=313 stale=true\n- pending6@gamma.example status=pending days_pending=344 stale=true\n- pending7@gamma.example status=pending days_pending=374 stale=true\n- pending8@gamma.example status=pending days_pending=405 stale=true", + "severity": 3, + "next_steps": "Revoke stale invitations in Atlassian Administration > Directory > Users. Path: admin.atlassian.com/o/test-org/users. Pending invites consume seats until accepted or revoked." + } +] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt new file mode 100644 index 000000000..ca30786a9 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt @@ -0,0 +1,13 @@ +Pending Invites Analysis — Test Org +=============================================== +Pending / unaccepted users: 8 +Stale invites (>=30 days): 8 + +- pending1@gamma.example status=pending days_pending=191 stale=true +- pending2@gamma.example status=pending days_pending=207 stale=true +- pending3@gamma.example status=pending days_pending=252 stale=true +- pending4@gamma.example status=pending days_pending=282 stale=true +- pending5@gamma.example status=pending days_pending=313 stale=true +- pending6@gamma.example status=pending days_pending=344 stale=true +- pending7@gamma.example status=pending days_pending=374 stale=true +- pending8@gamma.example status=pending days_pending=405 stale=true diff --git a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json new file mode 100644 index 000000000..b6e38ef7d --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json @@ -0,0 +1,87 @@ +[ + { + "account_id": "overlap-1", + "name": "Overlap User 1", + "email": "overlap1@gamma.example", + "licensed": [ + "jira-software", + "confluence", + "loom" + ], + "active_on": [ + "jira-software" + ], + "redundant": [ + "confluence", + "loom" + ] + }, + { + "account_id": "overlap-2", + "name": "Overlap User 2", + "email": "overlap2@gamma.example", + "licensed": [ + "jira-software", + "confluence", + "loom" + ], + "active_on": [ + "jira-software" + ], + "redundant": [ + "confluence", + "loom" + ] + }, + { + "account_id": "overlap-3", + "name": "Overlap User 3", + "email": "overlap3@gamma.example", + "licensed": [ + "jira-software", + "confluence", + "loom" + ], + "active_on": [ + "jira-software" + ], + "redundant": [ + "confluence", + "loom" + ] + }, + { + "account_id": "overlap-4", + "name": "Overlap User 4", + "email": "overlap4@gamma.example", + "licensed": [ + "jira-software", + "confluence", + "loom" + ], + "active_on": [ + "jira-software" + ], + "redundant": [ + "confluence", + "loom" + ] + }, + { + "account_id": "overlap-5", + "name": "Overlap User 5", + "email": "overlap5@gamma.example", + "licensed": [ + "jira-software", + "confluence", + "loom" + ], + "active_on": [ + "jira-software" + ], + "redundant": [ + "confluence", + "loom" + ] + } +] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json new file mode 100644 index 000000000..b6793c3de --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json @@ -0,0 +1,8 @@ +[ + { + "title": "Overlapping Product Entitlements in Atlassian Organization `Test Org`", + "details": "Product Overlap Analysis — Test Org\n=================================================\nUsers with 2+ licensed products but inactive on some: 5\n\nNote: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing.\n Consolidate access when users are active on only a subset of assigned products.\n\n- Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom", + "severity": 3, + "next_steps": "In Atlassian Administration > Directory, review users with multiple product assignments. Revoke redundant product access for users active on fewer products. For Teamwork Collection, confirm whether seats are counted per unique user before removing access." + } +] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt new file mode 100644 index 000000000..2c0356420 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt @@ -0,0 +1,12 @@ +Product Overlap Analysis — Test Org +================================================= +Users with 2+ licensed products but inactive on some: 5 + +Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing. + Consolidate access when users are active on only a subset of assigned products. + +- Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom +- Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom +- Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom +- Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom +- Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom diff --git a/codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json new file mode 100644 index 000000000..70092c78c --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json @@ -0,0 +1,14 @@ +[ + { + "title": "Consolidate redundant product access for Atlassian Organization `Test Org`", + "details": "Action: consolidate_product_access\\nEstimated reclaimable seats: 10\\n\\n# Atlassian License Reclamation Report\n\n**Organization:** Test Org (`test-org`)\n**Generated:** 2026-06-10T13:38:05Z\n\n## Executive Summary\n\n| Category | Reclaimable signal | Estimated seats |\n|----------|-------------------|-----------------|\n| Inactive billable users (>90d) | 0 issue(s) | 0 |\n| Redundant product access | 1 issue(s) | 10 |\n| Stale pending invites (>=30d) | 1 issue(s) | 8 |\n\n## Prioritized Recommendations\n\n1. **Suspend before remove** — suspend inactive users first to stop billing while preserving group memberships for easy restore.\n2. **Revoke stale invites** — pending invitations consume tier capacity until revoked in Atlassian Administration.\n3. **Consolidate product access** — remove redundant product licenses for users active on a subset of products.\n4. **Teamwork Collection note** — duplicate product rows may not imply duplicate billing; confirm licensing model before bulk removal.\n\n## Administration Paths\n\n- Managed accounts: https://admin.atlassian.com/o/test-org/users\n- Suspend (directory API, operator action): POST /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}/suspend\n- Remove from directory (operator action): DELETE /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}\n\n## Findings Detail\n\n### Inactive Billable Users\n Inactive Billable Users Analysis — Test Org\n ============================================================\n Billable users scanned: 5\n Inactive billable users (>90d): 0\n \n By product:\n \n Sample inactive users (up to 10):\n\n### Product Overlap\n Product Overlap Analysis — Test Org\n =================================================\n Users with 2+ licensed products but inactive on some: 5\n \n Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing.\n Consolidate access when users are active on only a subset of assigned products.\n \n - Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n\n### Pending Invites\n Pending Invites Analysis — Test Org\n ===============================================\n Pending / unaccepted users: 8\n Stale invites (>=30 days): 8\n \n - pending1@gamma.example status=pending days_pending=191 stale=true\n - pending2@gamma.example status=pending days_pending=207 stale=true\n - pending3@gamma.example status=pending days_pending=252 stale=true\n - pending4@gamma.example status=pending days_pending=282 stale=true\n - pending5@gamma.example status=pending days_pending=313 stale=true\n - pending6@gamma.example status=pending days_pending=344 stale=true\n - pending7@gamma.example status=pending days_pending=374 stale=true\n - pending8@gamma.example status=pending days_pending=405 stale=true", + "severity": 3, + "next_steps": "Revoke redundant product licenses for users active on fewer than 10 assigned products." + }, + { + "title": "Revoke stale pending invitations for Atlassian Organization `Test Org`", + "details": "Action: revoke_stale_invites\\nEstimated reclaimable seats: 8\\n\\n# Atlassian License Reclamation Report\n\n**Organization:** Test Org (`test-org`)\n**Generated:** 2026-06-10T13:38:05Z\n\n## Executive Summary\n\n| Category | Reclaimable signal | Estimated seats |\n|----------|-------------------|-----------------|\n| Inactive billable users (>90d) | 0 issue(s) | 0 |\n| Redundant product access | 1 issue(s) | 10 |\n| Stale pending invites (>=30d) | 1 issue(s) | 8 |\n\n## Prioritized Recommendations\n\n1. **Suspend before remove** — suspend inactive users first to stop billing while preserving group memberships for easy restore.\n2. **Revoke stale invites** — pending invitations consume tier capacity until revoked in Atlassian Administration.\n3. **Consolidate product access** — remove redundant product licenses for users active on a subset of products.\n4. **Teamwork Collection note** — duplicate product rows may not imply duplicate billing; confirm licensing model before bulk removal.\n\n## Administration Paths\n\n- Managed accounts: https://admin.atlassian.com/o/test-org/users\n- Suspend (directory API, operator action): POST /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}/suspend\n- Remove from directory (operator action): DELETE /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}\n\n## Findings Detail\n\n### Inactive Billable Users\n Inactive Billable Users Analysis — Test Org\n ============================================================\n Billable users scanned: 5\n Inactive billable users (>90d): 0\n \n By product:\n \n Sample inactive users (up to 10):\n\n### Product Overlap\n Product Overlap Analysis — Test Org\n =================================================\n Users with 2+ licensed products but inactive on some: 5\n \n Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing.\n Consolidate access when users are active on only a subset of assigned products.\n \n - Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n\n### Pending Invites\n Pending Invites Analysis — Test Org\n ===============================================\n Pending / unaccepted users: 8\n Stale invites (>=30 days): 8\n \n - pending1@gamma.example status=pending days_pending=191 stale=true\n - pending2@gamma.example status=pending days_pending=207 stale=true\n - pending3@gamma.example status=pending days_pending=252 stale=true\n - pending4@gamma.example status=pending days_pending=282 stale=true\n - pending5@gamma.example status=pending days_pending=313 stale=true\n - pending6@gamma.example status=pending days_pending=344 stale=true\n - pending7@gamma.example status=pending days_pending=374 stale=true\n - pending8@gamma.example status=pending days_pending=405 stale=true", + "severity": 3, + "next_steps": "Revoke 8 stale pending invites in Atlassian Administration > Directory > Users." + } +] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json b/codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json new file mode 100644 index 000000000..ea17d0398 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json @@ -0,0 +1,68 @@ +{ + "org_id": "test-org-003", + "org_name": "Gamma Org", + "fetched_at": "2026-06-10T00:00:00Z", + "partial": false, + "users": [ + { + "account_id": "overlap-1", + "name": "Overlap User 1", + "email": "overlap1@gamma.example", + "access_billable": true, + "department": "Engineering", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-09T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-01-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-01-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-2", + "name": "Overlap User 2", + "email": "overlap2@gamma.example", + "access_billable": true, + "department": "Engineering", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-08T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-02-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-03-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-3", + "name": "Overlap User 3", + "email": "overlap3@gamma.example", + "access_billable": true, + "department": "Product", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-07T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-04-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-05-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-4", + "name": "Overlap User 4", + "email": "overlap4@gamma.example", + "access_billable": true, + "department": "Product", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-06T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-06-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-07-01T00:00:00.000Z"} + ] + }, + { + "account_id": "overlap-5", + "name": "Overlap User 5", + "email": "overlap5@gamma.example", + "access_billable": true, + "department": "Design", + "product_access": [ + {"key": "jira-software", "name": "Jira", "last_active": "2026-06-05T00:00:00.000Z"}, + {"key": "confluence", "name": "Confluence", "last_active": "2025-08-01T00:00:00.000Z"}, + {"key": "loom", "name": "Loom", "last_active": "2025-09-01T00:00:00.000Z"} + ] + } + ] +} diff --git a/codebundles/atlassian-org-license-optimization/identify-atlassian-inactive-billable-users.sh b/codebundles/atlassian-org-license-optimization/identify-atlassian-inactive-billable-users.sh new file mode 100755 index 000000000..7df2da74d --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/identify-atlassian-inactive-billable-users.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# ATLASSIAN_ORG_ID +# ATLASSIAN_ORG_NAME +# +# OPTIONAL: +# INACTIVE_DAYS_THRESHOLD (default 90) +# PRODUCTS (default All) +# TIMEOUT_SECONDS +# ----------------------------------------------------------------------------- + +: "${ATLASSIAN_ORG_ID:?Must set ATLASSIAN_ORG_ID}" +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" +: "${INACTIVE_DAYS_THRESHOLD:=90}" +: "${PRODUCTS:=All}" + +OUTPUT_FILE="atlassian_inactive_billable_issues.json" +SUMMARY_FILE="atlassian_inactive_billable_summary.txt" +issues_json='[]' + +source "$(dirname "$0")/atlassian-api-helpers.sh" + +echo "Identifying inactive billable users for organization: ${ATLASSIAN_ORG_NAME} (${ATLASSIAN_ORG_ID})" +echo "Inactive threshold: ${INACTIVE_DAYS_THRESHOLD} days" +echo "Products filter: ${PRODUCTS}" + +if ! ensure_user_inventory; then + issues_json=$(append_api_access_issue "$issues_json" "Failed to fetch managed user inventory from GET /v1/orgs/{orgId}/users.") + echo "$issues_json" > "$OUTPUT_FILE" + echo "API access failure. See ${OUTPUT_FILE}" > "$SUMMARY_FILE" + exit 0 +fi + +inactive_users=$(jq \ + --argjson threshold "$INACTIVE_DAYS_THRESHOLD" \ + --arg products "$PRODUCTS" \ + 'def days_since_iso($iso): + if ($iso // "") == "" then 999999 + else (($iso | sub("\\.[0-9]+"; "") | fromdateiso8601?) as $ts | + if $ts == null then 999999 else (((now - $ts) / 86400) | floor) end) end; + def product_allowed($key): + if $products == "All" or $products == "" then true + else ($products | split(",") | map(gsub(" "; "")) | index($key)) != null end; + [.users[] | select(.access_billable == true) | . as $user | + ($user.product_access // []) | map(select(.key != null and product_allowed(.key))) as $pa | + if ($pa | length) == 0 then + {account_id: ($user.account_id // $user.accountId), name: ($user.name // $user.email), email: ($user.email // ""), + department: ($user.department // "unknown"), licensed_products: [], days_since_activity: 999999, inactive: true} + else + ($pa | map({key: .key, days: days_since_iso(.last_active)})) as $stats | + { + account_id: ($user.account_id // $user.accountId), + name: ($user.name // $user.email), + email: ($user.email // ""), + department: ($user.department // "unknown"), + licensed_products: ($stats | map(.key)), + days_since_activity: ($stats | map(.days) | min), + inactive: (all($stats[]; .days >= $threshold)) + } + end | + select(.inactive) + ]' "$INVENTORY_CACHE_FILE") + +total_billable=$(jq '[.users[] | select(.access_billable == true)] | length' "$INVENTORY_CACHE_FILE") +total_inactive=$(echo "$inactive_users" | jq 'length') + +inactive_by_product=$(echo "$inactive_users" | jq ' + [.[].licensed_products[]?] | group_by(.) | map({key: .[0], value: length}) | from_entries +') + +{ + echo "Inactive Billable Users Analysis — ${ATLASSIAN_ORG_NAME}" + echo "============================================================" + echo "Billable users scanned: ${total_billable}" + echo "Inactive billable users (>${INACTIVE_DAYS_THRESHOLD}d): ${total_inactive}" + echo "" + echo "By product:" + echo "$inactive_by_product" | jq -r 'to_entries[]? | "- \(.key): \(.value)"' 2>/dev/null || echo "(none)" + echo "" + echo "Sample inactive users (up to 10):" + echo "$inactive_users" | jq -r 'limit(10; .[]) | "- \(.name) <\(.email)> products=\(.licensed_products | join(",")) days=\(.days_since_activity) dept=\(.department)"' +} > "$SUMMARY_FILE" +cat "$SUMMARY_FILE" + +if [[ "$total_inactive" -ge 5 ]]; then + severity=3 +elif [[ "$total_inactive" -ge 1 ]]; then + severity=2 +else + severity=0 +fi + +if [[ "$severity" -ge 2 ]]; then + details=$(cat "$SUMMARY_FILE") + issues_json=$(echo "$issues_json" | jq \ + --arg title "Inactive Billable Users in Atlassian Organization \`${ATLASSIAN_ORG_NAME}\`" \ + --arg details "$details" \ + --argjson severity "$severity" \ + --arg next_steps "Review inactive users in Atlassian Administration > Directory > Managed accounts. Suspend access before removal to preserve group memberships. Path: admin.atlassian.com/o/${ATLASSIAN_ORG_ID}/users. Consider revoking product access for users inactive >${INACTIVE_DAYS_THRESHOLD} days." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') +fi + +echo "$inactive_users" | jq '.' > atlassian_inactive_billable_data.json +echo "$issues_json" > "$OUTPUT_FILE" +echo "Analysis completed. Results saved to ${OUTPUT_FILE}" diff --git a/codebundles/atlassian-org-license-optimization/recommend-atlassian-license-reclamation.sh b/codebundles/atlassian-org-license-optimization/recommend-atlassian-license-reclamation.sh new file mode 100755 index 000000000..ef18f2c67 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/recommend-atlassian-license-reclamation.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# ATLASSIAN_ORG_ID +# ATLASSIAN_ORG_NAME +# +# OPTIONAL: +# RECLAMATION_MIN_SEATS (default 5) +# INACTIVE_DAYS_THRESHOLD, PENDING_INVITE_DAYS_THRESHOLD, MIN_OVERLAP_PRODUCTS +# ----------------------------------------------------------------------------- + +: "${ATLASSIAN_ORG_ID:?Must set ATLASSIAN_ORG_ID}" +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" +: "${RECLAMATION_MIN_SEATS:=5}" +: "${INACTIVE_DAYS_THRESHOLD:=90}" +: "${PENDING_INVITE_DAYS_THRESHOLD:=30}" +: "${MIN_OVERLAP_PRODUCTS:=2}" +: "${PRODUCTS:=All}" + +OUTPUT_FILE="atlassian_reclamation_issues.json" +REPORT_FILE="atlassian_license_reclamation_report.md" +issues_json='[]' + +source "$(dirname "$0")/atlassian-api-helpers.sh" + +echo "Synthesizing license reclamation recommendations for: ${ATLASSIAN_ORG_NAME}" + +# Run upstream analyzers if their outputs are absent. +if [[ ! -f atlassian_inactive_billable_issues.json ]]; then + ./identify-atlassian-inactive-billable-users.sh || true +fi +if [[ ! -f atlassian_product_overlap_issues.json ]]; then + ./analyze-atlassian-product-overlap.sh || true +fi +if [[ ! -f atlassian_pending_invite_issues.json ]]; then + ./surface-atlassian-pending-invites.sh || true +fi + +read_array() { + local file="$1" + if [[ -f "$file" ]]; then + cat "$file" + else + echo '[]' + fi +} + +inactive_issues=$(read_array atlassian_inactive_billable_issues.json) +overlap_issues=$(read_array atlassian_product_overlap_issues.json) +invite_issues=$(read_array atlassian_pending_invite_issues.json) + +inactive_count=$(jq '[.[] | select(.severity >= 2)] | length' <<< "$inactive_issues") +overlap_count=$(jq '[.[] | select(.severity >= 2)] | length' <<< "$overlap_issues") +invite_count=$(jq '[.[] | select(.severity >= 3)] | length' <<< "$invite_issues") + +inactive_seats=0 +if [[ -f atlassian_inactive_billable_data.json ]]; then + inactive_seats=$(jq 'length' atlassian_inactive_billable_data.json) +fi +overlap_seats=0 +if [[ -f atlassian_product_overlap_data.json ]]; then + overlap_seats=$(jq '[.[].redundant[]] | length' atlassian_product_overlap_data.json) +fi +stale_invites=0 +if [[ -f atlassian_pending_invite_data.json ]]; then + stale_invites=$(jq '[.[] | select(.stale)] | length' atlassian_pending_invite_data.json) +fi + +{ + echo "# Atlassian License Reclamation Report" + echo "" + echo "**Organization:** ${ATLASSIAN_ORG_NAME} (\`${ATLASSIAN_ORG_ID}\`)" + echo "**Generated:** $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "" + echo "## Executive Summary" + echo "" + echo "| Category | Reclaimable signal | Estimated seats |" + echo "|----------|-------------------|-----------------|" + echo "| Inactive billable users (>${INACTIVE_DAYS_THRESHOLD}d) | ${inactive_count} issue(s) | ${inactive_seats} |" + echo "| Redundant product access | ${overlap_count} issue(s) | ${overlap_seats} |" + echo "| Stale pending invites (>=${PENDING_INVITE_DAYS_THRESHOLD}d) | ${invite_count} issue(s) | ${stale_invites} |" + echo "" + echo "## Prioritized Recommendations" + echo "" + echo "1. **Suspend before remove** — suspend inactive users first to stop billing while preserving group memberships for easy restore." + echo "2. **Revoke stale invites** — pending invitations consume tier capacity until revoked in Atlassian Administration." + echo "3. **Consolidate product access** — remove redundant product licenses for users active on a subset of products." + echo "4. **Teamwork Collection note** — duplicate product rows may not imply duplicate billing; confirm licensing model before bulk removal." + echo "" + echo "## Administration Paths" + echo "" + echo "- Managed accounts: https://admin.atlassian.com/o/${ATLASSIAN_ORG_ID}/users" + echo "- Suspend (directory API, operator action): POST /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}/suspend" + echo "- Remove from directory (operator action): DELETE /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}" + echo "" + echo "## Findings Detail" + echo "" + echo "### Inactive Billable Users" + if [[ -f atlassian_inactive_billable_summary.txt ]]; then + sed 's/^/ /' atlassian_inactive_billable_summary.txt + else + echo " (no data)" + fi + echo "" + echo "### Product Overlap" + if [[ -f atlassian_product_overlap_summary.txt ]]; then + sed 's/^/ /' atlassian_product_overlap_summary.txt + else + echo " (no data)" + fi + echo "" + echo "### Pending Invites" + if [[ -f atlassian_pending_invite_summary.txt ]]; then + sed 's/^/ /' atlassian_pending_invite_summary.txt + else + echo " (no data)" + fi +} > "$REPORT_FILE" + +cat "$REPORT_FILE" + +total_reclaimable=$(( inactive_seats + overlap_seats + stale_invites )) +recommendations='[]' + +if [[ "$inactive_seats" -ge "$RECLAMATION_MIN_SEATS" ]]; then + recommendations=$(echo "$recommendations" | jq \ + --argjson seats "$inactive_seats" \ + --arg threshold "$INACTIVE_DAYS_THRESHOLD" \ + '. += [{ + action: "suspend_inactive_billable", + priority: 1, + estimated_seats: $seats, + severity: 3, + title: "Suspend inactive billable users", + next_steps: ("Suspend \($seats) billable users inactive >" + $threshold + " days via Atlassian Administration > Directory.") + }]') +fi + +if [[ "$overlap_seats" -ge "$RECLAMATION_MIN_SEATS" ]]; then + recommendations=$(echo "$recommendations" | jq \ + --argjson seats "$overlap_seats" \ + '. += [{ + action: "consolidate_product_access", + priority: 2, + estimated_seats: $seats, + severity: 3, + title: "Consolidate redundant product access", + next_steps: ("Revoke redundant product licenses for users active on fewer than " + ($seats | tostring) + " assigned products.") + }]') +fi + +if [[ "$stale_invites" -ge 1 ]]; then + sev=4 + [[ "$stale_invites" -ge "$RECLAMATION_MIN_SEATS" ]] && sev=3 + recommendations=$(echo "$recommendations" | jq \ + --argjson seats "$stale_invites" \ + --argjson severity "$sev" \ + '. += [{ + action: "revoke_stale_invites", + priority: 3, + estimated_seats: $seats, + severity: $severity, + title: "Revoke stale pending invitations", + next_steps: ("Revoke \($seats) stale pending invites in Atlassian Administration > Directory > Users.") + }]') +fi + +if [[ "$(echo "$recommendations" | jq 'length')" -eq 0 && "$total_reclaimable" -eq 0 ]]; then + echo "No reclamation candidates meeting thresholds. Organization appears efficiently utilized." + echo '[]' > "$OUTPUT_FILE" + exit 0 +fi + +report_details=$(cat "$REPORT_FILE") +while IFS= read -r rec; do + title=$(echo "$rec" | jq -r '.title') + severity=$(echo "$rec" | jq -r '.severity') + next_steps=$(echo "$rec" | jq -r '.next_steps') + seats=$(echo "$rec" | jq -r '.estimated_seats') + action=$(echo "$rec" | jq -r '.action') + details="Action: ${action}\nEstimated reclaimable seats: ${seats}\n\n${report_details}" + issues_json=$(echo "$issues_json" | jq \ + --arg title "${title} for Atlassian Organization \`${ATLASSIAN_ORG_NAME}\`" \ + --arg details "$details" \ + --argjson severity "$severity" \ + --arg next_steps "$next_steps" \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') +done < <(echo "$recommendations" | jq -c '.[]') + +echo "$issues_json" > "$OUTPUT_FILE" +echo "Reclamation report saved to ${REPORT_FILE}" +echo "Issues saved to ${OUTPUT_FILE}" diff --git a/codebundles/atlassian-org-license-optimization/runbook.robot b/codebundles/atlassian-org-license-optimization/runbook.robot new file mode 100644 index 000000000..01ebac3cf --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/runbook.robot @@ -0,0 +1,273 @@ +*** Settings *** +Documentation Identifies Atlassian Cloud license waste and rightsizing opportunities across inactive billable users, overlapping product entitlements, and stale pending invites, then produces prioritized reclamation recommendations. +Metadata Author rw-codebundle-agent +Metadata Display Name Atlassian Organization License Optimization +Metadata Supports Atlassian License Optimization Organization Cost Management +Force Tags Atlassian License Optimization Organization Cost Management + +Library String +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library Collections + +Suite Setup Suite Initialization + + +*** Tasks *** +Identify Inactive Billable Users Across Atlassian Products for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Pages managed accounts and enriches with per-product last_active dates to flag billable users without recent activity within INACTIVE_DAYS_THRESHOLD. + [Tags] Atlassian Organization License Optimization Inactive Users access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=identify-atlassian-inactive-billable-users.sh + ... env=${env} + ... secret__atlassian_org_api_key=${ATLASSIAN_ORG_API_KEY} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./identify-atlassian-inactive-billable-users.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_inactive_billable_issues.json 2>/dev/null || echo "[]" + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for inactive billable users task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Billable users in organization `${ATLASSIAN_ORG_NAME}` should show product activity within `${INACTIVE_DAYS_THRESHOLD}` days + ... actual=Inactive billable users detected without recent product activity + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Inactive Billable Users Analysis: + RW.Core.Add Pre To Report ${result.stdout} + +Analyze Overlapping Product Entitlements for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Finds users licensed on multiple products who are active on only a subset, highlighting redundant seat assignments under per-product or Teamwork Collection licensing. + [Tags] Atlassian Organization License Optimization Product Overlap access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=analyze-atlassian-product-overlap.sh + ... env=${env} + ... secret__atlassian_org_api_key=${ATLASSIAN_ORG_API_KEY} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./analyze-atlassian-product-overlap.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_product_overlap_issues.json 2>/dev/null || echo "[]" + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for product overlap task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Users in organization `${ATLASSIAN_ORG_NAME}` should use all licensed products they are assigned + ... actual=Users with overlapping product entitlements are inactive on one or more licensed products + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Product Overlap Analysis: + RW.Core.Add Pre To Report ${result.stdout} + +Surface Pending Invites and Unaccepted Seats for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Detects invited-but-not-accepted users and pending product access that still count toward user tier limits, quantifying reclaimable seats from stale invites. + [Tags] Atlassian Organization License Optimization Pending Invites access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=surface-atlassian-pending-invites.sh + ... env=${env} + ... secret__atlassian_org_api_key=${ATLASSIAN_ORG_API_KEY} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./surface-atlassian-pending-invites.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_pending_invite_issues.json 2>/dev/null || echo "[]" + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for pending invites task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=Pending invitations in organization `${ATLASSIAN_ORG_NAME}` should be accepted or revoked within `${PENDING_INVITE_DAYS_THRESHOLD}` days + ... actual=Stale or outstanding pending invites are consuming license capacity + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + RW.Core.Add Pre To Report Pending Invites Analysis: + RW.Core.Add Pre To Report ${result.stdout} + +Recommend License Reclamation Actions for Organization `${ATLASSIAN_ORG_NAME}` + [Documentation] Synthesizes findings from prior tasks into prioritized suspend, remove, and consolidate recommendations with estimated seat savings and a markdown report for IT/finance handoff. + [Tags] Atlassian Organization License Optimization Recommendations access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=recommend-atlassian-license-reclamation.sh + ... env=${env} + ... secret__atlassian_org_api_key=${ATLASSIAN_ORG_API_KEY} + ... timeout_seconds=${TIMEOUT_SECONDS} + ... include_in_history=false + ... show_in_rwl_cheatsheet=true + ... cmd_override=./recommend-atlassian-license-reclamation.sh + + ${issues}= RW.CLI.Run Cli + ... cmd=cat atlassian_reclamation_issues.json 2>/dev/null || echo "[]" + ... env=${env} + ... timeout_seconds=60 + ... include_in_history=false + + TRY + ${issue_list}= Evaluate json.loads(r'''${issues.stdout}''') json + EXCEPT + Log Failed to parse JSON for reclamation recommendations task, defaulting to empty list. WARN + ${issue_list}= Create List + END + + IF len(@{issue_list}) > 0 + FOR ${issue} IN @{issue_list} + RW.Core.Add Issue + ... severity=${issue['severity']} + ... expected=License utilization in organization `${ATLASSIAN_ORG_NAME}` should minimize reclaimable seat waste before renewal + ... actual=Prioritized license reclamation opportunities identified with estimated seat savings + ... title=${issue['title']} + ... reproduce_hint=${result.cmd} + ... details=${issue['details']} + ... next_steps=${issue['next_steps']} + END + END + + ${report}= RW.CLI.Run Cli + ... cmd=cat atlassian_license_reclamation_report.md 2>/dev/null || echo "(report not generated)" + ... env=${env} + ... timeout_seconds=30 + ... include_in_history=false + + RW.Core.Add Pre To Report License Reclamation Report: + RW.Core.Add Pre To Report ${report.stdout} + RW.Core.Add Pre To Report ${result.stdout} + + +*** Keywords *** +Suite Initialization + TRY + ${api_key}= RW.Core.Import Secret + ... atlassian_org_api_key + ... type=string + ... description=Atlassian Organization Admin API key (Bearer token) + ... pattern=\w* + Set Suite Variable ${ATLASSIAN_ORG_API_KEY} ${api_key} + EXCEPT + Log atlassian_org_api_key secret not found. WARN + Set Suite Variable ${ATLASSIAN_ORG_API_KEY} ${EMPTY} + END + + ${ATLASSIAN_ORG_ID}= RW.Core.Import User Variable ATLASSIAN_ORG_ID + ... type=string + ... description=Atlassian Cloud organization UUID + ... pattern=[\w-]+ + ${ATLASSIAN_ORG_NAME}= RW.Core.Import User Variable ATLASSIAN_ORG_NAME + ... type=string + ... description=Human-readable organization name for reports and task titles + ... pattern=.* + ${ATLASSIAN_DIRECTORY_ID}= RW.Core.Import User Variable ATLASSIAN_DIRECTORY_ID + ... type=string + ... description=Primary user directory ID (auto-discover when empty) + ... pattern=[\w-]* + ... default= + ${INACTIVE_DAYS_THRESHOLD}= RW.Core.Import User Variable INACTIVE_DAYS_THRESHOLD + ... type=string + ... description=Days without product activity before flagging a billable user as inactive + ... pattern=^\d+$ + ... default=90 + ${PENDING_INVITE_DAYS_THRESHOLD}= RW.Core.Import User Variable PENDING_INVITE_DAYS_THRESHOLD + ... type=string + ... description=Days an outstanding invite may sit before it is flagged as stale + ... pattern=^\d+$ + ... default=30 + ${MIN_OVERLAP_PRODUCTS}= RW.Core.Import User Variable MIN_OVERLAP_PRODUCTS + ... type=string + ... description=Minimum licensed products before overlap analysis applies to a user + ... pattern=^\d+$ + ... default=2 + ${PRODUCTS}= RW.Core.Import User Variable PRODUCTS + ... type=string + ... description=Comma-separated product keys to analyze or All + ... pattern=.* + ... default=All + ${RECLAMATION_MIN_SEATS}= RW.Core.Import User Variable RECLAMATION_MIN_SEATS + ... type=string + ... description=Minimum reclaimable seats per product before emitting a recommendation issue + ... pattern=^\d+$ + ... default=5 + ${TIMEOUT_SECONDS}= RW.Core.Import User Variable TIMEOUT_SECONDS + ... type=string + ... description=Per-task timeout for large organizations + ... pattern=^\d+$ + ... default=900 + + Set Suite Variable ${ATLASSIAN_ORG_ID} ${ATLASSIAN_ORG_ID} + Set Suite Variable ${ATLASSIAN_ORG_NAME} ${ATLASSIAN_ORG_NAME} + Set Suite Variable ${ATLASSIAN_DIRECTORY_ID} ${ATLASSIAN_DIRECTORY_ID} + Set Suite Variable ${INACTIVE_DAYS_THRESHOLD} ${INACTIVE_DAYS_THRESHOLD} + Set Suite Variable ${PENDING_INVITE_DAYS_THRESHOLD} ${PENDING_INVITE_DAYS_THRESHOLD} + Set Suite Variable ${MIN_OVERLAP_PRODUCTS} ${MIN_OVERLAP_PRODUCTS} + Set Suite Variable ${PRODUCTS} ${PRODUCTS} + Set Suite Variable ${RECLAMATION_MIN_SEATS} ${RECLAMATION_MIN_SEATS} + Set Suite Variable ${TIMEOUT_SECONDS} ${TIMEOUT_SECONDS} + + ${env}= Create Dictionary + ... ATLASSIAN_ORG_ID=${ATLASSIAN_ORG_ID} + ... ATLASSIAN_ORG_NAME=${ATLASSIAN_ORG_NAME} + ... ATLASSIAN_DIRECTORY_ID=${ATLASSIAN_DIRECTORY_ID} + ... INACTIVE_DAYS_THRESHOLD=${INACTIVE_DAYS_THRESHOLD} + ... PENDING_INVITE_DAYS_THRESHOLD=${PENDING_INVITE_DAYS_THRESHOLD} + ... MIN_OVERLAP_PRODUCTS=${MIN_OVERLAP_PRODUCTS} + ... PRODUCTS=${PRODUCTS} + ... RECLAMATION_MIN_SEATS=${RECLAMATION_MIN_SEATS} + ... TIMEOUT_SECONDS=${TIMEOUT_SECONDS} + Set Suite Variable ${env} ${env} diff --git a/codebundles/atlassian-org-license-optimization/sli-atlassian-license-reclamation-health.sh b/codebundles/atlassian-org-license-optimization/sli-atlassian-license-reclamation-health.sh new file mode 100755 index 000000000..099e973e5 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/sli-atlassian-license-reclamation-health.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# Lightweight SLI health probe: scores 1 when no severity-3+ reclamation signals exist. +# Uses limited pagination (SLI_MAX_PAGES) to stay under 30 seconds. + +: "${ATLASSIAN_ORG_ID:?Must set ATLASSIAN_ORG_ID}" +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" +: "${INACTIVE_DAYS_THRESHOLD:=90}" +: "${PENDING_INVITE_DAYS_THRESHOLD:=30}" +: "${MIN_OVERLAP_PRODUCTS:=2}" +: "${RECLAMATION_MIN_SEATS:=5}" +: "${SLI_MAX_PAGES:=2}" + +source "$(dirname "$0")/atlassian-api-helpers.sh" + +export ATLASSIAN_MAX_PAGES="${SLI_MAX_PAGES}" +rm -f "$INVENTORY_CACHE_FILE" "$DIRECTORY_USERS_CACHE_FILE" 2>/dev/null || true + +inactive_count=0 +overlap_count=0 +stale_invites=0 +api_ok=1 + +if ensure_user_inventory; then + inactive_count=$(jq \ + --argjson threshold "$INACTIVE_DAYS_THRESHOLD" \ + 'def days_since_iso($iso): + if ($iso // "") == "" then 999999 + else (($iso | sub("\\.[0-9]+"; "") | fromdateiso8601?) as $ts | + if $ts == null then 999999 else (((now - $ts) / 86400) | floor) end) end; + [.users[] | select(.access_billable == true) | + (.product_access // []) as $pa | + if ($pa | length) == 0 then true + else all($pa[]; days_since_iso(.last_active) >= $threshold) end + ] | length' "$INVENTORY_CACHE_FILE") + + overlap_count=$(jq \ + --argjson threshold "$INACTIVE_DAYS_THRESHOLD" \ + --argjson min_products "$MIN_OVERLAP_PRODUCTS" \ + 'def days_since_iso($iso): + if ($iso // "") == "" then 999999 + else (($iso | sub("\\.[0-9]+"; "") | fromdateiso8601?) as $ts | + if $ts == null then 999999 else (((now - $ts) / 86400) | floor) end) end; + [.users[] | select(.access_billable == true) | + (.product_access // []) as $pa | select(($pa | length) >= $min_products) | + ($pa | map(days_since_iso(.last_active) < $threshold)) as $active | + ($pa | length) > ($active | map(select(.)) | length) + ] | length' "$INVENTORY_CACHE_FILE") +else + api_ok=0 +fi + +if ensure_directory_users; then + stale_invites=$(jq \ + --argjson threshold "$PENDING_INVITE_DAYS_THRESHOLD" \ + 'def days_since_iso($iso): + if ($iso // "") == "" then 999999 + else (($iso | sub("\\.[0-9]+"; "") | fromdateiso8601?) as $ts | + if $ts == null then 999999 else (((now - $ts) / 86400) | floor) end) end; + [.users[] | + select( + (.membershipStatus // "" | ascii_downcase | test("pending")) or + (.accountStatus // "" | ascii_downcase | test("pending")) + ) | + days_since_iso(.addedToOrg // .added_to_org // "") >= $threshold + ] | length' "$DIRECTORY_USERS_CACHE_FILE") +else + api_ok=0 +fi + +severity3_plus=0 +[[ "$inactive_count" -ge "$RECLAMATION_MIN_SEATS" ]] && severity3_plus=1 +[[ "$overlap_count" -ge "$RECLAMATION_MIN_SEATS" ]] && severity3_plus=1 +[[ "$stale_invites" -ge 1 ]] && severity3_plus=1 + +inactive_score=1 +overlap_score=1 +invite_score=1 +api_score=1 + +[[ "$inactive_count" -ge "$RECLAMATION_MIN_SEATS" ]] && inactive_score=0 +[[ "$overlap_count" -ge "$RECLAMATION_MIN_SEATS" ]] && overlap_score=0 +[[ "$stale_invites" -ge 1 ]] && invite_score=0 +[[ "$api_ok" -eq 0 ]] && api_score=0 + +health_score=$(awk -v a="$inactive_score" -v b="$overlap_score" -v c="$invite_score" -v d="$api_score" \ + 'BEGIN { printf "%.2f", (a+b+c+d)/4 }') + +jq -n \ + --argjson health_score "$health_score" \ + --argjson inactive_score "$inactive_score" \ + --argjson overlap_score "$overlap_score" \ + --argjson invite_score "$invite_score" \ + --argjson api_score "$api_score" \ + --argjson inactive_count "$inactive_count" \ + --argjson overlap_count "$overlap_count" \ + --argjson stale_invites "$stale_invites" \ + --argjson severity3_plus "$severity3_plus" \ + '{ + health_score: ($health_score | tonumber), + sub_scores: { + inactive_billable: $inactive_score, + product_overlap: $overlap_score, + stale_invites: $invite_score, + api_reachability: $api_score + }, + counts: { + inactive_billable: $inactive_count, + overlap_candidates: $overlap_count, + stale_invites: $stale_invites, + severity3_plus_signals: $severity3_plus + } + }' diff --git a/codebundles/atlassian-org-license-optimization/sli.robot b/codebundles/atlassian-org-license-optimization/sli.robot new file mode 100644 index 000000000..f402dea94 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/sli.robot @@ -0,0 +1,145 @@ +*** Settings *** +Documentation Measures Atlassian organization license reclamation health by scoring inactive billable users, product overlap waste, stale pending invites, and API reachability. Produces a 0-1 aggregate score where 1 means no severity-3+ reclamation signals. +Metadata Author rw-codebundle-agent +Metadata Display Name Atlassian Organization License Optimization SLI +Metadata Supports Atlassian License Optimization Organization Cost Management +Force Tags Atlassian License Optimization Organization SLI + +Library BuiltIn +Library RW.Core +Library RW.CLI +Library RW.platform +Library Collections + +Suite Setup Suite Initialization + + +*** Keywords *** +Suite Initialization + TRY + ${api_key}= RW.Core.Import Secret + ... atlassian_org_api_key + ... type=string + ... description=Atlassian Organization Admin API key (Bearer token) + ... pattern=\w* + Set Suite Variable ${ATLASSIAN_ORG_API_KEY} ${api_key} + EXCEPT + Log atlassian_org_api_key secret not found. WARN + Set Suite Variable ${ATLASSIAN_ORG_API_KEY} ${EMPTY} + END + + ${ATLASSIAN_ORG_ID}= RW.Core.Import User Variable ATLASSIAN_ORG_ID + ... type=string + ... description=Atlassian Cloud organization UUID + ... pattern=[\w-]+ + ${ATLASSIAN_ORG_NAME}= RW.Core.Import User Variable ATLASSIAN_ORG_NAME + ... type=string + ... description=Human-readable organization name + ... pattern=.* + ${ATLASSIAN_DIRECTORY_ID}= RW.Core.Import User Variable ATLASSIAN_DIRECTORY_ID + ... type=string + ... description=Primary user directory ID (auto-discover when empty) + ... pattern=[\w-]* + ... default= + ${INACTIVE_DAYS_THRESHOLD}= RW.Core.Import User Variable INACTIVE_DAYS_THRESHOLD + ... type=string + ... description=Days without product activity before flagging inactive + ... pattern=^\d+$ + ... default=90 + ${PENDING_INVITE_DAYS_THRESHOLD}= RW.Core.Import User Variable PENDING_INVITE_DAYS_THRESHOLD + ... type=string + ... description=Days before a pending invite is considered stale + ... pattern=^\d+$ + ... default=30 + ${MIN_OVERLAP_PRODUCTS}= RW.Core.Import User Variable MIN_OVERLAP_PRODUCTS + ... type=string + ... description=Minimum licensed products for overlap detection + ... pattern=^\d+$ + ... default=2 + ${RECLAMATION_MIN_SEATS}= RW.Core.Import User Variable RECLAMATION_MIN_SEATS + ... type=string + ... description=Minimum reclaimable seats before SLI sub-score fails + ... pattern=^\d+$ + ... default=5 + ${SLI_MAX_PAGES}= RW.Core.Import User Variable SLI_MAX_PAGES + ... type=string + ... description=Maximum API pages fetched per SLI run for speed + ... pattern=^\d+$ + ... default=2 + + Set Suite Variable ${ATLASSIAN_ORG_ID} ${ATLASSIAN_ORG_ID} + Set Suite Variable ${ATLASSIAN_ORG_NAME} ${ATLASSIAN_ORG_NAME} + Set Suite Variable ${ATLASSIAN_DIRECTORY_ID} ${ATLASSIAN_DIRECTORY_ID} + Set Suite Variable ${INACTIVE_DAYS_THRESHOLD} ${INACTIVE_DAYS_THRESHOLD} + Set Suite Variable ${PENDING_INVITE_DAYS_THRESHOLD} ${PENDING_INVITE_DAYS_THRESHOLD} + Set Suite Variable ${MIN_OVERLAP_PRODUCTS} ${MIN_OVERLAP_PRODUCTS} + Set Suite Variable ${RECLAMATION_MIN_SEATS} ${RECLAMATION_MIN_SEATS} + Set Suite Variable ${SLI_MAX_PAGES} ${SLI_MAX_PAGES} + + ${env}= Create Dictionary + ... ATLASSIAN_ORG_ID=${ATLASSIAN_ORG_ID} + ... ATLASSIAN_ORG_NAME=${ATLASSIAN_ORG_NAME} + ... ATLASSIAN_DIRECTORY_ID=${ATLASSIAN_DIRECTORY_ID} + ... INACTIVE_DAYS_THRESHOLD=${INACTIVE_DAYS_THRESHOLD} + ... PENDING_INVITE_DAYS_THRESHOLD=${PENDING_INVITE_DAYS_THRESHOLD} + ... MIN_OVERLAP_PRODUCTS=${MIN_OVERLAP_PRODUCTS} + ... RECLAMATION_MIN_SEATS=${RECLAMATION_MIN_SEATS} + ... SLI_MAX_PAGES=${SLI_MAX_PAGES} + Set Suite Variable ${env} ${env} + + Set Suite Variable ${score_inactive} 1 + Set Suite Variable ${score_overlap} 1 + Set Suite Variable ${score_invites} 1 + Set Suite Variable ${score_api} 1 + + +*** Tasks *** +Check Inactive Billable Users and Score + [Documentation] Binary score: 1 when inactive billable user count is below RECLAMATION_MIN_SEATS, 0 otherwise. + [Tags] Atlassian sli access:read-only data:config + + ${result}= RW.CLI.Run Bash File + ... bash_file=sli-atlassian-license-reclamation-health.sh + ... env=${env} + ... secret__atlassian_org_api_key=${ATLASSIAN_ORG_API_KEY} + ... timeout_seconds=30 + ... include_in_history=false + ... cmd_override=./sli-atlassian-license-reclamation-health.sh + + TRY + ${data}= Evaluate json.loads(r'''${result.stdout}''') json + ${score_inactive}= Evaluate int(${data}['sub_scores']['inactive_billable']) + ${score_overlap}= Evaluate int(${data}['sub_scores']['product_overlap']) + ${score_invites}= Evaluate int(${data}['sub_scores']['stale_invites']) + ${score_api}= Evaluate int(${data}['sub_scores']['api_reachability']) + ${health_score}= Evaluate float(${data}['health_score']) + Set Suite Variable ${score_inactive} + Set Suite Variable ${score_overlap} + Set Suite Variable ${score_invites} + Set Suite Variable ${score_api} + Set Suite Variable ${health_score} + Set Suite Variable ${sli_counts} ${data['counts']} + EXCEPT + Log SLI health probe parse failed; defaulting sub-scores to 0. WARN + Set Suite Variable ${score_inactive} 0 + Set Suite Variable ${score_overlap} 0 + Set Suite Variable ${score_invites} 0 + Set Suite Variable ${score_api} 0 + Set Suite Variable ${health_score} 0 + ${empty}= Create Dictionary + Set Suite Variable ${sli_counts} ${empty} + END + + RW.Core.Push Metric ${score_inactive} sub_name=inactive_billable + RW.Core.Push Metric ${score_overlap} sub_name=product_overlap + RW.Core.Push Metric ${score_invites} sub_name=stale_invites + RW.Core.Push Metric ${score_api} sub_name=api_reachability + +Generate Aggregate License Reclamation Health Score + [Documentation] Averages sub-scores into the final 0-1 SLI metric. Healthy when no severity-3+ reclamation signals are present. + [Tags] Atlassian sli access:read-only data:config + + ${aggregate}= Evaluate (int(${score_inactive}) + int(${score_overlap}) + int(${score_invites}) + int(${score_api})) / 4.0 + ${aggregate}= Convert To Number ${aggregate} 2 + RW.Core.Add To Report Atlassian license reclamation health: ${aggregate} (inactive=${score_inactive}, overlap=${score_overlap}, invites=${score_invites}, api=${score_api}) + RW.Core.Push Metric ${aggregate} diff --git a/codebundles/atlassian-org-license-optimization/surface-atlassian-pending-invites.sh b/codebundles/atlassian-org-license-optimization/surface-atlassian-pending-invites.sh new file mode 100755 index 000000000..7c575494a --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/surface-atlassian-pending-invites.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +set -euo pipefail +set -x +# ----------------------------------------------------------------------------- +# REQUIRED ENV VARS: +# ATLASSIAN_ORG_ID +# ATLASSIAN_ORG_NAME +# +# OPTIONAL: +# PENDING_INVITE_DAYS_THRESHOLD (default 30) +# ATLASSIAN_DIRECTORY_ID (auto-discover if empty) +# ----------------------------------------------------------------------------- + +: "${ATLASSIAN_ORG_ID:?Must set ATLASSIAN_ORG_ID}" +: "${ATLASSIAN_ORG_NAME:?Must set ATLASSIAN_ORG_NAME}" +: "${PENDING_INVITE_DAYS_THRESHOLD:=30}" + +OUTPUT_FILE="atlassian_pending_invite_issues.json" +SUMMARY_FILE="atlassian_pending_invite_summary.txt" +issues_json='[]' + +source "$(dirname "$0")/atlassian-api-helpers.sh" + +echo "Surfacing pending invites for organization: ${ATLASSIAN_ORG_NAME}" +echo "Stale invite threshold: ${PENDING_INVITE_DAYS_THRESHOLD} days" + +if ! ensure_directory_users; then + issues_json=$(append_api_access_issue "$issues_json" "Failed to fetch directory users from GET /v2/orgs/{orgId}/directories/{directoryId}/users.") + echo "$issues_json" > "$OUTPUT_FILE" + exit 0 +fi + +pending_report=$(jq \ + --argjson threshold "$PENDING_INVITE_DAYS_THRESHOLD" \ + 'def days_since_iso($iso): + if ($iso // "") == "" then 999999 + else (($iso | sub("\\.[0-9]+"; "") | fromdateiso8601?) as $ts | + if $ts == null then 999999 else (((now - $ts) / 86400) | floor) end) end; + [.users[] | + select( + (.membershipStatus // "" | ascii_downcase | test("pending")) or + (.accountStatus // "" | ascii_downcase | test("pending")) or + (.status // "" | ascii_downcase | test("pending")) + ) | + { + account_id: (.accountId // .account_id), + email: (.email // ""), + name: (.name // .nickname // ""), + membership_status: (.membershipStatus // .status // "pending"), + added_to_org: (.addedToOrg // .added_to_org // ""), + days_pending: days_since_iso(.addedToOrg // .added_to_org // ""), + stale: (days_since_iso(.addedToOrg // .added_to_org // "") >= $threshold) + } + ]' "$DIRECTORY_USERS_CACHE_FILE") + +pending_total=$(echo "$pending_report" | jq 'length') +stale_total=$(echo "$pending_report" | jq '[.[] | select(.stale)] | length') + +{ + echo "Pending Invites Analysis — ${ATLASSIAN_ORG_NAME}" + echo "===============================================" + echo "Pending / unaccepted users: ${pending_total}" + echo "Stale invites (>=${PENDING_INVITE_DAYS_THRESHOLD} days): ${stale_total}" + echo "" + echo "$pending_report" | jq -r '.[] | "- \(.email // .name) status=\(.membership_status) days_pending=\(.days_pending) stale=\(.stale)"' +} > "$SUMMARY_FILE" +cat "$SUMMARY_FILE" + +if [[ "$stale_total" -ge 5 ]]; then + severity=3 +elif [[ "$stale_total" -ge 1 ]]; then + severity=4 +elif [[ "$pending_total" -ge 1 ]]; then + severity=3 +else + severity=0 +fi + +# Adjust: spec says expected severities [3,4] for invites task +if [[ "$stale_total" -ge 1 ]]; then + if [[ "$stale_total" -ge 8 ]]; then + severity=3 + else + severity=4 + fi +fi + +if [[ "$severity" -ge 3 ]]; then + details=$(cat "$SUMMARY_FILE") + issues_json=$(echo "$issues_json" | jq \ + --arg title "Stale Pending Invites in Atlassian Organization \`${ATLASSIAN_ORG_NAME}\`" \ + --arg details "$details" \ + --argjson severity "$severity" \ + --arg next_steps "Revoke stale invitations in Atlassian Administration > Directory > Users. Path: admin.atlassian.com/o/${ATLASSIAN_ORG_ID}/users. Pending invites consume seats until accepted or revoked." \ + '. += [{ + "title": $title, + "details": $details, + "severity": $severity, + "next_steps": $next_steps + }]') +elif [[ "$pending_total" -ge 1 ]]; then + details=$(cat "$SUMMARY_FILE") + issues_json=$(echo "$issues_json" | jq \ + --arg title "Pending Invites Consuming Seats in Atlassian Organization \`${ATLASSIAN_ORG_NAME}\`" \ + --arg details "$details" \ + --argjson severity "3" \ + --arg next_steps "Review outstanding invitations and revoke those no longer needed." \ + '. += [{ + "title": $title, + "details": $details, + "severity": ($severity | tonumber), + "next_steps": $next_steps + }]') +fi + +echo "$pending_report" | jq '.' > atlassian_pending_invite_data.json +echo "$issues_json" > "$OUTPUT_FILE" +echo "Analysis completed. Results saved to ${OUTPUT_FILE}" From 6447d7c3d1287e7f37da4a6f4d6cac0c2d7edf5d Mon Sep 17 00:00:00 2001 From: "rw-codebundle-agent[bot]" Date: Wed, 10 Jun 2026 13:38:29 +0000 Subject: [PATCH 2/2] Exclude generated analysis artifacts from the bundle Add .gitignore for runtime JSON and report outputs produced during runbook execution and mock testing. Co-authored-by: Cursor --- .../.gitignore | 7 ++ .../atlassian_directory_users.json | 72 --------------- .../atlassian_inactive_billable_data.json | 1 - .../atlassian_inactive_billable_issues.json | 1 - .../atlassian_inactive_billable_summary.txt | 8 -- .../atlassian_license_reclamation_report.md | 66 -------------- .../atlassian_pending_invite_data.json | 74 ---------------- .../atlassian_pending_invite_issues.json | 8 -- .../atlassian_pending_invite_summary.txt | 13 --- .../atlassian_product_overlap_data.json | 87 ------------------- .../atlassian_product_overlap_issues.json | 8 -- .../atlassian_product_overlap_summary.txt | 12 --- .../atlassian_reclamation_issues.json | 14 --- .../atlassian_user_inventory.json | 68 --------------- 14 files changed, 7 insertions(+), 432 deletions(-) create mode 100644 codebundles/atlassian-org-license-optimization/.gitignore delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_directory_users.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json delete mode 100644 codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json diff --git a/codebundles/atlassian-org-license-optimization/.gitignore b/codebundles/atlassian-org-license-optimization/.gitignore new file mode 100644 index 000000000..c8ff5ca00 --- /dev/null +++ b/codebundles/atlassian-org-license-optimization/.gitignore @@ -0,0 +1,7 @@ +atlassian_user_inventory.json +atlassian_directory_users.json +atlassian_inactive_billable_* +atlassian_product_overlap_* +atlassian_pending_invite_* +atlassian_reclamation_issues.json +atlassian_license_reclamation_report.md diff --git a/codebundles/atlassian-org-license-optimization/atlassian_directory_users.json b/codebundles/atlassian-org-license-optimization/atlassian_directory_users.json deleted file mode 100644 index e929f126f..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_directory_users.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "org_id": "test-org-003", - "directory_id": "dir-003", - "fetched_at": "2026-06-10T00:00:00Z", - "partial": false, - "users": [ - { - "accountId": "pending-1", - "email": "pending1@gamma.example", - "name": "Pending User 1", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-12-01T00:00:00.000Z" - }, - { - "accountId": "pending-2", - "email": "pending2@gamma.example", - "name": "Pending User 2", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-11-15T00:00:00.000Z" - }, - { - "accountId": "pending-3", - "email": "pending3@gamma.example", - "name": "Pending User 3", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-10-01T00:00:00.000Z" - }, - { - "accountId": "pending-4", - "email": "pending4@gamma.example", - "name": "Pending User 4", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-09-01T00:00:00.000Z" - }, - { - "accountId": "pending-5", - "email": "pending5@gamma.example", - "name": "Pending User 5", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-08-01T00:00:00.000Z" - }, - { - "accountId": "pending-6", - "email": "pending6@gamma.example", - "name": "Pending User 6", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-07-01T00:00:00.000Z" - }, - { - "accountId": "pending-7", - "email": "pending7@gamma.example", - "name": "Pending User 7", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-06-01T00:00:00.000Z" - }, - { - "accountId": "pending-8", - "email": "pending8@gamma.example", - "name": "Pending User 8", - "membershipStatus": "pending", - "accountStatus": "pending", - "addedToOrg": "2025-05-01T00:00:00.000Z" - } - ] -} diff --git a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json deleted file mode 100644 index fe51488c7..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_data.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json deleted file mode 100644 index fe51488c7..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_issues.json +++ /dev/null @@ -1 +0,0 @@ -[] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt b/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt deleted file mode 100644 index a9170464b..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_inactive_billable_summary.txt +++ /dev/null @@ -1,8 +0,0 @@ -Inactive Billable Users Analysis — Test Org -============================================================ -Billable users scanned: 5 -Inactive billable users (>90d): 0 - -By product: - -Sample inactive users (up to 10): diff --git a/codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md b/codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md deleted file mode 100644 index cb92d8d80..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_license_reclamation_report.md +++ /dev/null @@ -1,66 +0,0 @@ -# Atlassian License Reclamation Report - -**Organization:** Test Org (`test-org`) -**Generated:** 2026-06-10T13:38:05Z - -## Executive Summary - -| Category | Reclaimable signal | Estimated seats | -|----------|-------------------|-----------------| -| Inactive billable users (>90d) | 0 issue(s) | 0 | -| Redundant product access | 1 issue(s) | 10 | -| Stale pending invites (>=30d) | 1 issue(s) | 8 | - -## Prioritized Recommendations - -1. **Suspend before remove** — suspend inactive users first to stop billing while preserving group memberships for easy restore. -2. **Revoke stale invites** — pending invitations consume tier capacity until revoked in Atlassian Administration. -3. **Consolidate product access** — remove redundant product licenses for users active on a subset of products. -4. **Teamwork Collection note** — duplicate product rows may not imply duplicate billing; confirm licensing model before bulk removal. - -## Administration Paths - -- Managed accounts: https://admin.atlassian.com/o/test-org/users -- Suspend (directory API, operator action): POST /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}/suspend -- Remove from directory (operator action): DELETE /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId} - -## Findings Detail - -### Inactive Billable Users - Inactive Billable Users Analysis — Test Org - ============================================================ - Billable users scanned: 5 - Inactive billable users (>90d): 0 - - By product: - - Sample inactive users (up to 10): - -### Product Overlap - Product Overlap Analysis — Test Org - ================================================= - Users with 2+ licensed products but inactive on some: 5 - - Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing. - Consolidate access when users are active on only a subset of assigned products. - - - Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom - - Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom - - Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom - - Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom - - Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom - -### Pending Invites - Pending Invites Analysis — Test Org - =============================================== - Pending / unaccepted users: 8 - Stale invites (>=30 days): 8 - - - pending1@gamma.example status=pending days_pending=191 stale=true - - pending2@gamma.example status=pending days_pending=207 stale=true - - pending3@gamma.example status=pending days_pending=252 stale=true - - pending4@gamma.example status=pending days_pending=282 stale=true - - pending5@gamma.example status=pending days_pending=313 stale=true - - pending6@gamma.example status=pending days_pending=344 stale=true - - pending7@gamma.example status=pending days_pending=374 stale=true - - pending8@gamma.example status=pending days_pending=405 stale=true diff --git a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json deleted file mode 100644 index 8e87d36e8..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_data.json +++ /dev/null @@ -1,74 +0,0 @@ -[ - { - "account_id": "pending-1", - "email": "pending1@gamma.example", - "name": "Pending User 1", - "membership_status": "pending", - "added_to_org": "2025-12-01T00:00:00.000Z", - "days_pending": 191, - "stale": true - }, - { - "account_id": "pending-2", - "email": "pending2@gamma.example", - "name": "Pending User 2", - "membership_status": "pending", - "added_to_org": "2025-11-15T00:00:00.000Z", - "days_pending": 207, - "stale": true - }, - { - "account_id": "pending-3", - "email": "pending3@gamma.example", - "name": "Pending User 3", - "membership_status": "pending", - "added_to_org": "2025-10-01T00:00:00.000Z", - "days_pending": 252, - "stale": true - }, - { - "account_id": "pending-4", - "email": "pending4@gamma.example", - "name": "Pending User 4", - "membership_status": "pending", - "added_to_org": "2025-09-01T00:00:00.000Z", - "days_pending": 282, - "stale": true - }, - { - "account_id": "pending-5", - "email": "pending5@gamma.example", - "name": "Pending User 5", - "membership_status": "pending", - "added_to_org": "2025-08-01T00:00:00.000Z", - "days_pending": 313, - "stale": true - }, - { - "account_id": "pending-6", - "email": "pending6@gamma.example", - "name": "Pending User 6", - "membership_status": "pending", - "added_to_org": "2025-07-01T00:00:00.000Z", - "days_pending": 344, - "stale": true - }, - { - "account_id": "pending-7", - "email": "pending7@gamma.example", - "name": "Pending User 7", - "membership_status": "pending", - "added_to_org": "2025-06-01T00:00:00.000Z", - "days_pending": 374, - "stale": true - }, - { - "account_id": "pending-8", - "email": "pending8@gamma.example", - "name": "Pending User 8", - "membership_status": "pending", - "added_to_org": "2025-05-01T00:00:00.000Z", - "days_pending": 405, - "stale": true - } -] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json deleted file mode 100644 index be2333931..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_issues.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "title": "Stale Pending Invites in Atlassian Organization `Test Org`", - "details": "Pending Invites Analysis — Test Org\n===============================================\nPending / unaccepted users: 8\nStale invites (>=30 days): 8\n\n- pending1@gamma.example status=pending days_pending=191 stale=true\n- pending2@gamma.example status=pending days_pending=207 stale=true\n- pending3@gamma.example status=pending days_pending=252 stale=true\n- pending4@gamma.example status=pending days_pending=282 stale=true\n- pending5@gamma.example status=pending days_pending=313 stale=true\n- pending6@gamma.example status=pending days_pending=344 stale=true\n- pending7@gamma.example status=pending days_pending=374 stale=true\n- pending8@gamma.example status=pending days_pending=405 stale=true", - "severity": 3, - "next_steps": "Revoke stale invitations in Atlassian Administration > Directory > Users. Path: admin.atlassian.com/o/test-org/users. Pending invites consume seats until accepted or revoked." - } -] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt b/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt deleted file mode 100644 index ca30786a9..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_pending_invite_summary.txt +++ /dev/null @@ -1,13 +0,0 @@ -Pending Invites Analysis — Test Org -=============================================== -Pending / unaccepted users: 8 -Stale invites (>=30 days): 8 - -- pending1@gamma.example status=pending days_pending=191 stale=true -- pending2@gamma.example status=pending days_pending=207 stale=true -- pending3@gamma.example status=pending days_pending=252 stale=true -- pending4@gamma.example status=pending days_pending=282 stale=true -- pending5@gamma.example status=pending days_pending=313 stale=true -- pending6@gamma.example status=pending days_pending=344 stale=true -- pending7@gamma.example status=pending days_pending=374 stale=true -- pending8@gamma.example status=pending days_pending=405 stale=true diff --git a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json deleted file mode 100644 index b6e38ef7d..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_data.json +++ /dev/null @@ -1,87 +0,0 @@ -[ - { - "account_id": "overlap-1", - "name": "Overlap User 1", - "email": "overlap1@gamma.example", - "licensed": [ - "jira-software", - "confluence", - "loom" - ], - "active_on": [ - "jira-software" - ], - "redundant": [ - "confluence", - "loom" - ] - }, - { - "account_id": "overlap-2", - "name": "Overlap User 2", - "email": "overlap2@gamma.example", - "licensed": [ - "jira-software", - "confluence", - "loom" - ], - "active_on": [ - "jira-software" - ], - "redundant": [ - "confluence", - "loom" - ] - }, - { - "account_id": "overlap-3", - "name": "Overlap User 3", - "email": "overlap3@gamma.example", - "licensed": [ - "jira-software", - "confluence", - "loom" - ], - "active_on": [ - "jira-software" - ], - "redundant": [ - "confluence", - "loom" - ] - }, - { - "account_id": "overlap-4", - "name": "Overlap User 4", - "email": "overlap4@gamma.example", - "licensed": [ - "jira-software", - "confluence", - "loom" - ], - "active_on": [ - "jira-software" - ], - "redundant": [ - "confluence", - "loom" - ] - }, - { - "account_id": "overlap-5", - "name": "Overlap User 5", - "email": "overlap5@gamma.example", - "licensed": [ - "jira-software", - "confluence", - "loom" - ], - "active_on": [ - "jira-software" - ], - "redundant": [ - "confluence", - "loom" - ] - } -] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json deleted file mode 100644 index b6793c3de..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_issues.json +++ /dev/null @@ -1,8 +0,0 @@ -[ - { - "title": "Overlapping Product Entitlements in Atlassian Organization `Test Org`", - "details": "Product Overlap Analysis — Test Org\n=================================================\nUsers with 2+ licensed products but inactive on some: 5\n\nNote: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing.\n Consolidate access when users are active on only a subset of assigned products.\n\n- Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n- Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom", - "severity": 3, - "next_steps": "In Atlassian Administration > Directory, review users with multiple product assignments. Revoke redundant product access for users active on fewer products. For Teamwork Collection, confirm whether seats are counted per unique user before removing access." - } -] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt b/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt deleted file mode 100644 index 2c0356420..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_product_overlap_summary.txt +++ /dev/null @@ -1,12 +0,0 @@ -Product Overlap Analysis — Test Org -================================================= -Users with 2+ licensed products but inactive on some: 5 - -Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing. - Consolidate access when users are active on only a subset of assigned products. - -- Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom -- Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom -- Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom -- Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom -- Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom diff --git a/codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json b/codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json deleted file mode 100644 index 70092c78c..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_reclamation_issues.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "title": "Consolidate redundant product access for Atlassian Organization `Test Org`", - "details": "Action: consolidate_product_access\\nEstimated reclaimable seats: 10\\n\\n# Atlassian License Reclamation Report\n\n**Organization:** Test Org (`test-org`)\n**Generated:** 2026-06-10T13:38:05Z\n\n## Executive Summary\n\n| Category | Reclaimable signal | Estimated seats |\n|----------|-------------------|-----------------|\n| Inactive billable users (>90d) | 0 issue(s) | 0 |\n| Redundant product access | 1 issue(s) | 10 |\n| Stale pending invites (>=30d) | 1 issue(s) | 8 |\n\n## Prioritized Recommendations\n\n1. **Suspend before remove** — suspend inactive users first to stop billing while preserving group memberships for easy restore.\n2. **Revoke stale invites** — pending invitations consume tier capacity until revoked in Atlassian Administration.\n3. **Consolidate product access** — remove redundant product licenses for users active on a subset of products.\n4. **Teamwork Collection note** — duplicate product rows may not imply duplicate billing; confirm licensing model before bulk removal.\n\n## Administration Paths\n\n- Managed accounts: https://admin.atlassian.com/o/test-org/users\n- Suspend (directory API, operator action): POST /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}/suspend\n- Remove from directory (operator action): DELETE /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}\n\n## Findings Detail\n\n### Inactive Billable Users\n Inactive Billable Users Analysis — Test Org\n ============================================================\n Billable users scanned: 5\n Inactive billable users (>90d): 0\n \n By product:\n \n Sample inactive users (up to 10):\n\n### Product Overlap\n Product Overlap Analysis — Test Org\n =================================================\n Users with 2+ licensed products but inactive on some: 5\n \n Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing.\n Consolidate access when users are active on only a subset of assigned products.\n \n - Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n\n### Pending Invites\n Pending Invites Analysis — Test Org\n ===============================================\n Pending / unaccepted users: 8\n Stale invites (>=30 days): 8\n \n - pending1@gamma.example status=pending days_pending=191 stale=true\n - pending2@gamma.example status=pending days_pending=207 stale=true\n - pending3@gamma.example status=pending days_pending=252 stale=true\n - pending4@gamma.example status=pending days_pending=282 stale=true\n - pending5@gamma.example status=pending days_pending=313 stale=true\n - pending6@gamma.example status=pending days_pending=344 stale=true\n - pending7@gamma.example status=pending days_pending=374 stale=true\n - pending8@gamma.example status=pending days_pending=405 stale=true", - "severity": 3, - "next_steps": "Revoke redundant product licenses for users active on fewer than 10 assigned products." - }, - { - "title": "Revoke stale pending invitations for Atlassian Organization `Test Org`", - "details": "Action: revoke_stale_invites\\nEstimated reclaimable seats: 8\\n\\n# Atlassian License Reclamation Report\n\n**Organization:** Test Org (`test-org`)\n**Generated:** 2026-06-10T13:38:05Z\n\n## Executive Summary\n\n| Category | Reclaimable signal | Estimated seats |\n|----------|-------------------|-----------------|\n| Inactive billable users (>90d) | 0 issue(s) | 0 |\n| Redundant product access | 1 issue(s) | 10 |\n| Stale pending invites (>=30d) | 1 issue(s) | 8 |\n\n## Prioritized Recommendations\n\n1. **Suspend before remove** — suspend inactive users first to stop billing while preserving group memberships for easy restore.\n2. **Revoke stale invites** — pending invitations consume tier capacity until revoked in Atlassian Administration.\n3. **Consolidate product access** — remove redundant product licenses for users active on a subset of products.\n4. **Teamwork Collection note** — duplicate product rows may not imply duplicate billing; confirm licensing model before bulk removal.\n\n## Administration Paths\n\n- Managed accounts: https://admin.atlassian.com/o/test-org/users\n- Suspend (directory API, operator action): POST /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}/suspend\n- Remove from directory (operator action): DELETE /v2/orgs/{orgId}/directories/{directoryId}/users/{accountId}\n\n## Findings Detail\n\n### Inactive Billable Users\n Inactive Billable Users Analysis — Test Org\n ============================================================\n Billable users scanned: 5\n Inactive billable users (>90d): 0\n \n By product:\n \n Sample inactive users (up to 10):\n\n### Product Overlap\n Product Overlap Analysis — Test Org\n =================================================\n Users with 2+ licensed products but inactive on some: 5\n \n Note: Under Teamwork Collection licensing, duplicate product rows may not imply duplicate billing.\n Consolidate access when users are active on only a subset of assigned products.\n \n - Overlap User 1 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 2 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 3 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 4 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n - Overlap User 5 : licensed=jira-software,confluence,loom active_on=jira-software redundant=confluence,loom\n\n### Pending Invites\n Pending Invites Analysis — Test Org\n ===============================================\n Pending / unaccepted users: 8\n Stale invites (>=30 days): 8\n \n - pending1@gamma.example status=pending days_pending=191 stale=true\n - pending2@gamma.example status=pending days_pending=207 stale=true\n - pending3@gamma.example status=pending days_pending=252 stale=true\n - pending4@gamma.example status=pending days_pending=282 stale=true\n - pending5@gamma.example status=pending days_pending=313 stale=true\n - pending6@gamma.example status=pending days_pending=344 stale=true\n - pending7@gamma.example status=pending days_pending=374 stale=true\n - pending8@gamma.example status=pending days_pending=405 stale=true", - "severity": 3, - "next_steps": "Revoke 8 stale pending invites in Atlassian Administration > Directory > Users." - } -] diff --git a/codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json b/codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json deleted file mode 100644 index ea17d0398..000000000 --- a/codebundles/atlassian-org-license-optimization/atlassian_user_inventory.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "org_id": "test-org-003", - "org_name": "Gamma Org", - "fetched_at": "2026-06-10T00:00:00Z", - "partial": false, - "users": [ - { - "account_id": "overlap-1", - "name": "Overlap User 1", - "email": "overlap1@gamma.example", - "access_billable": true, - "department": "Engineering", - "product_access": [ - {"key": "jira-software", "name": "Jira", "last_active": "2026-06-09T00:00:00.000Z"}, - {"key": "confluence", "name": "Confluence", "last_active": "2025-01-01T00:00:00.000Z"}, - {"key": "loom", "name": "Loom", "last_active": "2025-01-01T00:00:00.000Z"} - ] - }, - { - "account_id": "overlap-2", - "name": "Overlap User 2", - "email": "overlap2@gamma.example", - "access_billable": true, - "department": "Engineering", - "product_access": [ - {"key": "jira-software", "name": "Jira", "last_active": "2026-06-08T00:00:00.000Z"}, - {"key": "confluence", "name": "Confluence", "last_active": "2025-02-01T00:00:00.000Z"}, - {"key": "loom", "name": "Loom", "last_active": "2025-03-01T00:00:00.000Z"} - ] - }, - { - "account_id": "overlap-3", - "name": "Overlap User 3", - "email": "overlap3@gamma.example", - "access_billable": true, - "department": "Product", - "product_access": [ - {"key": "jira-software", "name": "Jira", "last_active": "2026-06-07T00:00:00.000Z"}, - {"key": "confluence", "name": "Confluence", "last_active": "2025-04-01T00:00:00.000Z"}, - {"key": "loom", "name": "Loom", "last_active": "2025-05-01T00:00:00.000Z"} - ] - }, - { - "account_id": "overlap-4", - "name": "Overlap User 4", - "email": "overlap4@gamma.example", - "access_billable": true, - "department": "Product", - "product_access": [ - {"key": "jira-software", "name": "Jira", "last_active": "2026-06-06T00:00:00.000Z"}, - {"key": "confluence", "name": "Confluence", "last_active": "2025-06-01T00:00:00.000Z"}, - {"key": "loom", "name": "Loom", "last_active": "2025-07-01T00:00:00.000Z"} - ] - }, - { - "account_id": "overlap-5", - "name": "Overlap User 5", - "email": "overlap5@gamma.example", - "access_billable": true, - "department": "Design", - "product_access": [ - {"key": "jira-software", "name": "Jira", "last_active": "2026-06-05T00:00:00.000Z"}, - {"key": "confluence", "name": "Confluence", "last_active": "2025-08-01T00:00:00.000Z"}, - {"key": "loom", "name": "Loom", "last_active": "2025-09-01T00:00:00.000Z"} - ] - } - ] -}