diff --git a/.claude/skills/jira-propose/SKILL.md b/.claude/skills/jira-propose/SKILL.md new file mode 100644 index 000000000..f70c3c388 --- /dev/null +++ b/.claude/skills/jira-propose/SKILL.md @@ -0,0 +1,138 @@ +# /jira-propose — Propose JIRA ticket changes locally + +Create or update a `.jira/*.yml` file to represent a JIRA ticket change. This skill operates **entirely locally** — no JIRA API calls are made. The resulting file is committed as part of a PR and applied to JIRA automatically when the PR merges. + +## When to invoke + +Invoke `/jira-propose` when: +- A feature, bug fix, or improvement is being built and should be tracked in JIRA +- An existing JIRA ticket needs updates (status, components, description, etc.) +- The user explicitly asks to propose a JIRA ticket for the current work + +## Steps + +### 1. Gather context + +Run these commands in parallel to understand the current work: + +```bash +git diff --stat HEAD +git log --oneline -10 +git branch --show-current +ls .jira/*.yml 2>/dev/null || echo "(no existing jira files)" +``` + +Also read any `.jira/*.yml` files that exist, to check for matches. + +### 2. Determine issue type + +Default to **story** (`id: "7"`). Use **bug** (`id: "1"`) when: +- Branch name contains `fix`, `bug`, `hotfix`, or `patch` +- Commit messages contain `fix:`, `bug:`, `fixes`, `closes`, or `regression` +- The diff clearly addresses a defect rather than adding capability + +### 3. Search for a matching existing ticket + +Check each `.jira/*.yml` file: +- Compare the filename slug against the branch name and commit messages +- Compare `details.summary` (if present) against the likely ticket title +- If a file matches the current work, **update it** (append to `updates` array) +- If no file matches, **create a new one** + +### 4a. New ticket — write `PDCL--.yml` + +Generate a short random `globalId` (8 hex characters, e.g. `"a3f8b2c1"`) and a kebab-case short description (3–6 words) from the branch name and commits. + +Write `.jira/PDCL--.yml` with this structure: + +```yaml +updates: + - path: /rest/api/2/issue + method: POST + body: + fields: + project: + key: PDCL + issuetype: + id: "7" # Story (use "1" for Bug, "14801" for Documentation) + summary: + description: | + + components: + - id: "155901" # AEP Web SDK + customfield_23300: # AEP Web SDK product field + id: "116005" + labels: + - # idempotency key — do not remove +``` + +**Filename and globalId:** +- The `` in the filename is the idempotency key for this ticket +- It must be unique per ticket file so multiple new tickets can be created in a single PR +- Generate a random 8-character hex string (e.g. `a3f8b2c1`) +- The same value goes in `labels` so `apply.js` can find the ticket on re-runs +- The apply script auto-creates a remote link from the ticket to the PR — no remotelink entry needed in `updates` +- After merge, the build replaces the file with `PDCL--.yml` + +**Description guidelines:** +- Lead with who benefits (e.g., "Analytics customers using the Web SDK...") +- Explain the problem being solved +- State the business value / outcome (e.g., "This eliminates the need for manual re-configuration after...") +- Keep to 2–4 sentences + +### 4b. Existing ticket — update `updates` array + +When a matching file is found, append (or replace if same operation exists) to the `updates` array: + +```yaml +updates: + - path: /rest/api/2/issue/{key} + method: PUT + body: + update: + summary: + - set: + # Add other fields as needed; always use "set", never "add"/"remove" +``` + +Use `PUT /rest/api/2/issue/{key}` with `update.field[].set` for field changes. +Use `POST /rest/api/2/issue/{key}/transitions` for status transitions. +The `{key}` placeholder is replaced with the real ticket key by `apply.js` at run time. + +### 5. Write the file + +- Place it in `.jira/` at the repo root +- Use YAML comments (`#`) to document custom field IDs inline +- Verify the YAML is valid before finishing + +### 6. Report back + +After writing the file, tell the user: +- The filename created or updated +- The ticket identifier (globalId for new tickets, PDCL-NNNN for existing) +- The proposed summary +- What will happen when the PR merges (apply.js will execute the updates, then replace the file with a real-key filename) + +## Reference: PDCL custom fields + +| Field | ID / value | +|-------|-----------| +| Project key | `PDCL` | +| AEP Web SDK component | `components[].id: "155901"` | +| Documentation component | `components[].id: "157512"` | +| Product field (`customfield_23300`) | `id: "116005"` | +| Story issue type | `issuetype.id: "7"` | +| Bug issue type | `issuetype.id: "1"` | +| Documentation issue type | `issuetype.id: "14801"` | +| JIRA base URL | `https://jira.corp.adobe.com` | + +## Constraints + +- **No JIRA API calls** — all information comes from git state, context, and existing `.jira/` files +- All `update` operations must use `set` (never `add`/`remove`) to ensure idempotency +- New tickets must have a `POST /rest/api/2/issue` as the first entry in `updates`, with the `globalId` in `labels` +- Do **not** add a remotelink entry to `updates` — the apply script creates it automatically +- File must be valid YAML +- Do not populate the `details` section — that is written by `fetch.js` after apply diff --git a/.github/workflows/version-and-publish.yml b/.github/workflows/version-and-publish.yml index 7d0640400..21ba96611 100644 --- a/.github/workflows/version-and-publish.yml +++ b/.github/workflows/version-and-publish.yml @@ -10,6 +10,95 @@ concurrency: cancel-in-progress: false jobs: + # Applies any pending .jira/*.yml changes to JIRA on every push to main. + apply-jira: + runs-on: ubuntu-latest + environment: Production + permissions: + contents: write + pull-requests: write + env: + JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 2 + ssh-key: ${{ secrets.ALLOY_BOT_GITHUB_SSH_PRIVATE_KEY }} + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + + - name: Detect changed .jira files + id: jira-files + run: | + changed=$(git diff HEAD^1 HEAD --name-only -- '.jira/*.yml' | tr '\n' ' ') + echo "files=$changed" >> "$GITHUB_OUTPUT" + if [ -n "$changed" ]; then + echo "has_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_changes=false" >> "$GITHUB_OUTPUT" + fi + + - name: Get PR context + id: pr-context + if: steps.jira-files.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + pr_info=$(gh pr list --state merged --base main --limit 5 --json number,url,title \ + --jq 'map(select(.number != null)) | first') + pr_number=$(echo "$pr_info" | jq -r '.number // ""') + pr_url=$(echo "$pr_info" | jq -r '.url // ""') + pr_title=$(echo "$pr_info" | jq -r '.title // ""') + echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT" + echo "pr_url=$pr_url" >> "$GITHUB_OUTPUT" + echo "pr_title=$pr_title" >> "$GITHUB_OUTPUT" + + - name: Apply JIRA changes and fetch updated state + id: apply-jira + if: steps.jira-files.outputs.has_changes == 'true' + env: + GITHUB_PR_URL: ${{ steps.pr-context.outputs.pr_url }} + GITHUB_PR_TITLE: ${{ steps.pr-context.outputs.pr_title }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + applied_tickets="" + for file in ${{ steps.jira-files.outputs.files }}; do + ticket_key=$(node scripts/jira/process.js "$file") + [ -n "$ticket_key" ] && applied_tickets="$applied_tickets $ticket_key" + done + echo "tickets=$applied_tickets" >> "$GITHUB_OUTPUT" + + - name: Commit refreshed .jira files + if: steps.jira-files.outputs.has_changes == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A -- '.jira/' + if ! git diff --cached --quiet; then + git commit -m "chore: refresh JIRA details [skip ci]" + git push origin HEAD:main + fi + + - name: Post PR comment with JIRA tickets + if: > + steps.jira-files.outputs.has_changes == 'true' && + steps.apply-jira.outputs.tickets != '' && + steps.pr-context.outputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + tickets="${{ steps.apply-jira.outputs.tickets }}" + base_url="https://jira.corp.adobe.com/browse" + body="**JIRA tickets updated by this PR:**"$'\n' + for key in $tickets; do + body="$body"$'\n'"- [$key]($base_url/$key)" + done + gh pr comment ${{ steps.pr-context.outputs.pr_number }} --body "$body" + # Cheap gate so the Production approval is only requested when there's # actual release work (changesets exist) or the operator forced a run. detect: @@ -32,7 +121,7 @@ jobs: fi publish: - needs: detect + needs: [detect, apply-jira] if: needs.detect.outputs.proceed == 'true' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest environment: Production @@ -66,6 +155,8 @@ jobs: pnpm changeset version git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + # Pull any commits made by apply-jira before pushing (e.g. JIRA detail refresh) + git pull --rebase origin main git add -A if ! git diff --cached --quiet; then git commit -m "chore: publish [skip ci]" diff --git a/.jira/.gitkeep b/.jira/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/.jira/README.md b/.jira/README.md new file mode 100644 index 000000000..873d07b45 --- /dev/null +++ b/.jira/README.md @@ -0,0 +1,98 @@ +# .jira/ — Version-controlled JIRA ticket management + +YAML files in this directory represent pending JIRA changes. When a PR merges, the build workflow applies each file to JIRA and replaces it with a refreshed snapshot. + +## File naming convention + +| File type | Name format | Example | +|-----------|-------------|---------| +| Existing ticket | `{PROJECT}-{TICKET#}-{short-title}.yml` | `PDCL-1234-support-for-adcloud.yml` | +| New ticket (not yet in JIRA) | `{PROJECT}-XXXX-{short-description}.yml` | `PDCL-XXXX-add-identity-map-support.yml` | + +Short titles and descriptions use kebab-case, 3–6 words. + +## YAML schema + +```yaml +# Optional read-only snapshot of the ticket's current JIRA state. +# Populated by fetch.mjs; ignored by apply.mjs. +details: + key: PDCL-1234 + summary: Support for AdCloud + status: { name: "In Progress" } + # ... other non-null fields from JIRA + +# Optional ordered array of idempotent REST calls to apply on merge. +# Omit if there are no pending changes. +updates: + - path: /rest/api/2/issue/PDCL-1234 + method: PUT + body: + update: + summary: + - set: "Updated title" + customfield_23300: # AEP Web SDK product field + - set: { id: "116005" } +``` + +### `details` section + +- Written by `fetch.mjs`; never modified manually +- Read-only snapshot; apply.mjs ignores it entirely +- Null and empty-array fields are omitted +- String fields longer than 500 chars are truncated with `...` + +### `updates` section + +- Ordered array of `{ path, method, body }` REST call objects +- `body` is YAML and is serialized to JSON before sending to JIRA +- All field updates must use `set` operations (never `add`/`remove`) to ensure idempotency +- For new tickets: first entry is a `POST /rest/api/2/issue` with `fields` (not `update`) +- Absent or empty `updates` → apply.mjs prints the key and exits 0 without any API calls + +## Custom field reference (PDCL project) + +| Field | ID | Value | +|-------|----|-------| +| AEP Web SDK component | `components[].id` | `"155901"` | +| Documentation component | `components[].id` | `"157512"` | +| AEP Web SDK product (`customfield_23300`) | `id` | `"116005"` | +| Issue type: Story | `issuetype.id` | `"7"` | +| Issue type: Bug | `issuetype.id` | `"1"` | +| Issue type: Documentation | `issuetype.id` | `"14801"` | + +## End-to-end flow + +``` +Developer CI (on merge to main) +───────── ───────────────────── +/jira-propose → apply.mjs + ↓ writes .jira/*.yml ↓ calls JIRA REST API +PR review ↓ creates remote link (repo-{PR#}) + ↓ approves JIRA changes delete file +Merge PR fetch.mjs + ↓ writes refreshed details + skip-ci commit +``` + +1. **Propose** — Run `/jira-propose` in Claude Code. Reads git diff and context, creates or updates a `.jira/*.yml` file locally. No JIRA API calls. +2. **Review** — The `.jira/` file diff appears in the PR. Reviewers can inspect and amend JIRA changes before they land. +3. **Apply** — On merge, `apply.mjs` executes each `updates` entry in order. For new (`XXXX`) tickets it creates the ticket first (idempotent: checks whether a remote link with `globalId: repo-{PR#}` already exists). +4. **Fetch** — After apply succeeds, the XXXX file is deleted and `fetch.mjs` writes a fresh file named with the real ticket key. A `[skip ci]` commit lands both JIRA and changeset changes. + +## Scripts + +```bash +# Apply a ticket file's updates to JIRA (CI usage) +JIRA_API_TOKEN=... GITHUB_PR_URL=... GITHUB_PR_TITLE=... \ + node scripts/jira/apply.mjs .jira/PDCL-1234-my-feature.yml + +# Dry-run: see planned API calls without making them +node scripts/jira/apply.mjs --dry-run .jira/PDCL-1234-my-feature.yml + +# Fetch current JIRA state into a file +JIRA_API_TOKEN=... node scripts/jira/fetch.mjs PDCL-1234 .jira/PDCL-1234-my-feature.yml + +# Dry-run: see what would be written +node scripts/jira/fetch.mjs --dry-run PDCL-1234 .jira/PDCL-1234-my-feature.yml +``` diff --git a/README.md b/README.md index f7a3d5a55..4b6cf1d7c 100644 --- a/README.md +++ b/README.md @@ -20,3 +20,12 @@ This repo contains multiple projects. Each one is in a subdirectory and has its ## Contribution Check out the [contribution guidelines](CONTRIBUTING.md) for quick start information, and head over to the [developer documentation](https://github.com/adobe/alloy/wiki) to understand the architecture and structure of the library. + +## JIRA ticket management + +JIRA changes are version-controlled as YAML files in [`.jira/`](./.jira/). See [`.jira/README.md`](./.jira/README.md) for the full workflow. + +**Quick start:** +1. Run `/jira-propose` in Claude Code to create or update a `.jira/` ticket file +2. The file appears in your PR diff for review +3. On merge, the build workflow applies it to JIRA automatically diff --git a/openspec/changes/github-jira-yaml-workflow/.openspec.yaml b/openspec/changes/github-jira-yaml-workflow/.openspec.yaml new file mode 100644 index 000000000..fab62b414 --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-24 diff --git a/openspec/changes/github-jira-yaml-workflow/design.md b/openspec/changes/github-jira-yaml-workflow/design.md new file mode 100644 index 000000000..afae55749 --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/design.md @@ -0,0 +1,91 @@ +## Context + +The team manages JIRA tickets for the AEP Web SDK (project `PDCL` at `jira.corp.adobe.com`) manually through the browser. Existing scripts in `scripts/createJiraTicket.js` and `scripts/team/` (api.js, config.js, token.js) were written to automate ticket creation but have never been adopted team-wide because they require individual token setup and don't integrate with the PR review process. + +The new system treats `.jira/*.yml` files as the source of truth for pending JIRA changes. A GitHub Actions workflow applies them on merge. A Claude Code action creates/updates these files locally during development. + +Custom field IDs used in the PDCL project: +- `components`: `[{ id: "155901" }]` (AEP Web SDK) +- `customfield_23300`: `{ id: "116005" }` (product) +- Documentation component: `{ id: "157512" }` +- Issue types: bug `1`, story `7`, documentation `14801` +- Base URL: `https://jira.corp.adobe.com` + +## Goals / Non-Goals + +**Goals:** +- All JIRA changes made via PR are auditable, reviewable, and version-controlled +- Apply step is idempotent — re-running the workflow never creates duplicate tickets or duplicate updates +- Local authoring (propose) makes zero JIRA API calls +- YAML files are human-readable and support inline comments for custom field documentation +- Remote link to the merged PR is always created in JIRA as part of apply +- PR review is the approval mechanism for JIRA changes — `/jira-propose` writes files locally with no external effect, so developers can write ticket files freely without prior sign-off + +**Non-Goals:** +- Real-time JIRA → repo sync (the `details` snapshot is a one-time capture, not kept live) +- Automated backfill of historical tickets +- Creating tickets in JIRA projects other than PDCL (fetch can read any project key; only PDCL ticket creation is supported by apply) +- Replacing the JIRA UI for complex ticket authoring (descriptions, attachments, etc.) + +## Decisions + +### YAML over JSON for ticket files +**Decision**: Use YAML with inline comments. +**Rationale**: Custom field IDs like `customfield_23300` are opaque. YAML lets authors add `# AEP Web SDK product field` next to each custom field so the file is self-documenting. The apply script converts YAML bodies to JSON before sending to the REST API. +**Alternative**: JSON with a separate field-map file — rejected because it separates documentation from the data. + +### REST call array (`updates`) rather than a declarative diff +**Decision**: `updates` is an ordered array of `{ path, method, body }` objects. +**Rationale**: JIRA's REST API is operation-based (edit fields via PUT/POST with specific payloads). A declarative diff would require a JIRA-specific diff engine. The array approach maps directly to the API and keeps the schema simple. +**Idempotency**: All `body.update` payloads use `set` operations, never `add`/`remove`. The apply script skips updates when the remote link already exists (checked before applying). + +### Remote link as idempotency key for new tickets +**Decision**: Each new ticket YAML carries a unique hex `globalId` (e.g. `"a3f8b2c1d4e5f6a7"`) in its remotelink `updates` entry, generated by the `/jira-propose` skill at authoring time. Before creating a new ticket, the apply script searches JIRA for recent issues and fetches all their remote links in parallel (via `Promise.all`); if any issue's links contain a matching `globalId`, creation is skipped, the found key is returned, and the create fields are PUT to the existing ticket to ensure idempotency. The XXXX file is deleted and `fetchFile` re-creates the real-key file — fully idempotent on re-run. +**Rationale**: A per-ticket unique `globalId` (rather than a shared `repo-{PR#}`) allows multiple new tickets in the same PR to each have their own idempotency key. JIRA remote link `globalId` is indexed, making the lookup reliable. `Promise.all` on remote-link fetches keeps the search fast without sequential awaits (avoids ESLint `no-await-in-loop`). + +### `details` section is read-only metadata; refreshed by the build workflow +**Decision**: The `details` section is a read-only snapshot — the apply script ignores it entirely. After apply runs successfully, the build workflow deletes the file and calls fetch to regenerate `details` from live JIRA state, producing a fresh file with the real ticket key in the filename. +**Rationale**: Prevents accidental JIRA drift from a stale snapshot. The post-apply fetch ensures `details` always reflects the true JIRA state after the updates have landed. + +### New ticket file naming with `XXXX` sentinel +**Decision**: Unsubmitted tickets use `PDCL-XXXX-short-description.yml`. After apply creates the ticket (or finds an existing one via the remote-link check), the build workflow deletes the XXXX file and calls `fetch.mjs ` to create a properly-named file (e.g. `PDCL-1234-short-description.yml`) with populated `details`. +**Rationale**: Deleting the XXXX file and creating a named file in a single skip-ci commit is idempotent: if the build fails and re-runs, apply finds the remote link, exits 0, and the delete+fetch sequence completes cleanly. + +### Local `/jira-propose` makes zero JIRA calls +**Decision**: The propose action only reads `.jira/` files and git state. It never calls the JIRA API. +**Rationale**: Developers may not have a JIRA PAT configured; the action should work offline and in CI lint passes. JIRA reads are deferred to the `details` snapshot (which can be populated manually or by a separate fetch script in future). + +### Apply and fetch script location +**Decision**: `scripts/jira/apply.js`, `scripts/jira/fetch.js`, `scripts/jira/api.js`, and `scripts/jira/process.js` — standalone Node.js ES modules (`.js` extension; `package.json` has `"type": "module"` at the repo root). +**Rationale**: Keeps CI logic in the same `scripts/` tree as the existing JIRA helpers, enabling code reuse from `scripts/team/config.js`. Using `.js` (not `.mjs`) matches the repo ESLint convention (`import/extensions` rule requires `js: "always"`). The `api.js` factory enables dependency injection for tests without `vi.mock()`; `process.js` provides a single orchestration entry point for the build workflow. + +### Fetch script design +**Decision**: `fetch.js` requires exactly two arguments: `` and ``. Both are required. It calls JIRA's `GET /rest/api/2/issue/{key}` endpoint and **overwrites** the file at the given path with a fresh `details`-only section (no `updates`). In `--dry-run` mode it still performs the GET and prints the would-be content to stdout without writing. A `process.js` orchestrator wraps apply + fetch for use in the build workflow loop. +**Rationale**: Not preserving `updates` prevents stale update entries from accumulating in the file after the build has already applied them. After apply succeeds, the old `updates` are already executed — the refreshed file should be a clean snapshot. Keeping key and filename independent lets the build workflow substitute the real ticket number for XXXX in the filename. Fetch can read any JIRA project since it is read-only. + +### No separate JIRA workflow file +**Decision**: Integrate the `apply-jira` job into the existing `.github/workflows/version-and-publish.yml`. +**Rationale**: Avoids proliferating workflow files. The apply job is a pure side-effect on merge to `main` — it belongs alongside other post-merge steps (publish, deploy, release notes). It runs independently of the changeset gate so it fires on every push to `main`, not just release pushes. + +## Risks / Trade-offs + +- **Build dies between apply and commit** → Mitigation: Idempotent by design — re-run finds the remote link, deletes the XXXX file again (or finds it already gone), and re-creates the real-key file via fetch. Git commit is a no-op if files haven't changed. +- **PAT expiry breaks apply workflow** → Mitigation: Workflow fails loudly with a clear error; no partial JIRA state written. Token rotation follows the existing `JIRA_API_TOKEN` ops pattern. +- **Large JIRA body fields (description)** → Mitigation: `details` snapshot truncates long string fields (> 500 chars) with a `...` suffix; full content lives in JIRA. +- **XXXX files from different PRs collide** → Each PR that creates a new ticket produces a distinct JIRA ticket with a distinct key. Concurrent XXXX files on concurrent PRs are fine — they will each create their own ticket once merged. + +## Migration Plan + +1. Add `.jira/` directory with a `.gitkeep` and README +2. Add `scripts/jira/fetch.mjs` — test locally against a real PDCL ticket with `--dry-run` +3. Add `scripts/jira/apply.mjs` — test locally with `--dry-run` +4. Add `apply-jira` job to `version-and-publish.yml`; coordinate its file additions/deletions with the existing changeset skip-ci commit so a single commit covers both; add `JIRA_API_TOKEN` secret to the `Production` environment +5. Add `.claude/skills/jira-propose/SKILL.md` +6. Create or update `CLAUDE.md` and the project README with the full workflow + +Rollback: Remove the `apply-jira` job from the workflow. YAML files in `.jira/` are inert without the job. + +## Resolved Questions + +- **Should the apply workflow post a PR comment with JIRA links after applying?** Yes — add a step that posts a PR comment listing the JIRA tickets that were created or updated. +- **Should `details` be populated automatically by a fetch script when `/jira-propose` is run?** No — `/jira-propose` works locally only and assumes local ticket files are up-to-date. Developers run fetch manually if they need to refresh `details`. diff --git a/openspec/changes/github-jira-yaml-workflow/proposal.md b/openspec/changes/github-jira-yaml-workflow/proposal.md new file mode 100644 index 000000000..d908e3aa9 --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/proposal.md @@ -0,0 +1,39 @@ +## Why + +The team's JIRA backlog management is disconnected from the codebase — ticket updates happen in the browser, making them invisible to code review, hard to audit, and impossible to automate. By representing JIRA changes as YAML files in the repo, every ticket update becomes a reviewable, version-controlled artifact that is applied deterministically when a PR merges, eliminating manual JIRA work and keeping the backlog in sync with shipped code. + +## What Changes + +- New `.jira/` directory at the repo root holds one YAML file per ticket (existing or new) +- YAML files follow the schema `{PROJECT}-{TICKET#}-short-title.yml` (e.g. `PDCL-1234-support-for-adcloud.yml`); new unsubmitted tickets use `PDCL-XXXX-short-description.yml` +- Each file has two optional top-level sections: + - `details` — snapshot of the ticket's current non-null fields (read-only reference; not applied to JIRA) + - `updates` — ordered array of idempotent REST calls (`path`, `method`, `body`) to apply when the PR merges +- YAML is used (not JSON) so inline comments can document custom field IDs (e.g. `customfield_23300`) +- The existing build workflow (`version-and-publish.yml`) gains a new `apply-jira` job: for each changed `.jira/*.yml` file that has an `updates` section, it runs `apply.mjs` → on success, deletes the file → runs `fetch.mjs` to create a fresh file at the real-key filename; all `.jira/` additions/deletions are committed in a single skip-ci commit alongside any changeset version bump +- `scripts/jira/apply.js ` applies that file's `updates` to JIRA (interpolating `{GITHUB_PR_URL}` and `{GITHUB_PR_TITLE}` placeholders) and prints the resolved ticket key (e.g. `PDCL-1234`) to stdout; the remote link to the PR is an explicit entry in the `updates` array with a unique per-ticket `globalId` +- New tickets use `PDCL-XXXX-...` sentinel names; before creating, apply searches JIRA for any issue whose remote links already contain the ticket's unique `globalId`; if found, the existing key is returned (no new ticket created); in either case the XXXX file is deleted and replaced by a real-key file via fetch +- `scripts/jira/fetch.js ` fetches the current JIRA state for `` and writes a fresh `details`-only snapshot to `` (no `updates` section); both arguments are required +- `scripts/jira/process.js ` orchestrates apply + fetch for a single file; used by both the build workflow loop and as a CLI entry point +- A new `/jira-propose` Claude Code action inspects current git changes and chat context, searches `.jira/` for a matching ticket file, and creates one locally if none exists — **no JIRA API calls are made locally** +- Existing `scripts/createJiraTicket.js` and related scripts are superseded and will be removed; their field/template knowledge (project key, component IDs, custom fields) is migrated into the YAML schema and helper scripts + +## Capabilities + +### New Capabilities + +- `jira-yaml-schema`: YAML file format and directory conventions for representing JIRA ticket state and pending updates +- `jira-apply-workflow`: Job added to the existing build workflow that applies `.jira/` YAML files to JIRA on every push to `main`, including remote-link creation and idempotency logic +- `jira-fetch-script`: `scripts/jira/fetch.js` that writes a fresh `details`-only snapshot from a live JIRA ticket; `scripts/jira/process.js` orchestrates apply + fetch together +- `jira-propose-action`: Claude Code `/jira-propose` slash command that creates or updates `.jira/` YAML files locally based on current changes — no JIRA API calls + +### Modified Capabilities + +## Impact + +- New `.jira/` directory and files added to all PRs that touch JIRA tickets +- `.github/workflows/version-and-publish.yml` gains a new `apply-jira` job; no new workflow file is created +- New `scripts/jira/apply.js`, `scripts/jira/fetch.js`, `scripts/jira/api.js`, and `scripts/jira/process.js` Node.js scripts; all reuse `scripts/team/config.js` for base URL and project key +- Existing `scripts/createJiraTicket.js`, `scripts/openPr.js` JIRA fetch logic, and `scripts/team/` helpers referenced but some superseded +- New `.claude/skills/jira-propose/SKILL.md` skill definition (skills can be invoked by the agent automatically; commands cannot) +- `JIRA_API_TOKEN` secret will be added to the GitHub Actions `Production` environment diff --git a/openspec/changes/github-jira-yaml-workflow/specs/jira-apply-workflow/spec.md b/openspec/changes/github-jira-yaml-workflow/specs/jira-apply-workflow/spec.md new file mode 100644 index 000000000..dee0c2d57 --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/specs/jira-apply-workflow/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: The existing build workflow gains an `apply-jira` job that runs on every push to `main` +`.github/workflows/version-and-publish.yml` SHALL include a new `apply-jira` job. The job SHALL run on every `push` to `main` (independently of the changeset gate used by the `publish` job). No separate workflow file is created. + +#### Scenario: Job runs on every push to `main`, not only release pushes +- **WHEN** a PR with `.jira/*.yml` changes is merged and there is no pending changeset +- **THEN** the `apply-jira` job still runs + +#### Scenario: Job exits early when no `.jira/*.yml` files changed in the push +- **WHEN** the merge commit contains no changes to `.jira/*.yml` files +- **THEN** the `apply-jira` job exits without making any JIRA API calls + +### Requirement: The workflow discovers changed `.jira/` files from the merge and runs apply then fetch on each +The `apply-jira` job SHALL detect which `.jira/*.yml` files changed in the merge commit using `git diff HEAD^1 HEAD --name-only`. For each changed file it SHALL: +1. Run `node scripts/jira/apply.mjs ` and capture the ticket key printed to stdout +2. Run `node scripts/jira/fetch.mjs ` to refresh `details` with post-apply JIRA state +3. After all files are processed, commit any changes to `.jira/` files back to `main` with `[skip ci]` + +#### Scenario: apply then fetch run for each changed file +- **WHEN** a PR merges that changed `PDCL-1234-my-feature.yml` and `PDCL-XXXX-new-ticket.yml` +- **THEN** the job runs `apply.mjs` then `fetch.mjs` on each file independently + +#### Scenario: Ticket key from apply is passed to fetch +- **WHEN** `apply.mjs PDCL-XXXX-new-ticket.yml` prints `PDCL-5678` to stdout (the newly created ticket key) +- **THEN** the job calls `fetch.mjs PDCL-5678 PDCL-XXXX-new-ticket.yml` + +#### Scenario: Updated details are committed back to main +- **WHEN** fetch refreshes one or more `.jira/` files with new `details` content +- **THEN** the job commits those changes to `main` with a `chore: refresh JIRA details [skip ci]` message + +### Requirement: Apply script takes a single filename argument and prints the resolved ticket key to stdout +`scripts/jira/apply.mjs` SHALL accept one required positional argument: the path to a `.jira/*.yml` file. It SHALL print the resolved JIRA ticket key (e.g. `PDCL-1234`) to stdout as the final line of output, so the calling script can capture it. + +For existing ticket files, the key is parsed from the filename. For `XXXX` files, the key is the real ticket key obtained after creation (or discovered via idempotency check). + +If the file has no `updates` key or an empty array, the script SHALL still print the ticket key and exit 0 without making any API calls. + +#### Scenario: Ticket key printed to stdout for existing ticket +- **WHEN** `node scripts/jira/apply.mjs .jira/PDCL-1234-my-feature.yml` runs +- **THEN** `PDCL-1234` is the last line printed to stdout + +#### Scenario: Real ticket key printed for new ticket +- **WHEN** `node scripts/jira/apply.mjs .jira/PDCL-XXXX-new-ticket.yml` runs and creates ticket `PDCL-5678` +- **THEN** `PDCL-5678` is the last line printed to stdout + +#### Scenario: No-update file prints key without API calls +- **WHEN** a file has no `updates` array and `apply.mjs` is called on it +- **THEN** the script prints the ticket key and exits 0 with no JIRA API calls made + +### Requirement: Apply script is idempotent for new ticket creation +Before creating a new ticket (file with `XXXX` in the name), the apply script SHALL search JIRA for any issue in the project with a remote link whose URL exactly matches `GITHUB_PR_URL`. If found, that ticket's key is used for all subsequent steps. The search SHALL handle JIRA API pagination. + +#### Scenario: Second run finds existing remote link and skips creation +- **WHEN** `apply.mjs` is called a second time for the same `PDCL-XXXX-*.yml` and the PR's remote link already exists +- **THEN** the script uses the found ticket key, skips the create call, and prints the key to stdout + +#### Scenario: No existing remote link — ticket is created +- **WHEN** no remote link matching `GITHUB_PR_URL` exists in the project +- **THEN** the apply script executes the create-ticket REST call and uses the returned key + +### Requirement: Apply script creates a JIRA remote link for every processed ticket that has updates +After applying all `updates` for a ticket, the apply script SHALL POST to `/rest/api/2/issue/{key}/remotelink` with: +- `url`: the value of `GITHUB_PR_URL` +- `title`: the value of `GITHUB_PR_TITLE` +- `relationship`: `"mentioned in"` + +If a remote link with the same URL already exists on the ticket, the script SHALL skip link creation. + +Files with no `updates` array SHALL NOT have a remote link created. + +#### Scenario: Remote link created after updates applied +- **WHEN** a `.jira/` file with a non-empty `updates` array is processed +- **THEN** a remote link pointing to the merged PR is added to the JIRA ticket + +#### Scenario: Remote link skipped for no-update files +- **WHEN** a `.jira/` file has no `updates` key +- **THEN** no remote link is created + +### Requirement: Apply script accepts PR context via environment variables +The apply script SHALL read `GITHUB_PR_URL` and `GITHUB_PR_TITLE` from the environment. Both are required when the file has an `updates` array. If either is missing when updates are present, the script SHALL exit with a clear error message and code 1. + +#### Scenario: Missing PR URL causes exit when updates present +- **WHEN** `GITHUB_PR_URL` is not set and the file has updates +- **THEN** the script exits with code 1 and an error message + +### Requirement: Apply script supports a `--dry-run` flag +When `--dry-run` is passed, the script SHALL print all planned API calls (method, path, JSON body) and the ticket key to stdout without making any HTTP requests. + +#### Scenario: Dry run prints planned calls and key +- **WHEN** `node scripts/jira/apply.mjs --dry-run .jira/PDCL-1234-foo.yml` is run +- **THEN** all planned JIRA calls are printed and `PDCL-1234` is the last line, with no HTTP requests made + +### Requirement: Apply script exits with a non-zero code on any API failure +If any JIRA REST call returns a non-2xx response, the apply script SHALL log the error and exit with code 1, causing the workflow job to fail. + +#### Scenario: API error causes job failure +- **WHEN** a JIRA API call returns 4xx or 5xx +- **THEN** the script logs the status and response body, then exits with code 1 + +### Requirement: Apply script uses `JIRA_API_TOKEN` from the environment +The apply script SHALL read the bearer token from `JIRA_API_TOKEN`. If absent, it SHALL print `"JIRA_API_TOKEN is required"` and exit with code 1. + +#### Scenario: Missing token causes early exit +- **WHEN** `JIRA_API_TOKEN` is not set +- **THEN** the script exits with code 1 before making any HTTP requests diff --git a/openspec/changes/github-jira-yaml-workflow/specs/jira-fetch-script/spec.md b/openspec/changes/github-jira-yaml-workflow/specs/jira-fetch-script/spec.md new file mode 100644 index 000000000..65aabcdf1 --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/specs/jira-fetch-script/spec.md @@ -0,0 +1,68 @@ +## ADDED Requirements + +### Requirement: Fetch script requires both a ticket key and a filename as positional arguments +`scripts/jira/fetch.mjs` SHALL accept exactly two required positional arguments: +1. `` — a JIRA issue key (e.g. `PDCL-1234`) +2. `` — the `.jira/` file to write (e.g. `.jira/PDCL-1234-my-feature.yml` or `PDCL-XXXX-new-ticket.yml`) + +If either argument is missing, the script SHALL print usage and exit with code 1. + +The ticket key and filename are treated independently — the key is used to fetch from JIRA, and the filename determines where to write. This allows the caller to pass the real key alongside an `XXXX` filename (e.g. after `apply.mjs` creates a new ticket). + +#### Scenario: Both arguments required +- **WHEN** the script is called with only one argument +- **THEN** it prints usage (`Usage: fetch.mjs `) and exits with code 1 + +#### Scenario: Key and filename provided independently +- **WHEN** called as `node scripts/jira/fetch.mjs PDCL-1234 .jira/PDCL-XXXX-new-ticket.yml` +- **THEN** it fetches JIRA data for `PDCL-1234` and writes to `.jira/PDCL-XXXX-new-ticket.yml` + +#### Scenario: Normal call for existing ticket +- **WHEN** called as `node scripts/jira/fetch.mjs PDCL-1234 .jira/PDCL-1234-my-feature.yml` +- **THEN** it fetches `PDCL-1234` from JIRA and updates `.jira/PDCL-1234-my-feature.yml` + +### Requirement: Fetch script populates the `details` section from live JIRA data +The fetch script SHALL call `GET /rest/api/2/issue/{key}` and extract all non-null fields from the response. It SHALL write these fields under the `details` key in the output YAML file. + +Long string values (over 500 characters) SHALL be truncated to 500 characters with a `...` suffix. The `details` section SHALL be annotated with a YAML comment (`# fetched from JIRA `) on the first line. + +Fields with null or empty-array values SHALL be omitted from `details`. + +#### Scenario: Non-null fields appear in `details` +- **WHEN** the fetch script runs against `PDCL-1234` which has a summary, status, assignee, and two components +- **THEN** all four fields appear under `details` in the output YAML + +#### Scenario: Null fields are omitted +- **WHEN** a field in the JIRA response is null or an empty array +- **THEN** that field does not appear in the `details` section + +#### Scenario: Long description is truncated +- **WHEN** the JIRA issue has a description longer than 500 characters +- **THEN** the `details.description` value is truncated to 500 characters followed by `...` + +### Requirement: Fetch script preserves an existing `updates` section +If the target file already exists and contains an `updates` array, the fetch script SHALL overwrite only the `details` section and leave `updates` unchanged. + +If the file does not exist, the script creates it with only a `details` section (no `updates` key). + +#### Scenario: Existing `updates` are not clobbered +- **WHEN** `.jira/PDCL-1234-my-feature.yml` already exists with two entries in `updates` +- **THEN** after running fetch, the file still has those two `updates` entries unchanged, with a refreshed `details` section + +#### Scenario: New file created with only `details` +- **WHEN** no file exists at the given filename path +- **THEN** the script creates the file containing only a `details` section + +### Requirement: Fetch script supports a `--dry-run` flag +When `--dry-run` is passed, the script SHALL print the computed JIRA API URL and the YAML that would be written, without writing any files or making HTTP requests. + +#### Scenario: Dry run prints planned output +- **WHEN** `node scripts/jira/fetch.mjs --dry-run PDCL-1234 .jira/PDCL-1234-foo.yml` is run +- **THEN** the target path and YAML content are printed to stdout and nothing is written + +### Requirement: Fetch script requires `JIRA_API_TOKEN` from the environment +The fetch script SHALL read the bearer token from `JIRA_API_TOKEN`. If absent, it SHALL print `"JIRA_API_TOKEN is required"` and exit with code 1. + +#### Scenario: Missing token causes early exit +- **WHEN** `JIRA_API_TOKEN` is not set +- **THEN** the script exits with code 1 before making any HTTP request diff --git a/openspec/changes/github-jira-yaml-workflow/specs/jira-propose-action/spec.md b/openspec/changes/github-jira-yaml-workflow/specs/jira-propose-action/spec.md new file mode 100644 index 000000000..5cebc1c7c --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/specs/jira-propose-action/spec.md @@ -0,0 +1,79 @@ +## ADDED Requirements + +### Requirement: `/jira-propose` creates or updates a `.jira/` YAML file locally without calling JIRA +The `/jira-propose` Claude Code slash command SHALL operate entirely locally. It SHALL NOT make any calls to the JIRA REST API. All information used to populate a new ticket file comes from git state, chat context, and existing `.jira/` files. + +#### Scenario: Propose runs without JIRA credentials +- **WHEN** a developer runs `/jira-propose` with no `JIRA_API_TOKEN` set +- **THEN** the command completes successfully and writes a `.jira/` YAML file + +### Requirement: `/jira-propose` searches existing `.jira/` files for a matching ticket +When invoked, `/jira-propose` SHALL scan all `.jira/*.yml` files and compare their content (filename slug, `details.summary` if present) against the current git changes and chat context. If a suitable match is found, the command SHALL update the existing file. If no match is found, the command SHALL create a new `PDCL-XXXX-.yml` file. + +#### Scenario: Matching ticket found +- **WHEN** an existing `.jira/PDCL-1234-my-feature.yml` matches the current changes +- **THEN** `/jira-propose` updates that file's `updates` section rather than creating a new file + +#### Scenario: No matching ticket found +- **WHEN** no existing `.jira/` file matches the current changes +- **THEN** `/jira-propose` creates a new `PDCL-XXXX-.yml` with an `updates` array containing a create-ticket REST call + +### Requirement: `/jira-propose` uses current git changes and chat context to populate the ticket +`/jira-propose` SHALL inspect: +- The current git diff (staged and unstaged changes) to infer what changed +- Recent commit messages on the branch +- Chat context provided by the user in the invocation or recent conversation + +From this context it SHALL populate: +- `summary` (required): a concise one-line ticket title +- `description` (optional): a paragraph describing the change and motivation, including business value — customer names, who benefits, why it matters, and the elevator-pitch value statement +- `issuetype`: defaulting to `story` (id `7`) unless the change is clearly a bug fix +- `components`: `[{ id: "155901" }]` (AEP Web SDK, matching existing defaults) +- `customfield_23300`: `{ id: "116005" }` (product field, matching existing defaults) + +#### Scenario: Summary derived from commit messages +- **WHEN** the branch has commit messages describing a feature +- **THEN** the proposed ticket's `summary` reflects those commit messages + +#### Scenario: Issue type defaults to story +- **WHEN** the change does not appear to be a bug fix +- **THEN** the proposed ticket uses `issuetype: { id: "7" }` (story) + +#### Scenario: Issue type set to bug for fix changes +- **WHEN** commit messages or diff context indicate a bug fix +- **THEN** the proposed ticket uses `issuetype: { id: "1" }` (bug) + +### Requirement: New ticket `updates` array contains an idempotent create-ticket call as the first entry +For new tickets, the first entry in `updates` SHALL be a `POST /rest/api/2/issue` call whose body uses `fields` (not `update`) to set all initial field values. This follows JIRA's create-issue API shape. + +The `summary`, `issuetype`, `project`, `components`, and `customfield_23300` fields SHALL always be present in the create body. The `description` field SHALL be included if non-empty. + +#### Scenario: Create call is first in updates array +- **WHEN** `/jira-propose` creates a new `PDCL-XXXX-*.yml` file +- **THEN** `updates[0]` is `{ path: "/rest/api/2/issue", method: "POST", body: { fields: { ... } } }` + +#### Scenario: Create body includes required fields +- **WHEN** the created file's first update is inspected +- **THEN** it contains `project`, `issuetype`, `summary`, `components`, and `customfield_23300` + +### Requirement: YAML output includes inline comments for custom field IDs +The YAML files written by `/jira-propose` SHALL include inline comments explaining custom field IDs, matching the pattern used in `scripts/team/api.js`. + +Specifically: +- `customfield_23300` SHALL have the comment `# AEP Web SDK product field` +- Component IDs SHALL have comments identifying the component name (e.g. `# AEP Web SDK`, `# Documentation`) + +#### Scenario: Custom field comment present in output +- **WHEN** `/jira-propose` writes a new ticket file +- **THEN** `customfield_23300` has an adjacent YAML comment identifying it + +### Requirement: `/jira-propose` is implemented as a Claude Code skill in `.claude/skills/jira-propose/` +The action SHALL be implemented as a markdown skill file at `.claude/skills/jira-propose/SKILL.md`. Skills (unlike commands) can be invoked automatically by the agent in addition to being invoked manually by the developer. + +#### Scenario: Skill is available in Claude Code +- **WHEN** a developer types `/jira-propose` in a Claude Code session +- **THEN** Claude Code loads and executes the skill from `.claude/skills/jira-propose/SKILL.md` + +#### Scenario: Skill can be invoked automatically by the agent +- **WHEN** the Claude Code agent determines that a JIRA ticket file should be created or updated +- **THEN** the agent can invoke the skill without explicit developer input diff --git a/openspec/changes/github-jira-yaml-workflow/specs/jira-yaml-schema/spec.md b/openspec/changes/github-jira-yaml-workflow/specs/jira-yaml-schema/spec.md new file mode 100644 index 000000000..e5516e6a6 --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/specs/jira-yaml-schema/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Ticket files live in `.jira/` with a defined naming convention +All JIRA ticket files SHALL be stored in a `.jira/` directory at the repository root. + +Existing ticket files SHALL be named `{PROJECT}-{TICKET#}-{short-title}.yml` (e.g. `PDCL-1234-support-for-adcloud.yml`). + +New tickets not yet created in JIRA SHALL be named `{PROJECT}-XXXX-{short-description}.yml` (e.g. `PDCL-XXXX-add-identity-map-support.yml`). The `XXXX` sentinel signals that no real ticket key has been assigned yet. + +Short titles and descriptions SHALL use kebab-case and be 3–6 words. + +#### Scenario: Existing ticket file naming +- **WHEN** a developer creates a `.jira/` file for an existing JIRA ticket `PDCL-1234` +- **THEN** the file is named `PDCL-1234-.yml` and resides at `.jira/PDCL-1234-.yml` + +#### Scenario: New ticket file naming +- **WHEN** a developer creates a `.jira/` file for a ticket not yet in JIRA +- **THEN** the file is named `PDCL-XXXX-.yml` with the `XXXX` sentinel + +### Requirement: YAML file structure with `details` and `updates` sections +Each ticket file SHALL be valid YAML. The file MAY contain two top-level keys: `details` and `updates`. + +`details` SHALL contain a snapshot of the ticket's current non-null fields as they appear in JIRA, with long string values truncated to 500 characters followed by `...`. The `details` section is for human reference only and SHALL NOT be applied to JIRA by the apply script. + +`updates` SHALL be an ordered array of REST call objects. Each object SHALL have the fields: +- `path` (string): JIRA REST API path, e.g. `/rest/api/2/issue/PDCL-1234` +- `method` (string): HTTP method, e.g. `PUT`, `POST` +- `body` (object): Request body; YAML object converted to JSON before sending + +For new tickets (XXXX), the `details` key SHALL be absent. +For existing tickets with no pending changes, the `updates` key SHALL be absent. + +YAML comments (`#`) SHALL be used freely to document custom field IDs and their meanings. + +#### Scenario: File for existing ticket with updates +- **WHEN** a file `PDCL-1234-my-feature.yml` is opened +- **THEN** it contains a `details` map with current ticket fields and an `updates` array with REST call objects + +#### Scenario: File for new ticket +- **WHEN** a file `PDCL-XXXX-new-feature.yml` is opened +- **THEN** it contains only an `updates` array (no `details` key); the first update is a POST to create the ticket + +#### Scenario: File for existing ticket with no changes +- **WHEN** a file `PDCL-5678-reference-only.yml` is opened +- **THEN** it contains only a `details` map and no `updates` key + +### Requirement: `updates` entries use idempotent `set` operations +All field updates within `updates[*].body` SHALL use the JIRA `update` object with `set` operations, never `add` or `remove`, so that re-applying the same file produces the same JIRA state. + +#### Scenario: Field update is idempotent +- **WHEN** the apply script sends the same `updates` array twice to JIRA +- **THEN** the JIRA ticket state after both runs is identical and no duplicate data is created + +#### Scenario: Correct update body structure +- **WHEN** a developer writes an update to change the `status` field +- **THEN** the body uses `{ "update": { "fieldName": [{ "set": }] } }` or the equivalent direct PUT payload — never `add`/`remove` operations + +### Requirement: YAML bodies are converted to JSON before sending to JIRA +The apply script SHALL parse each `updates[*].body` as a YAML object and serialize it to JSON before including it in the HTTP request body. + +#### Scenario: YAML comments do not appear in JSON output +- **WHEN** a YAML body contains inline comments (e.g. `# AEP Web SDK product field`) +- **THEN** the JSON payload sent to JIRA contains no comment text diff --git a/openspec/changes/github-jira-yaml-workflow/tasks.md b/openspec/changes/github-jira-yaml-workflow/tasks.md new file mode 100644 index 000000000..a5579f2c3 --- /dev/null +++ b/openspec/changes/github-jira-yaml-workflow/tasks.md @@ -0,0 +1,54 @@ +## 1. Scaffold `.jira/` Directory and Schema + +- [x] 1.1 Create `.jira/` directory with a `.gitkeep` file; JIRA YAML files are tracked in version control +- [x] 1.2 Write a `.jira/README.md` documenting: file naming convention, YAML schema (`details` / `updates`), custom field reference (component IDs, issue type IDs, `customfield_23300`), and the expected end-to-end flow (propose skill → PR review → apply script → fetch script → build workflow commit) + +## 2. Fetch Script (`scripts/jira/fetch.js`) + +- [x] 2.1 Create `scripts/jira/fetch.js` as an ES module; require two positional args (`` and ``), exit with usage message if either is missing; wire up `--dry-run` flag and `JIRA_API_TOKEN` guard +- [x] 2.2 Implement `GET /rest/api/2/issue/{key}` call (supports any JIRA project, not limited to PDCL); extract non-null fields into a `details` object; omit null/empty-array fields; truncate strings over 500 chars with `...` +- [x] 2.3 Implement file write logic: overwrite the file with only `details` (no `updates` section) plus `# fetched from JIRA ` comment; in `--dry-run` mode, fetch from JIRA and print the would-be content to stdout without writing +- [x] 2.4 Export `fetchFile` for use by `process.js`; script entry point only executes when run directly + +## 3. Apply Script (`scripts/jira/apply.js`) + +- [x] 3.1 Create `scripts/jira/apply.js` as an ES module; require one positional `` argument; wire up `--dry-run` flag, `JIRA_API_TOKEN` guard, and `GITHUB_PR_URL`/`GITHUB_PR_TITLE` env-var validation (required only when `updates` is non-empty and not in dry-run) +- [x] 3.2 Implement ticket key resolution: parse `{PROJECT}-{NUMBER}` from filename; for `XXXX` files, extract the `globalId` from the remotelink entry in `updates`; search JIRA for recent issues in parallel (fetching remote links for each via `Promise.all`); if any issue has a remote link matching that `globalId`, reuse that ticket key and PUT the create fields to it (idempotency); if not found, POST to create a new ticket +- [x] 3.3 If the file has no `updates` or an empty array: return the resolved ticket key with no API calls +- [x] 3.4 Implement REST call executor: for each `updates` entry, serialize `body` from YAML to JSON and call JIRA API with `Authorization: Bearer`; fail fast on non-2xx; skip the `POST /rest/api/2/issue` create call for XXXX files (already handled in key resolution); use `eslint-disable-next-line no-await-in-loop` for intentionally sequential update loop +- [x] 3.5 Remote link idempotency via per-ticket unique globalId: the YAML remotelink entry in `updates` carries a unique hex `globalId` string generated at propose time; apply does not auto-inject a remotelink — it is explicit in the `updates` array +- [x] 3.6 Interpolate `{GITHUB_PR_URL}` and `{GITHUB_PR_TITLE}` placeholders in all `body` values before sending +- [x] 3.7 Add `--dry-run` output: log planned non-GET API calls and the resolved key without making mutating HTTP requests; read queries (searchIssues, getRemoteLinks) always run even in dry-run to give accurate previews +- [x] 3.8 Export `applyFile` for use by `process.js`; script entry point only executes when run directly + +## 3b. API Factory (`scripts/jira/api.js`) + +- [x] 3b.1 Create `scripts/jira/api.js` exporting a `createApi({ dryRun, baseUrl, token })` factory for dependency injection in tests +- [x] 3b.2 Expose `request(method, path, body)`, `searchIssues(jql, opts)`, `getRemoteLinks(key)` — dryRun only blocks non-GET requests + +## 3c. Process Orchestrator (`scripts/jira/process.js`) + +- [x] 3c.1 Create `scripts/jira/process.js` that calls `applyFile` then `fetchFile` for a single `.jira/*.yml` file; handles XXXX→real-key filename rename (deletes placeholder, creates new file); skips files with no updates; used by both the build workflow loop and as a CLI entry point +- [x] 3c.2 Export `processFile` for unit tests; vitest suite covers: no-updates skip, nonexistent file, existing ticket update, XXXX→real-key rename + +## 4. Build Workflow Integration (`.github/workflows/version-and-publish.yml`) + +- [x] 4.1 Add a new `apply-jira` job to `version-and-publish.yml` that runs on every push to `main`; use `Production` environment for `JIRA_API_TOKEN` access +- [x] 4.2 Add step to extract `GITHUB_PR_URL` and `GITHUB_PR_TITLE` (not PR number) from the merged PR via `gh pr list --state merged` +- [x] 4.3 Add step to detect changed `.jira/*.yml` files via `git diff HEAD^1 HEAD --name-only -- '.jira/*.yml'`; skip remaining steps if none +- [x] 4.4 Add loop step: for each changed file, run `node scripts/jira/process.js "$file"` (which handles apply + fetch + XXXX rename internally); capture printed ticket key +- [x] 4.5 Add a step after apply-jira that posts a PR comment listing all JIRA tickets created or updated (using `gh pr comment`) +- [x] 4.6 Add a commit step that stages all `.jira/` changes and commits with `[skip ci]`; the `publish` job declares `needs: [apply-jira]` so JIRA and changeset changes are coordinated + +## 5. `/jira-propose` Claude Code Skill + +- [x] 5.1 Create `.claude/skills/jira-propose/SKILL.md` skill file with instructions for the propose action (skills can be invoked automatically by the agent; commands cannot) +- [x] 5.2 Implement logic to scan `.jira/*.yml` files and match against current git diff, branch name, and recent commit messages +- [x] 5.3 Implement new-ticket YAML generation: populate `summary`, `description` (including business value: customer names, who benefits, elevator-pitch value statement), `issuetype`, `project`, `components`, `customfield_23300` with defaults from `scripts/team/api.js` ISSUE_TEMPLATES; include inline YAML comments for custom fields; generate a unique hex `globalId` per ticket for the remotelink entry +- [x] 5.4 Implement existing-ticket update: when a match is found, append or replace the `updates` array in the existing file +- [x] 5.5 Implement issue-type heuristic: default to `story` (id `7`); use `bug` (id `1`) when commit messages or diff context suggest a fix + +## 6. Cleanup and Documentation + +- [x] 6.1 README updated with `.jira/` workflow overview; CLAUDE.md was created during review but subsequently removed per reviewer request (documentation lives in `.jira/README.md` and the skill file) +- [ ] 6.2 Add `JIRA_API_TOKEN` secret to the `Production` GitHub Actions environment diff --git a/package.json b/package.json index 77aadc737..8aebbfa5a 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "globals": "^17.6.0", "happy-dom": "^20.9.0", "husky": "^9.1.7", + "js-yaml": "^5.1.0", "minimatch": "^10.2.5", "msw": "^2.14.3", "playwright": "^1.60.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fc9e9885..8e4acf10a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,202 +1,3 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - '@pnpm/exe': - specifier: 11.9.0 - version: 11.9.0 - pnpm: - specifier: 11.9.0 - version: 11.9.0 - -packages: - - '@pnpm/exe@11.9.0': - resolution: {integrity: sha512-pPPOpR79qW3nsNhlyDIdfstli4Bi78mk8r22ySxpFRwMbO8KXSjGrVzGmJBsVX39NnJTh7/WADj527nZhG9H9g==} - hasBin: true - - '@pnpm/linux-arm64@11.9.0': - resolution: {integrity: sha512-XYmY2qadHauBA3QaHi2R7fI6kt5Flje0WHz9MVrbH0kVH/XLpfOLnwPeE1+EX6K/nDa2CBvzp35VjYCNGFJa9A==} - cpu: [arm64] - os: [linux] - - '@pnpm/linux-x64@11.9.0': - resolution: {integrity: sha512-fl7W5imnSmmgXIqMQFZ/rPaVvk9OkKF8/anqHZE3XEDfWcn3BlWGndyOEas/JN7u2BXWYjs63DJZ3rnG6WOhLA==} - cpu: [x64] - os: [linux] - - '@pnpm/linuxstatic-arm64@11.9.0': - resolution: {integrity: sha512-fif8xbnzVEAIlvaU4yIgWKXeXYb4Kj6WMEl/KvM2x1Rp3AKAjBW/53SGzxO4cZP9doAqUzOIpMRGFVbHGeMDRw==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/linuxstatic-x64@11.9.0': - resolution: {integrity: sha512-9dKu3QdShqOpnWrjW9owARpIJeP0ul8UgIIbBUv8VDGEYibt+g49zQsNaD7SdMD3WeRSjExPQ1zIhplzr5cwvQ==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/macos-arm64@11.9.0': - resolution: {integrity: sha512-MWzBTgeI5p3odjdVltYvFXaSWAjF2Xk5YaxiP/u2RmW8N6PHsLyIyj37Ds992CrXgFM8fO1RpvsEirozhsD6KA==} - cpu: [arm64] - os: [darwin] - - '@pnpm/win-arm64@11.9.0': - resolution: {integrity: sha512-u/QxEcbKJZxC1t3zUYCZiHzu7TaZ/iXc6EGZoQjkeVT0LXmEVR6ypcK3ByjhIqbxQ3HGX75gnpIpR+VjRjubfw==} - cpu: [arm64] - os: [win32] - - '@pnpm/win-x64@11.9.0': - resolution: {integrity: sha512-HqJVHmZG5UKfLi38AjMl2azVmq87TlWRhwSW+f4q9LLaZeAkFyIZ7LY/pN6mh4VlH9yWj90C+tsb1yKiy7OKnw==} - cpu: [x64] - os: [win32] - - '@reflink/reflink-darwin-arm64@0.1.19': - resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@reflink/reflink-darwin-x64@0.1.19': - resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-arm64-musl@0.1.19': - resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@reflink/reflink-linux-x64-gnu@0.1.19': - resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-x64-musl@0.1.19': - resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@reflink/reflink-win32-x64-msvc@0.1.19': - resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@reflink/reflink@0.1.19': - resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} - engines: {node: '>= 10'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - pnpm@11.9.0: - resolution: {integrity: sha512-vWgtXQP+Ul73yf1ngMaITR51asTJyf4AxTh4KCQxDc+Q493E9Tg18G3669UIXkGFXgvLs7YN4qxburieUDbwOw==} - engines: {node: '>=22.13'} - hasBin: true - -snapshots: - - '@pnpm/exe@11.9.0': - dependencies: - '@reflink/reflink': 0.1.19 - detect-libc: 2.1.2 - optionalDependencies: - '@pnpm/linux-arm64': 11.9.0 - '@pnpm/linux-x64': 11.9.0 - '@pnpm/linuxstatic-arm64': 11.9.0 - '@pnpm/linuxstatic-x64': 11.9.0 - '@pnpm/macos-arm64': 11.9.0 - '@pnpm/win-arm64': 11.9.0 - '@pnpm/win-x64': 11.9.0 - - '@pnpm/linux-arm64@11.9.0': - optional: true - - '@pnpm/linux-x64@11.9.0': - optional: true - - '@pnpm/linuxstatic-arm64@11.9.0': - optional: true - - '@pnpm/linuxstatic-x64@11.9.0': - optional: true - - '@pnpm/macos-arm64@11.9.0': - optional: true - - '@pnpm/win-arm64@11.9.0': - optional: true - - '@pnpm/win-x64@11.9.0': - optional: true - - '@reflink/reflink-darwin-arm64@0.1.19': - optional: true - - '@reflink/reflink-darwin-x64@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-musl@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-musl@0.1.19': - optional: true - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - optional: true - - '@reflink/reflink-win32-x64-msvc@0.1.19': - optional: true - - '@reflink/reflink@0.1.19': - optionalDependencies: - '@reflink/reflink-darwin-arm64': 0.1.19 - '@reflink/reflink-darwin-x64': 0.1.19 - '@reflink/reflink-linux-arm64-gnu': 0.1.19 - '@reflink/reflink-linux-arm64-musl': 0.1.19 - '@reflink/reflink-linux-x64-gnu': 0.1.19 - '@reflink/reflink-linux-x64-musl': 0.1.19 - '@reflink/reflink-win32-arm64-msvc': 0.1.19 - '@reflink/reflink-win32-x64-msvc': 0.1.19 - - detect-libc@2.1.2: {} - - pnpm@11.9.0: {} - ---- lockfileVersion: '9.0' settings: @@ -240,19 +41,19 @@ importers: version: 25.6.2 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + version: 6.0.1(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) '@vitest/browser-playwright': specifier: ^4.1.6 - version: 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8) + version: 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.9) '@vitest/coverage-v8': specifier: ^4.1.6 - version: 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) + version: 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) '@vitest/eslint-plugin': specifier: ^1.6.16 - version: 1.6.17(eslint@10.3.0)(typescript@6.0.3)(vitest@4.1.8) + version: 1.6.17(eslint@10.3.0)(typescript@6.0.3)(vitest@4.1.9) baseline-browser-mapping: specifier: ^2.10.27 - version: 2.10.33 + version: 2.10.29 chalk: specifier: ^5.6.2 version: 5.6.2 @@ -301,6 +102,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + js-yaml: + specifier: ^5.1.0 + version: 5.1.0 minimatch: specifier: ^10.2.5 version: 10.2.5 @@ -330,10 +134,10 @@ importers: version: 0.2.4 vitest: specifier: ^4.1.6 - version: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) vitest-browser-react: specifier: ^2.0.2 - version: 2.2.0(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@4.1.8) + version: 2.2.0(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@4.1.9) packages/browser: dependencies: @@ -391,13 +195,13 @@ importers: version: 14.0.0 vitest: specifier: ^4.1.6 - version: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) packages/core: dependencies: '@adobe/aep-rules-engine': specifier: ^3.1.1 - version: 3.1.1(@vitest/browser@4.1.8)(vitest@4.1.8) + version: 3.1.1(@vitest/browser@4.1.9)(vitest@4.1.9) '@adobe/reactor-query-string': specifier: ^2.0.0 version: 2.0.0 @@ -606,10 +410,10 @@ importers: version: 2.0.9 '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + version: 6.0.1(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) vite: specifier: ^8.0.10 - version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0) + version: 8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0) sandboxes/node: dependencies: @@ -813,20 +617,20 @@ packages: resolution: {integrity: sha512-fCqPIfOcLE+CGqGPd66c8bZpwAji98tZ4JI9i/mlTNTlsIWslCfpg48s/ypyLxZTump5sypjrKn2/kY7q8oAbA==} engines: {node: '>=20.0.0'} - '@azure/msal-browser@5.13.0': - resolution: {integrity: sha512-Ea23x0U8XNFY+qJ9T44zO2BbY+AHdb+WdjmYnx36OhJ/KO+PGU5pmsNHf1DCElYX+6wyVRJz1HFeCPC/cHbRug==} + '@azure/msal-browser@5.15.0': + resolution: {integrity: sha512-2NYT6v+eeQn8kmNddr9LnbXSvXbVELpmFMmfFvtRxD7I/5+5GlkMlncApeuRFj+mY6C9syOwQip1a0Y+TIbyiA==} engines: {node: '>=0.8.0'} '@azure/msal-common@14.16.1': resolution: {integrity: sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==} engines: {node: '>=0.8.0'} - '@azure/msal-common@16.8.0': - resolution: {integrity: sha512-5S4RHOcInL2Nu2U217tDZbWGI6StMfcWCrA7TWvWdJmXQ+cYrrIqr84AsN62fGh2MDBysiBJPt6CfWceJfloEA==} + '@azure/msal-common@16.10.0': + resolution: {integrity: sha512-iYtjpanlv6963Jprs0MvzIap07V+QhultjQctfbEDQCflsDAEeO3R7XnVA5gk30fhoBFLdgJT7VqO0TGsEsN9w==} engines: {node: '>=0.8.0'} - '@azure/msal-node@5.2.4': - resolution: {integrity: sha512-rpBUg9dA8UpC2WiFt3KeDKVQmmmVrfxdRnW+F1ebgou/jX/0tAvYuonaq5RUo8OaqzOrj4x/HaI8DmY56RXZ2Q==} + '@azure/msal-node@5.3.0': + resolution: {integrity: sha512-fXtJX811pX8y8QlrQqBSH6+plvWyKZDI0IxkheAcyAw9OtcpXyFivmTC7eGUqutLWaDlKXuQ3yOESD4zAmkjHg==} engines: {node: '>=20'} '@azure/static-web-apps-cli@2.0.9': @@ -979,18 +783,10 @@ packages: resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': - resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.29.7': - resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.27.1': resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} @@ -1017,8 +813,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} hasBin: true @@ -1503,10 +1299,6 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} - engines: {node: '>=6.9.0'} - '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -1677,158 +1469,158 @@ packages: '@emotion/unitless@0.7.5': resolution: {integrity: sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==} - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -4502,9 +4294,6 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/estree@1.0.9': - resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/get-port@3.2.0': resolution: {integrity: sha512-TiNg8R1kjDde5Pub9F9vCwZA/BNW9HeXP5b9j7Qucqncy/McfPZ6xze/EyBdXS5FhMIGN6Fx3vg75l5KHy3V1Q==} @@ -4547,8 +4336,8 @@ packages: '@types/node@20.14.5': resolution: {integrity: sha512-aoRR+fJkZT2l0aGOJhuA8frnCSoNX6W7U2mpNq63+BxBIj5BQFt8rHy627kijCmm63ijdSdwvGgpUsU6MBsZZA==} - '@types/node@22.19.21': - resolution: {integrity: sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA==} + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} '@types/node@25.6.2': resolution: {integrity: sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==} @@ -4643,16 +4432,16 @@ packages: babel-plugin-react-compiler: optional: true - '@vitest/browser-playwright@4.1.8': - resolution: {integrity: sha512-SR7FqgegaexEg73xvf3ArtygXegagMdXnL0EZMpxrWvvhQxvicD/E8p0ib0J91riPRtQUViyh67Xjw3NqvyhVg==} + '@vitest/browser-playwright@4.1.9': + resolution: {integrity: sha512-Bq1rOGf9waevzG3EOkO/dene6bvKTUsZMVg8S1i+WH3JcMjuXEjiahP9rAqZRELUqjBySOJsvvSWqK/B3wjKQw==} peerDependencies: playwright: '*' - vitest: 4.1.8 + vitest: 4.1.9 - '@vitest/browser@4.1.8': - resolution: {integrity: sha512-u21VzX07HzlJYpFgkxmjEXar/tG2UqWGgyGG/46SrrPc7rSdCTPw5vuowopO9CIqF8UCUQzDFdbVnNpw6N0BfQ==} + '@vitest/browser@4.1.9': + resolution: {integrity: sha512-j1BKtWmPcqpMhmx/L9EPLgAJpCb0zKfwoWLmqBbxaogCXHjOwHFSEoHCBfnGtx93xKQwilZ26m+UOsHqHMkRNg==} peerDependencies: - vitest: 4.1.8 + vitest: 4.1.9 '@vitest/coverage-v8@3.2.4': resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} @@ -4663,11 +4452,11 @@ packages: '@vitest/browser': optional: true - '@vitest/coverage-v8@4.1.8': - resolution: {integrity: sha512-lt3kovsyHwYe00wq4D1ti0Z974fWj4NLp6siqiyEufUpyFwK9Yhi7rBhac9JL5aA0zoMrJqc4vYPZRUnI7l7nw==} + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} peerDependencies: - '@vitest/browser': 4.1.8 - vitest: 4.1.8 + '@vitest/browser': 4.1.9 + vitest: 4.1.9 peerDependenciesMeta: '@vitest/browser': optional: true @@ -4688,11 +4477,11 @@ packages: vitest: optional: true - '@vitest/expect@4.1.8': - resolution: {integrity: sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==} + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - '@vitest/mocker@4.1.8': - resolution: {integrity: sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==} + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} peerDependencies: msw: ^2.4.9 vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -4702,20 +4491,20 @@ packages: vite: optional: true - '@vitest/pretty-format@4.1.8': - resolution: {integrity: sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==} + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - '@vitest/runner@4.1.8': - resolution: {integrity: sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==} + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - '@vitest/snapshot@4.1.8': - resolution: {integrity: sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==} + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} - '@vitest/spy@4.1.8': - resolution: {integrity: sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==} + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} - '@vitest/utils@4.1.8': - resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} '@zeit/schemas@2.36.0': resolution: {integrity: sha512-7kjMwcChYEzMKjeex9ZFXkt1AyNov9R5HZtjBKVsmVpw7pa7ZtlCGvCBC2vnnXctaYN+aRI61HjIqeetZW5ROg==} @@ -4991,8 +4780,8 @@ packages: resolution: {integrity: sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw==} engines: {node: '>=4'} - axios@1.17.0: - resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} @@ -5120,8 +4909,8 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.33: - resolution: {integrity: sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==} + baseline-browser-mapping@2.10.29: + resolution: {integrity: sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -5934,8 +5723,8 @@ packages: resolution: {integrity: sha512-ginqzK3J90Rd4/Yz7qRrqUeIpe3TwSXTPPZtPne7tGBPeAaQiU8qt4fpKApnxHcq1AwtUdHVg5P77x/yrggG8Q==} engines: {node: '>=6'} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} engines: {node: '>=18'} hasBin: true @@ -6304,8 +6093,8 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} formdata-polyfill@4.0.10: @@ -6553,6 +6342,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + headers-polyfill@5.0.1: resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} @@ -7046,6 +6839,10 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + js-yaml@5.1.0: + resolution: {integrity: sha512-s8VA5jkR8f22S3NAXmhKPFqGUduqZGlsufabVOgN14iTdw/RXcym7bKkbwjxLK9Yw2lEvvmJjFp119+KPeo8Kg==} + hasBin: true + jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -9228,10 +9025,6 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.17: - resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} - engines: {node: '>=12.0.0'} - tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -9570,20 +9363,20 @@ packages: '@types/react-dom': optional: true - vitest@4.1.8: - resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.1.8 - '@vitest/browser-preview': 4.1.8 - '@vitest/browser-webdriverio': 4.1.8 - '@vitest/coverage-istanbul': 4.1.8 - '@vitest/coverage-v8': 4.1.8 - '@vitest/ui': 4.1.8 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 happy-dom: '*' jsdom: '*' vite: ^6.0.0 || ^7.0.0 || ^8.0.0 @@ -9803,9 +9596,9 @@ packages: snapshots: - '@adobe/aep-rules-engine@3.1.1(@vitest/browser@4.1.8)(vitest@4.1.8)': + '@adobe/aep-rules-engine@3.1.1(@vitest/browser@4.1.9)(vitest@4.1.9)': dependencies: - '@vitest/coverage-v8': 3.2.4(@vitest/browser@4.1.8)(vitest@4.1.8) + '@vitest/coverage-v8': 3.2.4(@vitest/browser@4.1.9)(vitest@4.1.9) transitivePeerDependencies: - '@vitest/browser' - supports-color @@ -10204,7 +9997,7 @@ snapshots: '@adobe/auth-token': 1.0.1(@types/node@25.6.2) chalk: 4.1.2 delay: 4.4.1 - form-data: 4.0.5 + form-data: 4.0.6 inquirer: 10.2.2 node-fetch: 2.7.0 ora: 5.4.1 @@ -10389,8 +10182,8 @@ snapshots: '@azure/core-tracing': 1.3.1 '@azure/core-util': 1.13.1 '@azure/logger': 1.3.0 - '@azure/msal-browser': 5.13.0 - '@azure/msal-node': 5.2.4 + '@azure/msal-browser': 5.15.0 + '@azure/msal-node': 5.3.0 open: 10.2.0 tslib: 2.8.1 transitivePeerDependencies: @@ -10403,17 +10196,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@azure/msal-browser@5.13.0': + '@azure/msal-browser@5.15.0': dependencies: - '@azure/msal-common': 16.8.0 + '@azure/msal-common': 16.10.0 '@azure/msal-common@14.16.1': {} - '@azure/msal-common@16.8.0': {} + '@azure/msal-common@16.10.0': {} - '@azure/msal-node@5.2.4': + '@azure/msal-node@5.3.0': dependencies: - '@azure/msal-common': 16.8.0 + '@azure/msal-common': 16.10.0 jsonwebtoken: 9.0.3 '@azure/static-web-apps-cli@2.0.9': @@ -10760,12 +10553,8 @@ snapshots: '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} - '@babel/helper-validator-identifier@7.28.5': {} - '@babel/helper-validator-identifier@7.29.7': {} - '@babel/helper-validator-option@7.27.1': {} '@babel/helper-wrap-function@7.28.3': @@ -10794,9 +10583,9 @@ snapshots: dependencies: '@babel/types': 7.29.0 - '@babel/parser@7.29.7': + '@babel/parser@7.29.3': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.28.5(@babel/core@7.28.5)': dependencies: @@ -11934,11 +11723,6 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': - dependencies: - '@babel/helper-string-parser': 7.29.7 - '@babel/helper-validator-identifier': 7.29.7 - '@bcoe/v8-coverage@1.0.2': {} '@blazediff/core@1.9.1': {} @@ -12291,82 +12075,82 @@ snapshots: '@emotion/unitless@0.7.5': {} - '@esbuild/aix-ppc64@0.27.7': + '@esbuild/aix-ppc64@0.27.2': optional: true - '@esbuild/android-arm64@0.27.7': + '@esbuild/android-arm64@0.27.2': optional: true - '@esbuild/android-arm@0.27.7': + '@esbuild/android-arm@0.27.2': optional: true - '@esbuild/android-x64@0.27.7': + '@esbuild/android-x64@0.27.2': optional: true - '@esbuild/darwin-arm64@0.27.7': + '@esbuild/darwin-arm64@0.27.2': optional: true - '@esbuild/darwin-x64@0.27.7': + '@esbuild/darwin-x64@0.27.2': optional: true - '@esbuild/freebsd-arm64@0.27.7': + '@esbuild/freebsd-arm64@0.27.2': optional: true - '@esbuild/freebsd-x64@0.27.7': + '@esbuild/freebsd-x64@0.27.2': optional: true - '@esbuild/linux-arm64@0.27.7': + '@esbuild/linux-arm64@0.27.2': optional: true - '@esbuild/linux-arm@0.27.7': + '@esbuild/linux-arm@0.27.2': optional: true - '@esbuild/linux-ia32@0.27.7': + '@esbuild/linux-ia32@0.27.2': optional: true - '@esbuild/linux-loong64@0.27.7': + '@esbuild/linux-loong64@0.27.2': optional: true - '@esbuild/linux-mips64el@0.27.7': + '@esbuild/linux-mips64el@0.27.2': optional: true - '@esbuild/linux-ppc64@0.27.7': + '@esbuild/linux-ppc64@0.27.2': optional: true - '@esbuild/linux-riscv64@0.27.7': + '@esbuild/linux-riscv64@0.27.2': optional: true - '@esbuild/linux-s390x@0.27.7': + '@esbuild/linux-s390x@0.27.2': optional: true - '@esbuild/linux-x64@0.27.7': + '@esbuild/linux-x64@0.27.2': optional: true - '@esbuild/netbsd-arm64@0.27.7': + '@esbuild/netbsd-arm64@0.27.2': optional: true - '@esbuild/netbsd-x64@0.27.7': + '@esbuild/netbsd-x64@0.27.2': optional: true - '@esbuild/openbsd-arm64@0.27.7': + '@esbuild/openbsd-arm64@0.27.2': optional: true - '@esbuild/openbsd-x64@0.27.7': + '@esbuild/openbsd-x64@0.27.2': optional: true - '@esbuild/openharmony-arm64@0.27.7': + '@esbuild/openharmony-arm64@0.27.2': optional: true - '@esbuild/sunos-x64@0.27.7': + '@esbuild/sunos-x64@0.27.2': optional: true - '@esbuild/win32-arm64@0.27.7': + '@esbuild/win32-arm64@0.27.2': optional: true - '@esbuild/win32-ia32@0.27.7': + '@esbuild/win32-ia32@0.27.2': optional: true - '@esbuild/win32-x64@0.27.7': + '@esbuild/win32-x64@0.27.2': optional: true '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': @@ -12517,7 +12301,7 @@ snapshots: '@inquirer/figures': 1.0.14 '@inquirer/type': 2.0.0 '@types/mute-stream': 0.0.4 - '@types/node': 22.19.21 + '@types/node': 22.20.0 '@types/wrap-ansi': 3.0.0 ansi-escapes: 4.3.2 cli-width: 4.1.0 @@ -20285,8 +20069,8 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 @@ -20294,18 +20078,18 @@ snapshots: '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 optional: true '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 optional: true '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.0 optional: true '@types/chai@5.2.3': @@ -20325,8 +20109,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/estree@1.0.9': {} - '@types/get-port@3.2.0': {} '@types/glob@5.0.38': @@ -20372,7 +20154,7 @@ snapshots: dependencies: undici-types: 5.26.5 - '@types/node@22.19.21': + '@types/node@22.20.0': dependencies: undici-types: 6.21.0 @@ -20480,34 +20262,34 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitejs/plugin-react@6.0.1(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0) - '@vitest/browser-playwright@4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)': + '@vitest/browser-playwright@4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: - '@vitest/browser': 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8) - '@vitest/mocker': 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + '@vitest/browser': 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/mocker': 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) playwright: 1.60.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8)': + '@vitest/browser@4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.9)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) - '@vitest/utils': 4.1.8 + '@vitest/mocker': 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -20515,7 +20297,7 @@ snapshots: - utf-8-validate - vite - '@vitest/coverage-v8@3.2.4(@vitest/browser@4.1.8)(vitest@4.1.8)': + '@vitest/coverage-v8@3.2.4(@vitest/browser@4.1.9)(vitest@4.1.9)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -20530,16 +20312,16 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/browser': 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.9) transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8)': + '@vitest/coverage-v8@4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.9 ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 @@ -20548,60 +20330,60 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.1.0 - vitest: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) optionalDependencies: - '@vitest/browser': 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8) + '@vitest/browser': 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.9) - '@vitest/eslint-plugin@1.6.17(eslint@10.3.0)(typescript@6.0.3)(vitest@4.1.8)': + '@vitest/eslint-plugin@1.6.17(eslint@10.3.0)(typescript@6.0.3)(vitest@4.1.9)': dependencies: '@typescript-eslint/scope-manager': 8.58.1 '@typescript-eslint/utils': 8.58.1(eslint@10.3.0)(typescript@6.0.3) eslint: 10.3.0 optionalDependencies: typescript: 6.0.3 - vitest: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color - '@vitest/expect@4.1.8': + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))': dependencies: - '@vitest/spy': 4.1.8 + '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.5(@types/node@25.6.2)(typescript@6.0.3) - vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0) - '@vitest/pretty-format@4.1.8': + '@vitest/pretty-format@4.1.9': dependencies: tinyrainbow: 3.1.0 - '@vitest/runner@4.1.8': + '@vitest/runner@4.1.9': dependencies: - '@vitest/utils': 4.1.8 + '@vitest/utils': 4.1.9 pathe: 2.0.3 - '@vitest/snapshot@4.1.8': + '@vitest/snapshot@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 magic-string: 0.30.21 pathe: 2.0.3 - '@vitest/spy@4.1.8': {} + '@vitest/spy@4.1.9': {} - '@vitest/utils@4.1.8': + '@vitest/utils@4.1.9': dependencies: - '@vitest/pretty-format': 4.1.8 + '@vitest/pretty-format': 4.1.9 convert-source-map: 2.0.0 tinyrainbow: 3.1.0 @@ -20967,10 +20749,10 @@ snapshots: axe-core@4.11.2: {} - axios@1.17.0: + axios@1.18.1: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 + form-data: 4.0.6 https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: @@ -21122,7 +20904,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.33: {} + baseline-browser-mapping@2.10.29: {} basic-ftp@5.2.2: {} @@ -21203,7 +20985,7 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.10.33 + baseline-browser-mapping: 2.10.29 caniuse-lite: 1.0.30001762 electron-to-chromium: 1.5.267 node-releases: 2.0.27 @@ -22012,34 +21794,34 @@ snapshots: es6-promisify@7.0.0: {} - esbuild@0.27.7: + esbuild@0.27.2: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 optional: true escalade@3.2.0: {} @@ -22267,7 +22049,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.9 + '@types/estree': 1.0.8 esutils@2.0.3: {} @@ -22521,12 +22303,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 formdata-polyfill@4.0.10: @@ -22807,6 +22589,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + headers-polyfill@5.0.1: dependencies: '@types/set-cookie-parser': 2.4.10 @@ -23315,6 +23101,10 @@ snapshots: dependencies: argparse: 2.0.1 + js-yaml@5.1.0: + dependencies: + argparse: 2.0.1 + jsesc@3.0.2: {} jsesc@3.1.0: {} @@ -23609,8 +23399,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 source-map-js: 1.2.1 make-dir@2.1.0: @@ -26263,11 +26053,6 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - tinyglobby@0.2.17: - dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 - tinyrainbow@2.0.0: {} tinyrainbow@3.1.0: {} @@ -26518,7 +26303,7 @@ snapshots: vary@1.1.2: {} - vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0): + vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -26527,28 +26312,28 @@ snapshots: tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.2 - esbuild: 0.27.7 + esbuild: 0.27.2 fsevents: 2.3.3 terser: 5.44.0 yaml: 2.9.0 - vitest-browser-react@2.2.0(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@4.1.8): + vitest-browser-react@2.2.0(@types/react@19.2.14)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(vitest@4.1.9): dependencies: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - vitest: 4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) optionalDependencies: '@types/react': 19.2.14 - vitest@4.1.8(@types/node@25.6.2)(@vitest/browser-playwright@4.1.8)(@vitest/coverage-v8@4.1.8)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@25.6.2)(@vitest/browser-playwright@4.1.9)(@vitest/coverage-v8@4.1.9)(happy-dom@20.9.0)(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)): dependencies: - '@vitest/expect': 4.1.8 - '@vitest/mocker': 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0)) - '@vitest/pretty-format': 4.1.8 - '@vitest/runner': 4.1.8 - '@vitest/snapshot': 4.1.8 - '@vitest/spy': 4.1.8 - '@vitest/utils': 4.1.8 + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 es-module-lexer: 2.0.0 expect-type: 1.3.0 magic-string: 0.30.21 @@ -26558,14 +26343,14 @@ snapshots: std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.2 - tinyglobby: 0.2.17 + tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0) + vite: 8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.2 - '@vitest/browser-playwright': 4.1.8(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.7)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.8) - '@vitest/coverage-v8': 4.1.8(@vitest/browser@4.1.8)(vitest@4.1.8) + '@vitest/browser-playwright': 4.1.9(msw@2.14.5(@types/node@25.6.2)(typescript@6.0.3))(playwright@1.60.0)(vite@8.0.11(@types/node@25.6.2)(esbuild@0.27.2)(terser@5.44.0)(yaml@2.9.0))(vitest@4.1.9) + '@vitest/coverage-v8': 4.1.9(@vitest/browser@4.1.9)(vitest@4.1.9) happy-dom: 20.9.0 transitivePeerDependencies: - msw @@ -26574,7 +26359,7 @@ snapshots: wait-on@7.2.0: dependencies: - axios: 1.17.0 + axios: 1.18.1 joi: 17.13.4 lodash: 4.18.1 minimist: 1.2.8 diff --git a/scripts/jira/api.js b/scripts/jira/api.js new file mode 100644 index 000000000..09b635d3f --- /dev/null +++ b/scripts/jira/api.js @@ -0,0 +1,59 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +/** + * Factory that returns a JIRA REST API client. + * @param {{ dryRun?: boolean, baseUrl: string, token: string }} opts + */ +export default function createApi({ dryRun = false, baseUrl, token }) { + const authHeaders = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + const request = async (method, path, body) => { + const url = `${baseUrl}${path}`; + if (dryRun && method !== "GET") { + console.log(`[dry-run] ${method} ${url}`); + if (body !== undefined) console.log(JSON.stringify(body, null, 2)); + return {}; + } + const response = await fetch(url, { + method, + headers: authHeaders, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!response.ok) { + const text = await response.text(); + throw new Error( + `JIRA ${method} ${path} failed: ${response.status} ${text}`, + ); + } + const text = await response.text(); + return text ? JSON.parse(text) : {}; + }; + + const searchIssues = async ( + jql, + { fields = "key", maxResults = 50 } = {}, + ) => { + const qs = `jql=${encodeURIComponent(jql)}&fields=${fields}&maxResults=${maxResults}`; + const response = await fetch(`${baseUrl}/rest/api/2/search?${qs}`, { + headers: authHeaders, + }); + if (!response.ok) return []; + const data = await response.json(); + return data.issues ?? []; + }; + + return { dryRun, request, searchIssues }; +} diff --git a/scripts/jira/apply.js b/scripts/jira/apply.js new file mode 100644 index 000000000..dccecdcef --- /dev/null +++ b/scripts/jira/apply.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { readFileSync } from "fs"; +import { basename } from "path"; +import { fileURLToPath } from "url"; +import { load as yamlLoad } from "js-yaml"; +import createApi from "./api.js"; +import { JIRA_BASE_URL, JIRA_API_TOKEN } from "./config.js"; + +// Recursively replace {PLACEHOLDER} tokens in body values. +const interpolate = (value, vars) => { + if (typeof value === "string") { + return value.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`); + } + if (Array.isArray(value)) return value.map((v) => interpolate(v, vars)); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([k, v]) => [k, interpolate(v, vars)]), + ); + } + return value; +}; + +// Parse filename → { project, ticketKey (if numeric), globalId (if non-numeric) }. +// e.g. "PDCL-1234-title.yml" → ticketKey="PDCL-1234", globalId=null +// "PDCL-a3f8b2c1-title.yml" → ticketKey=null, globalId="a3f8b2c1" +const parseFilename = (filename) => { + const base = basename(filename, ".yml"); + const match = base.match(/^([A-Z]+)-([a-zA-Z0-9]+)/); + if (!match) + throw new Error(`Cannot parse ticket key from filename: ${filename}`); + const [, project, keyPart] = match; + if (/^\d+$/.test(keyPart)) { + return { project, ticketKey: `${project}-${keyPart}`, globalId: null }; + } + return { project, ticketKey: null, globalId: keyPart }; +}; + +/** + * Apply a .jira/*.yml file's updates to JIRA. + * For new tickets (non-numeric key in filename), the globalId is used as a JIRA label + * so that re-runs find the existing ticket instead of creating a duplicate. + * A remote link from the ticket to the PR is always created (idempotent via PR URL). + * @param {string} filename + * @param {{ api: object, prUrl?: string, prTitle?: string }} opts + * @returns {Promise} resolved ticket key, e.g. "PDCL-1234" + */ +export const applyFile = async ( + filename, + { api, prUrl = "", prTitle = "" }, +) => { + const { project, ticketKey: parsedKey, globalId } = parseFilename(filename); + let ticketKey = parsedKey; + + const parsed = yamlLoad(readFileSync(filename, "utf8")) ?? {}; + const updates = Array.isArray(parsed.updates) ? parsed.updates : []; + + const vars = { GITHUB_PR_URL: prUrl, GITHUB_PR_TITLE: prTitle }; + + // For new tickets: check if already created via label (idempotency). + if (globalId && !ticketKey) { + const issues = await api.searchIssues( + `project = ${project} AND labels = "${globalId}"`, + ); + if (issues.length > 0) { + ticketKey = issues[0].key; + console.log(`Found existing ticket ${ticketKey} via label ${globalId}`); + } + } + + for (const update of updates) { + const isCreate = + update.method === "POST" && String(update.path) === "/rest/api/2/issue"; + + if (isCreate) { + if (!ticketKey) { + // eslint-disable-next-line no-await-in-loop + const data = await api.request( + "POST", + "/rest/api/2/issue", + interpolate(update.body, vars), + ); + ticketKey = data.key ?? `${project}-UNKNOWN`; + } + // Skip if ticket already exists (idempotent). + continue; + } + + // All other updates run only when we have a ticket key. + if (!ticketKey) continue; + const path = String(update.path).replace(/{key}/g, ticketKey); + // eslint-disable-next-line no-await-in-loop + await api.request(update.method, path, interpolate(update.body, vars)); + } + + // Auto-create a remote link to the PR for every processed ticket (idempotent via PR URL). + if (ticketKey && prUrl) { + await api.request("POST", `/rest/api/2/issue/${ticketKey}/remotelink`, { + globalId: prUrl, + relationship: "mentioned in", + object: { url: prUrl, title: prTitle || prUrl }, + }); + } + + return ticketKey; +}; + +// Script entry point — only executes when run directly. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const rawArgs = process.argv.slice(2); + const dryRun = rawArgs.includes("--dry-run"); + const args = rawArgs.filter((a) => a !== "--dry-run"); + + if (args.length < 1) { + console.error("Usage: apply.js [--dry-run] "); + process.exit(1); + } + + const [filename] = args; + + const parsed = (() => { + try { + return yamlLoad(readFileSync(filename, "utf8")) ?? {}; + } catch { + return {}; + } + })(); + const hasUpdates = Array.isArray(parsed.updates) && parsed.updates.length > 0; + + if (hasUpdates) { + if (!JIRA_API_TOKEN) { + console.error("JIRA_API_TOKEN is required"); + process.exit(1); + } + if (!dryRun) { + if (!process.env.GITHUB_PR_URL) { + console.error("GITHUB_PR_URL is required when updates are present"); + process.exit(1); + } + if (!process.env.GITHUB_PR_TITLE) { + console.error("GITHUB_PR_TITLE is required when updates are present"); + process.exit(1); + } + } + } + + const api = createApi({ + dryRun, + baseUrl: JIRA_BASE_URL.replace(/\/$/, ""), + token: JIRA_API_TOKEN ?? "", + }); + + applyFile(filename, { + api, + prUrl: process.env.GITHUB_PR_URL ?? "", + prTitle: process.env.GITHUB_PR_TITLE ?? "", + }) + .then((key) => console.log(key)) + .catch((e) => { + console.error(e.message); + process.exit(1); + }); +} diff --git a/scripts/jira/apply.spec.js b/scripts/jira/apply.spec.js new file mode 100644 index 000000000..0cf7ccc82 --- /dev/null +++ b/scripts/jira/apply.spec.js @@ -0,0 +1,243 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { describe, it, expect, vi } from "vitest"; +import { writeFileSync, unlinkSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { applyFile } from "./apply.js"; + +// Unique prefix per spec file avoids temp-file collisions when tests run in parallel. +const uid = () => `${Date.now()}-${Math.random().toString(36).slice(2)}`; + +// Existing ticket file: PDCL-1234-...yml +const tempFile = (content) => { + const path = join(tmpdir(), `PDCL-1234-apply-spec-${uid()}.yml`); + writeFileSync(path, content, "utf8"); + return path; +}; + +// New ticket file: PDCL-{globalId}-...yml +const globalIdFile = (globalId, content) => { + const path = join(tmpdir(), `PDCL-${globalId}-apply-spec-${uid()}.yml`); + writeFileSync(path, content, "utf8"); + return path; +}; + +const mockApi = (overrides = {}) => ({ + dryRun: false, + request: vi.fn(async (method) => + method === "POST" ? { key: "PDCL-9999" } : {}, + ), + searchIssues: vi.fn(async () => []), + ...overrides, +}); + +describe("applyFile — existing ticket", () => { + it("returns the ticket key from the filename", async () => { + const file = tempFile(` +updates: + - path: /rest/api/2/issue/PDCL-1234 + method: PUT + body: + update: + summary: + - set: "New title" +`); + const api = mockApi(); + const key = await applyFile(file, { api }); + expect(key).toBe("PDCL-1234"); + unlinkSync(file); + }); + + it("calls JIRA with interpolated PR URL and title", async () => { + const file = tempFile(` +updates: + - path: /rest/api/2/issue/PDCL-1234 + method: PUT + body: + update: + summary: + - set: "{GITHUB_PR_TITLE}" +`); + const api = mockApi(); + await applyFile(file, { + api, + prUrl: "https://github.com/adobe/alloy/pull/99", + prTitle: "My PR", + }); + expect(api.request).toHaveBeenCalledWith( + "PUT", + "/rest/api/2/issue/PDCL-1234", + expect.objectContaining({ + update: expect.objectContaining({ + summary: [{ set: "My PR" }], + }), + }), + ); + unlinkSync(file); + }); + + it("returns key immediately when there are no updates (no API calls except remote link)", async () => { + const file = tempFile(`details:\n key: PDCL-1234\n`); + const api = mockApi(); + const key = await applyFile(file, { api }); + expect(key).toBe("PDCL-1234"); + // No updates in file, so no non-remotelink request should be made + expect(api.request).not.toHaveBeenCalled(); + unlinkSync(file); + }); + + it("auto-creates a remote link when prUrl is provided", async () => { + const file = tempFile(` +updates: + - path: /rest/api/2/issue/PDCL-1234 + method: PUT + body: + update: + summary: + - set: "Updated" +`); + const api = mockApi(); + await applyFile(file, { + api, + prUrl: "https://github.com/adobe/alloy/pull/99", + prTitle: "My PR", + }); + expect(api.request).toHaveBeenCalledWith( + "POST", + "/rest/api/2/issue/PDCL-1234/remotelink", + expect.objectContaining({ + globalId: "https://github.com/adobe/alloy/pull/99", + object: expect.objectContaining({ + url: "https://github.com/adobe/alloy/pull/99", + title: "My PR", + }), + }), + ); + unlinkSync(file); + }); +}); + +describe("applyFile — new ticket (globalId in filename)", () => { + it("creates ticket and returns real key when no existing ticket found", async () => { + const gid = "abc12345"; + const file = globalIdFile( + gid, + ` +updates: + - path: /rest/api/2/issue + method: POST + body: + fields: + project: { key: PDCL } + summary: New feature + labels: + - ${gid} +`, + ); + const api = mockApi(); + const key = await applyFile(file, { + api, + prUrl: "https://github.com/adobe/alloy/pull/1", + prTitle: "My PR", + }); + expect(key).toBe("PDCL-9999"); + // Label search found nothing → create called + expect(api.request).toHaveBeenCalledWith( + "POST", + "/rest/api/2/issue", + expect.any(Object), + ); + // Auto remote link created + expect(api.request).toHaveBeenCalledWith( + "POST", + "/rest/api/2/issue/PDCL-9999/remotelink", + expect.objectContaining({ + globalId: "https://github.com/adobe/alloy/pull/1", + }), + ); + unlinkSync(file); + }); + + it("finds existing ticket via label and skips create", async () => { + const gid = "existing99"; + const file = globalIdFile( + gid, + ` +updates: + - path: /rest/api/2/issue + method: POST + body: + fields: + project: { key: PDCL } + summary: New feature + labels: + - ${gid} + - path: /rest/api/2/issue/{key} + method: PUT + body: + update: + summary: + - set: "Updated" +`, + ); + const api = mockApi({ + searchIssues: vi.fn(async () => [{ key: "PDCL-5678" }]), + }); + const key = await applyFile(file, { + api, + prUrl: "https://github.com/adobe/alloy/pull/1", + prTitle: "My PR", + }); + expect(key).toBe("PDCL-5678"); + // Should NOT have called create + expect(api.request).not.toHaveBeenCalledWith( + "POST", + "/rest/api/2/issue", + expect.any(Object), + ); + // Should have applied the PUT with resolved key + expect(api.request).toHaveBeenCalledWith( + "PUT", + "/rest/api/2/issue/PDCL-5678", + expect.any(Object), + ); + unlinkSync(file); + }); + + it("skips non-create updates when no ticket key available", async () => { + const gid = "noop1234"; + // Only has a PUT, no POST create — no ticket key can be resolved + const file = globalIdFile( + gid, + ` +updates: + - path: /rest/api/2/issue/{key} + method: PUT + body: + update: + summary: + - set: "Should not run" +`, + ); + const api = mockApi(); + // No searchIssues result, no create → ticketKey stays null → PUT is skipped + await applyFile(file, { api }); + expect(api.request).not.toHaveBeenCalledWith( + "PUT", + expect.any(String), + expect.any(Object), + ); + unlinkSync(file); + }); +}); diff --git a/scripts/jira/config.js b/scripts/jira/config.js new file mode 100644 index 000000000..face9a87c --- /dev/null +++ b/scripts/jira/config.js @@ -0,0 +1,16 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +export const JIRA_BASE_URL = + process.env.JIRA_BASE_URL ?? "https://jira.corp.adobe.com"; + +export const JIRA_API_TOKEN = process.env.JIRA_API_TOKEN ?? null; diff --git a/scripts/jira/fetch.js b/scripts/jira/fetch.js new file mode 100644 index 000000000..dee61c88b --- /dev/null +++ b/scripts/jira/fetch.js @@ -0,0 +1,123 @@ +#!/usr/bin/env node +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { writeFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { dump as yamlDump } from "js-yaml"; +import createApi from "./api.js"; +import { JIRA_BASE_URL, JIRA_API_TOKEN } from "./config.js"; + +const MAX_STRING_LENGTH = 500; + +const truncate = (value) => { + if (typeof value === "string" && value.length > MAX_STRING_LENGTH) { + return value.slice(0, MAX_STRING_LENGTH) + "..."; + } + return value; +}; + +const isNonEmpty = (value) => { + if (value === null || value === undefined) return false; + if (Array.isArray(value) && value.length === 0) return false; + return true; +}; + +const extractFields = (fields) => { + const result = {}; + for (const [key, value] of Object.entries(fields)) { + if (!isNonEmpty(value)) continue; + if (typeof value === "string") { + result[key] = truncate(value); + } else if (Array.isArray(value)) { + const filtered = value + .map((item) => + typeof item === "object" && item !== null + ? extractFields(item) + : item, + ) + .filter((item) => + typeof item === "object" + ? Object.keys(item).length > 0 + : isNonEmpty(item), + ); + if (filtered.length > 0) result[key] = filtered; + } else if (typeof value === "object") { + const nested = extractFields(value); + if (Object.keys(nested).length > 0) result[key] = nested; + } else { + result[key] = value; + } + } + return result; +}; + +const buildYaml = (details, timestamp) => + `# fetched from JIRA ${timestamp}\n${yamlDump({ details }, { lineWidth: 120, noRefs: true })}`; + +/** + * Fetch live JIRA state for a ticket and write it to a file. + * @param {string} ticketKey e.g. "PDCL-1234" + * @param {string} filename target file path + * @param {{ api: object }} opts + */ +export const fetchFile = async (ticketKey, filename, { api }) => { + const data = await api.request( + "GET", + `/rest/api/2/issue/${encodeURIComponent(ticketKey)}`, + ); + const details = { key: data.key, ...extractFields(data.fields ?? {}) }; + const timestamp = new Date().toISOString(); + const content = buildYaml(details, timestamp); + + if (api.dryRun) { + console.log(`[dry-run] Would write to ${filename}:\n${content}`); + return; + } + + writeFileSync(filename, content, "utf8"); + console.log(`Wrote ${filename}`); +}; + +// Script entry point — only executes when run directly. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const rawArgs = process.argv.slice(2); + const dryRun = rawArgs.includes("--dry-run"); + const args = rawArgs.filter((a) => a !== "--dry-run"); + + if (args.length < 2) { + console.error("Usage: fetch.js [--dry-run] "); + console.error(" JIRA issue key, e.g. PDCL-1234"); + console.error( + " Path to write, e.g. .jira/PDCL-1234-my-feature.yml", + ); + process.exit(1); + } + + const [ticketKey, filename] = args; + + if (!JIRA_API_TOKEN) { + console.error("JIRA_API_TOKEN is required"); + process.exit(1); + } + + const api = createApi({ + dryRun, + baseUrl: JIRA_BASE_URL.replace(/\/$/, ""), + token: JIRA_API_TOKEN ?? "", + }); + + fetchFile(ticketKey, filename, { api }).catch((e) => { + console.error(e.message); + process.exit(1); + }); +} diff --git a/scripts/jira/fetch.spec.js b/scripts/jira/fetch.spec.js new file mode 100644 index 000000000..82eb6268a --- /dev/null +++ b/scripts/jira/fetch.spec.js @@ -0,0 +1,128 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { describe, it, expect, vi } from "vitest"; +import { writeFileSync, readFileSync, unlinkSync, existsSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { load as yamlLoad } from "js-yaml"; +import { fetchFile } from "./fetch.js"; + +const mockApi = (issueData = {}) => ({ + dryRun: false, + request: vi.fn(async () => ({ + key: "PDCL-1234", + fields: { + summary: "My feature", + status: { name: "In Progress" }, + assignee: null, + description: null, + components: [], + ...issueData, + }, + })), + searchIssues: vi.fn(async () => []), + getRemoteLinks: vi.fn(async () => []), +}); + +// Unique prefix per spec file avoids temp-file collisions when tests run in parallel. +const tempPath = (label) => + join( + tmpdir(), + `fetch-spec-${label}-${Date.now()}-${Math.random().toString(36).slice(2)}.yml`, + ); + +describe("fetchFile", () => { + it("writes details section to a new file", async () => { + const filename = tempPath("pdcl-1234"); + const api = mockApi({ summary: "Hello world" }); + + await fetchFile("PDCL-1234", filename, { api }); + + const content = readFileSync(filename, "utf8"); + const parsed = yamlLoad(content.replace(/^#.*\n/, "")); + expect(parsed.details.key).toBe("PDCL-1234"); + expect(parsed.details.summary).toBe("Hello world"); + expect(parsed.details.assignee).toBeUndefined(); + expect(parsed.details.components).toBeUndefined(); + unlinkSync(filename); + }); + + it("omits null and empty-array fields from details", async () => { + const filename = tempPath("pdcl-1234"); + const api = mockApi({ description: null, components: [] }); + + await fetchFile("PDCL-1234", filename, { api }); + + const content = readFileSync(filename, "utf8"); + const parsed = yamlLoad(content.replace(/^#.*\n/, "")); + expect(parsed.details.description).toBeUndefined(); + expect(parsed.details.components).toBeUndefined(); + unlinkSync(filename); + }); + + it("truncates long string fields to 500 chars", async () => { + const filename = tempPath("pdcl-1234"); + const longDesc = "x".repeat(600); + const api = mockApi({ description: longDesc }); + + await fetchFile("PDCL-1234", filename, { api }); + + const content = readFileSync(filename, "utf8"); + const parsed = yamlLoad(content.replace(/^#.*\n/, "")); + expect(parsed.details.description).toHaveLength(503); // 500 + "..." + expect(parsed.details.description.endsWith("...")).toBe(true); + unlinkSync(filename); + }); + + it("overwrites existing file with only details, no updates", async () => { + const filename = tempPath("pdcl-1234"); + writeFileSync( + filename, + `updates:\n - path: /rest/api/2/issue/PDCL-1234\n method: PUT\n body: {}\n`, + "utf8", + ); + const api = mockApi({ summary: "Updated" }); + + await fetchFile("PDCL-1234", filename, { api }); + + const content = readFileSync(filename, "utf8"); + const parsed = yamlLoad(content.replace(/^#.*\n/, "")); + expect(parsed.details.summary).toBe("Updated"); + expect(parsed.updates).toBeUndefined(); + unlinkSync(filename); + }); + + it("includes a fetched-from-JIRA comment on the first line", async () => { + const filename = tempPath("pdcl-1234"); + const api = mockApi(); + + await fetchFile("PDCL-1234", filename, { api }); + + const content = readFileSync(filename, "utf8"); + expect(content.startsWith("# fetched from JIRA ")).toBe(true); + unlinkSync(filename); + }); + + it("fetches from JIRA but outputs to stdout instead of writing in dry-run mode", async () => { + const filename = tempPath("pdcl-1234"); + const api = { ...mockApi(), dryRun: true }; + + await fetchFile("PDCL-1234", filename, { api }); + + expect(existsSync(filename)).toBe(false); + expect(api.request).toHaveBeenCalledWith( + "GET", + expect.stringContaining("PDCL-1234"), + ); + }); +}); diff --git a/scripts/jira/process.js b/scripts/jira/process.js new file mode 100644 index 000000000..3008bb3fb --- /dev/null +++ b/scripts/jira/process.js @@ -0,0 +1,116 @@ +#!/usr/bin/env node +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { readFileSync, unlinkSync, existsSync } from "fs"; +import { basename, dirname } from "path"; +import { fileURLToPath } from "url"; +import { load as yamlLoad } from "js-yaml"; +import createApi from "./api.js"; +import { applyFile } from "./apply.js"; +import { fetchFile } from "./fetch.js"; +import { JIRA_BASE_URL, JIRA_API_TOKEN } from "./config.js"; + +/** + * Process a single .jira/*.yml file: apply updates, refresh details via fetch. + * For new-ticket files (non-numeric key part), deletes the placeholder and creates + * a real-key file (e.g. PDCL-a3f8b2c1-title.yml → PDCL-1234-title.yml). + * @param {string} filename + * @param {{ api: object, prUrl?: string, prTitle?: string }} opts + * @returns {Promise} ticket key if processed, null if skipped + */ +export const processFile = async ( + filename, + { api, prUrl = "", prTitle = "" }, +) => { + if (!existsSync(filename)) { + console.log(`Skipping ${filename} (file not found)`); + return null; + } + + const parsed = yamlLoad(readFileSync(filename, "utf8")) ?? {}; + const hasUpdates = Array.isArray(parsed.updates) && parsed.updates.length > 0; + + if (!hasUpdates) { + console.log(`Skipping ${filename} (no updates)`); + return null; + } + + const ticketKey = await applyFile(filename, { api, prUrl, prTitle }); + console.log(`Applied: ${ticketKey}`); + + // Derive new filename: for new tickets (non-numeric key part), replace globalId with ticket number. + const dir = dirname(filename); + const base = basename(filename); + const keyMatch = base.match(/^[A-Z]+-([a-zA-Z0-9]+)/); + const keyPart = keyMatch?.[1] ?? ""; + const isNewTicket = !/^\d+$/.test(keyPart); + const ticketNum = ticketKey.split("-").pop(); + const newBase = isNewTicket ? base.replace(keyPart, ticketNum) : base; + const newFilename = `${dir}/${newBase}`; + + if (filename !== newFilename && !api.dryRun) { + unlinkSync(filename); + } + + await fetchFile(ticketKey, newFilename, { api }); + + return ticketKey; +}; + +// Script entry point — only executes when run directly. +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const rawArgs = process.argv.slice(2); + const dryRun = rawArgs.includes("--dry-run"); + const args = rawArgs.filter((a) => a !== "--dry-run"); + + if (args.length < 1) { + console.error("Usage: process.js [--dry-run] "); + process.exit(1); + } + + const [filename] = args; + + if (!JIRA_API_TOKEN) { + console.error("JIRA_API_TOKEN is required"); + process.exit(1); + } + if (!dryRun) { + if (!process.env.GITHUB_PR_URL) { + console.error("GITHUB_PR_URL is required"); + process.exit(1); + } + if (!process.env.GITHUB_PR_TITLE) { + console.error("GITHUB_PR_TITLE is required"); + process.exit(1); + } + } + + const api = createApi({ + dryRun, + baseUrl: JIRA_BASE_URL.replace(/\/$/, ""), + token: JIRA_API_TOKEN ?? "", + }); + + processFile(filename, { + api, + prUrl: process.env.GITHUB_PR_URL ?? "", + prTitle: process.env.GITHUB_PR_TITLE ?? "", + }) + .then((key) => { + if (key) console.log(key); + }) + .catch((e) => { + console.error(e.message); + process.exit(1); + }); +} diff --git a/scripts/jira/process.spec.js b/scripts/jira/process.spec.js new file mode 100644 index 000000000..02c179855 --- /dev/null +++ b/scripts/jira/process.spec.js @@ -0,0 +1,116 @@ +/* +Copyright 2025 Adobe. All rights reserved. +This file is licensed to you under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. You may obtain a copy +of the License at http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed under +the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS +OF ANY KIND, either express or implied. See the License for the specific language +governing permissions and limitations under the License. +*/ + +import { describe, it, expect, vi } from "vitest"; +import { writeFileSync, existsSync, unlinkSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { processFile } from "./process.js"; + +const mockApi = (overrides = {}) => ({ + dryRun: false, + request: vi.fn(async (method) => + method === "POST" + ? { key: "PDCL-9999" } + : { key: "PDCL-1234", fields: { summary: "Test" } }, + ), + searchIssues: vi.fn(async () => []), + ...overrides, +}); + +// jiraKey must be at the start of the basename for the filename regex to match. +// Unique suffix per spec file prevents collisions when tests run in parallel. +const writeTemp = (jiraKey, content) => { + const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const path = join(tmpdir(), `${jiraKey}-proc-spec-${uid}.yml`); + writeFileSync(path, content, "utf8"); + return path; +}; + +describe("processFile", () => { + it("returns null and skips when file has no updates", async () => { + const file = writeTemp("PDCL-1234", `details:\n key: PDCL-1234\n`); + const api = mockApi(); + const result = await processFile(file, { api }); + expect(result).toBeNull(); + expect(api.request).not.toHaveBeenCalled(); + unlinkSync(file); + }); + + it("returns null when file does not exist", async () => { + const api = mockApi(); + const result = await processFile("/tmp/PDCL-1234-nonexistent.yml", { api }); + expect(result).toBeNull(); + }); + + it("applies updates and returns ticket key for existing ticket", async () => { + const file = writeTemp( + "PDCL-1234", + ` +updates: + - path: /rest/api/2/issue/PDCL-1234 + method: PUT + body: + update: + summary: + - set: "Updated" +`, + ); + const api = mockApi(); + const result = await processFile(file, { + api, + prUrl: "https://github.com/adobe/alloy/pull/1", + prTitle: "PR", + }); + expect(result).toBe("PDCL-1234"); + expect(api.request).toHaveBeenCalled(); + expect(api.request).toHaveBeenCalledWith( + "GET", + expect.stringContaining("PDCL-1234"), + ); + if (existsSync(file)) unlinkSync(file); + }); + + it("deletes globalId file and creates real-key file", async () => { + const gid = "abc12345"; + const uid = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + const globalIdPath = join(tmpdir(), `PDCL-${gid}-proc-spec-${uid}.yml`); + writeFileSync( + globalIdPath, + ` +updates: + - path: /rest/api/2/issue + method: POST + body: + fields: + project: { key: PDCL } + summary: New feat + labels: + - ${gid} +`, + "utf8", + ); + + const api = mockApi(); + const result = await processFile(globalIdPath, { + api, + prUrl: "https://github.com/adobe/alloy/pull/2", + prTitle: "PR", + }); + + expect(result).toBe("PDCL-9999"); + expect(existsSync(globalIdPath)).toBe(false); + const realPath = globalIdPath.replace(gid, "9999"); + expect(existsSync(realPath)).toBe(true); + if (existsSync(realPath)) unlinkSync(realPath); + }); +});