# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Package Overview

Jira CLI Lambda that queries offboarding tickets, extracts structured user data (email, full name, last working day) from ADF ticket descriptions, and writes feedback back to tickets (comments, due dates, labels).

## Running tests

```bash
cd lambda/jira_cli && uv run pytest ./tests/                                              # All tests
cd lambda/jira_cli && uv run pytest ./tests/unit/                                         # Unit tests only
cd lambda/jira_cli && uv run pytest ./tests/integration/                                  # Integration tests only
cd lambda/jira_cli && uv run pytest ./tests/unit/test_jira_client.py                      # Single file
cd lambda/jira_cli && uv run pytest ./tests/unit/test_jira_client.py::test_query_jira_tickets_success # Single test
```

## CLI usage

```bash
cd lambda/jira_cli && uv run jira-cli query-offboarding-tickets
cd lambda/jira_cli && uv run jira-cli close-ticket --ticket-id SYS-1234 [--target-status Closed] [--dry-run]
```

Requires env vars: `JIRA_BASE_URL`, `JIRA_USER_EMAIL`, `JIRA_API_TOKEN` (token value locally; Secrets Manager secret name in Lambda).

`close-ticket` is the only write action exposed via the CLI (`dev.py`) today — `add-comment`/`add-due-date`/`add-label` remain Lambda-only.

## Lambda actions

The handler in `jira_client/app.py` dispatches on `event["action"]`:

| Action | Description | Write? |
|---|---|---|
| `query-offboarding-tickets` | Fetch and parse new open offboarding tickets | No |
| `query-approved-tickets` | Fetch offboarding tickets labelled `approved-for-offboarding` | No |
| `query-suspend-tickets` | Fetch and parse new open suspend tickets | No |
| `query-approved-suspend-tickets` | Fetch suspend tickets labelled `approved-for-suspension` | No |
| `query-completed-tickets` | Fetch tickets in a terminal automation state, not yet `Closed` (key only) | No |
| `validate-ticket` | Check reporter allowlist + footer sentinel | No |
| `add-comment` | Post a plain-text ADF comment on a ticket | Yes |
| `add-due-date` | Set the `duedate` field on a ticket | Yes |
| `add-label` | Append a label without overwriting existing labels | Yes |
| `close-ticket` | Transition a ticket to its closed/done status | Yes |

### Ticket types (registry)
Both ticket flavours are driven by a `TicketType` config registry in
`jira_client/ticket_types.py` (`OFFBOARDING`, `SUSPENSION`). Each entry bundles the
JQL queries, the sentinel/full-name regexes, the date extractor, the downstream
Auth0 operation (`delete` vs `suspend`), the GitHub issue `operation` wording, and
the Jira `LabelSet`. The four query actions all route through one parameterized
`_handle_query_tickets(evt, ticket_type, jql, response_cls)`. To add a future ticket
flavour, add one registry entry plus its constants — no new handler. Suspend tickets
parse the name from `submitted for: <name> User ID:` and the effective date from
`Suspension Start Date:` (stored in the shared `Ticket.last_working_day` field).

All write actions accept `dry_run: true` to skip the API call and return early.

## Key Architecture

### JQL queries
Named queries are defined as class attributes on `JQLQueries` in `constants.py`. The new-ticket queries (e.g. `NEW_OPEN_OFFBOARDING_TICKETS`) match unassigned tickets created in the last 14 days with `labels = empty`. Because the JQL filters `labels = empty`, applying any label via `add-label` acts as an idempotency guard — the ticket is excluded from all future new/approved runs.

`COMPLETED_TICKETS` (used by `query-completed-tickets`) is the exception: it matches `project = SYS`, `status != "Closed"`, and any terminal automation label — `automation-complete` / `suspension-complete` (work performed) or `no-actions-required` / `suspend-no-actions-required` (nothing to do). Its handler `_handle_query_completed_tickets` deliberately skips description parsing and returns only the ticket key, so a completed ticket whose description has since drifted is still returned (and therefore still closeable) rather than being soft-skipped. The offboarding close state machine (terraform) calls this hourly and closes whatever it returns, so labelled-complete tickets close on a later iteration.

### Label constants
`JiraLabels` in `constants.py` defines the approved label strings:
- `automation-complete` — all offboarding steps ran successfully
- `no-actions-required` — user not found in any system

### Description parsing
Ticket descriptions come back as Atlassian Document Format (ADF) JSON. The Lambda traverses `ticket["fields"]["description"]["content"]` to find paragraphs containing the sentinel string `"An offboarding request has been submitted"`, then applies:
- `TextUtils.clean_text()` — normalizes the raw ADF text (fixes missing spaces after punctuation, collapses whitespace)
- `SearchUtils.find_all_emails()` — extracts email via `RegexPatterns.EMAIL_RE`
- `SearchUtils.find_full_name()` — extracts name from "request has been submitted for **{name}**." pattern
- `SearchUtils.find_last_working_day()` — extracts ISO date from "Last day of Employment: YYYY-MM-DD"

### Jira API client (`jira_client/jira_client.py`)
Uses HTTP Basic Auth with `jira_user_email` and `jira_api_token`. A shared `_raise_on_auth_error()` helper raises descriptive `HTTPError` messages for 401/403 before the generic `raise_for_status()`. The session retries on 429/5xx with exponential backoff.

| Method | HTTP | Endpoint |
|---|---|---|
| `query_jira_tickets` | GET | `/rest/api/3/search/jql` |
| `add_comment` | POST | `/rest/api/3/issue/{key}/comment` |
| `add_due_date` | PUT | `/rest/api/3/issue/{key}` |
| `add_label` | PUT | `/rest/api/3/issue/{key}` |
| `get_transitions` | GET | `/rest/api/3/issue/{key}/transitions` |
| `_get_current_status` | GET | `/rest/api/3/issue/{key}?fields=status` |
| `close_ticket` | POST | `/rest/api/3/issue/{key}/transitions` |

### Closing tickets
`close_ticket(ticket_id, target_status)` doesn't PUT a status field directly — Jira issue status changes go through workflow transitions. It calls `get_transitions()` to list the ticket's available transitions, matches the one whose destination status name case-insensitively equals `target_status` (default `'Closed'`, confirmed against the `SYS` project workflow), and POSTs that transition's `id`. Transition IDs are workflow/project-specific, so they're resolved by status name rather than hardcoded.

If no transition leads to `target_status`, it reads the ticket's current status (`_get_current_status`): when the ticket is **already** at `target_status`, the call is a no-op success (nothing to do); otherwise a `ValueError` is raised listing the current status and the available destination statuses. The no-op path keeps the hourly close machine from posting spurious error comments on tickets that were closed manually or by a prior run between the query and the close.
