# AI Agent Instructions: `agentic_devtools/adapters/`

> Parent Instruction Chain
>
> 1. [agentic-devtools package instructions](../../.github/copilot-instructions.md)
>
> [`copilot-instructions.md` Precedence Model](../../docs/copilot-instructions-precedence-model.md)

## Scope

These instructions apply to all code under `agentic_devtools/adapters/`, including the
provider implementations (`github_provider.py`, `jira_provider.py`), the legacy adapters
(`github_adapter.py`, `jira_adapter.py`), the factory (`factory.py`), retry infrastructure
(`retry.py`), and supporting utilities.

## Provider and Factory Quality Requirements

### Fail-Fast Validation at Construction Time

Provider classes and the factory must validate required configuration values at the point of
construction, not on first use. Silently accepting empty or invalid configuration leads to
confusing runtime failures far from the source:

```python
# Wrong — empty owner_repo accepted; methods later fail with /repos//issues
class GitHubProvider:
    def __init__(self, owner_repo: str) -> None:
        self._owner_repo = owner_repo


# Correct — validate at construction, raise immediately
class GitHubProvider:
    def __init__(self, owner_repo: str) -> None:
        if not owner_repo or "/" not in owner_repo:
            raise ValueError(f"owner_repo must be in 'owner/repo' form, got: {owner_repo!r}")
        self._owner_repo = owner_repo
```

The factory (`factory.py`) must raise `ConfigError` before instantiating any provider when
required fields (e.g., `owner_repo`, `project_key`, `base_url`) are missing or empty. Strip
whitespace before the emptiness check, but pass the stripped value to the provider:

```python
project_key = config.get("platform.jira.project_key", "").strip()
if not project_key:
    raise ConfigError("platform.jira.project_key is required but missing or empty")
return JiraProvider(project_key=project_key, ...)
```

*Rationale: based on audit findings in PRs #2777 (8+ observations covering factory.py,
github_provider.py, and jira_provider.py).*

### Validate Numeric Parameters (Non-Negative Constraints)

Functions that accept retry counts, delays, or timeout values must validate that those
values are non-negative before use. Negative values can produce zero-iteration loops that
then attempt `raise last_exc` when `last_exc is None`, causing a `TypeError` that is harder
to diagnose than the original configuration error:

```python
# Wrong — max_retries=-1 causes loop to never execute, then raises TypeError
def retry_on_transient(max_retries: int = 3, initial_delay: float = 1.0) -> ...: ...


# Correct — validate up front
def retry_on_transient(max_retries: int = 3, initial_delay: float = 1.0) -> ...:
    if max_retries < 0:
        raise ValueError(f"max_retries must be >= 0, got {max_retries}")
    if initial_delay < 0:
        raise ValueError(f"initial_delay must be >= 0, got {initial_delay}")
    ...
```

*Rationale: based on audit findings in PRs #2777, #2801 (3+ observations).*

## Type Safety Requirements

### TypedDict str-field coercion from external API responses

> Based on audit of PRs #2838, #2820 (batch 1a7a4733)

When building typed data structures — both `TypedDict` instances (e.g., `IssueTypeInfo`,
`PropertySchema`) and dataclasses (e.g., `NormalizedIssue`) — from external API responses
(dict payloads from Jira, GitHub, etc.),
**always validate and coerce every field value to its declared Python type** before
assigning it to the target structure. Do not rely on `dict.get("key", "")` to produce a `str` — the
key may be present with a `None` value, which returns `None` regardless of the default.

1. **`str` fields**: use an explicit isinstance guard and fall back to `""` for any non-string
   value (including `None`, `int`, `list`, `dict`). Do not use `str()` to coerce unexpected
   payload types such as `dict`, `list`, or `None`; only use `str()` when a later rule
   explicitly allows selected scalar identifier types:

   ```python
   # ✅ CORRECT — empty string for any non-str value
   raw_name = item.get("name")
   name = raw_name if isinstance(raw_name, str) else ""

   # ❌ WRONG — dict.get("name", "") returns None when the key is present with value None
   name = item.get("name", "")

   # ❌ WRONG — passes non-string values (int, list, None) through unchecked
   name = item.get("name") or ""

   # ❌ WRONG — str(raw_name) turns dicts/lists into misleading repr strings
   name = raw_name if isinstance(raw_name, str) else ("" if raw_name is None else str(raw_name))
   ```

2. **list-of-str fields** (e.g. `labels: list[str]`): keep existing `str` values by default.
   If a field is an identifier list where numeric scalar IDs are explicitly allowed, you may
   convert whitelisted scalar numerics (`int`/`float`, not `bool`) to `str`. Never coerce
   `dict`, `list`, or `None` items:

   ```python
   # ✅ CORRECT — keeps only items that are already str
   raw_labels = item.get("labels")
   labels: list[str] = [v for v in raw_labels if isinstance(v, str)] if isinstance(raw_labels, list) else []

   # ✅ CORRECT — for identifier lists, allow scalar numerics (not bool) -> str
   raw_ids = item.get("allowedValues")
   allowed_ids: list[str] = (
       [
           v if isinstance(v, str) else str(v)
           for v in raw_ids
           if isinstance(v, str) or (isinstance(v, (int, float)) and not isinstance(v, bool))
       ]
       if isinstance(raw_ids, list)
       else []
   )

   # ❌ WRONG — preserves None/int/dict items in the list
   labels = raw_labels if isinstance(raw_labels, list) else []

   # ❌ WRONG — coerces every non-None item to str, including dicts and ints
   labels = [str(v) for v in raw_labels if v is not None] if isinstance(raw_labels, list) else []
   ```

3. **`or`-chain fallback on dict.get()**: avoid using `or` to chain multiple `.get()` calls
   when picking the first present value, because `or` treats any falsey value (e.g. `0`,
   `False`, `""`) as missing:

   ```python
   # ✅ CORRECT — str values returned as-is; safe scalar numerics (int/float, not bool)
   #              converted to str; all other types (dict, list, bool, None) are skipped
   def _first_present(d: dict, *keys: str, default: str = "") -> str:
       for k in keys:
           v = d.get(k)
           if isinstance(v, str):
               return v
           if isinstance(v, (int, float)) and not isinstance(v, bool):
               return str(v)
       return default


   label_value = _first_present(val, "name", "value", "id")

   # ❌ WRONG — `0` and `False` are silently skipped
   label_value = val.get("name") or val.get("value") or val.get("id", "")


   # ❌ WRONG — str(v) will stringify dicts and lists into misleading repr strings
   def _first_present_bad(d: dict, *keys: str, default: str = "") -> str:
       for k in keys:
           v = d.get(k)
           if v is not None:
               return str(v)  # converts {"id": 1} to "{'id': 1}"
       return default
   ```

4. **Optional `str` fields** (e.g. `description: str | None`): accept `None` explicitly
   only when the TypedDict declares the field as `Optional`; otherwise coerce:

   ```python
   # ✅ For str | None field — define the variable first, then check
   raw_desc = raw.get("description")
   description: str | None = raw_desc if isinstance(raw_desc, str) else None

   # ✅ For required str field with empty-string fallback
   raw_desc = raw.get("description")
   description: str = raw_desc if isinstance(raw_desc, str) else ""
   ```

## Error Handling Requirements

### Never Swallow `TransientError` with Bare `except Exception: pass`

The adapters use `@retry_on_transient` decorators. A bare `except Exception: pass` inside
a function decorated with `@retry_on_transient` silently swallows `TransientError` before it
can propagate to the retry loop, preventing retries on transient failures:

```python
# Wrong — TransientError raised by _request() is swallowed; retries never fire
try:
    result = self._request(...)
except Exception:
    pass

# Correct — catch only the specific exception you intend to handle
try:
    result = self._request(...)
except KeyError:
    result = None
```

When a broad catch is genuinely needed (e.g., during a best-effort lookup), explicitly
re-raise `TransientError`:

```python
from agentic_devtools.adapters.retry import TransientError

try:
    result = self._request(...)
except TransientError:
    raise  # let the retry decorator handle it
except Exception:
    result = None  # swallow only non-transient errors
```

*Rationale: based on audit findings in PRs #2777, #2801 (5+ observations).*

### Chain Exceptions When Translating SDK/API Errors

> Based on audit of PR #2820 (batch 1a7a4733)

When catching an SDK or `requests` exception and re-raising it as a higher-level error
(e.g. via a `_translate_sdk_error()` helper), **always chain the original exception** using
`raise ... from err`. This preserves the full traceback and cause, which is essential for
diagnosing unexpected SDK failures.

```python
# ✅ CORRECT — original traceback preserved
try:
    response = client.get_project_issueTypes(project_key)
except Exception as err:
    raise self._translate_sdk_error(err, f"get_issue_types for project {project_key}") from err

# ❌ WRONG — original traceback discarded
try:
    response = client.get_project_issueTypes(project_key)
except Exception as err:
    raise self._translate_sdk_error(err, f"get_issue_types for project {project_key}")
```

This rule applies to all `raise <translated_error>` patterns in adapter and SDK helper
code, including timeout, HTTP, and authentication error translations.

### Restrict HTTP Error Fallbacks to Expected Error Codes

Fallback logic that catches HTTP errors must be scoped to the specific status codes that
indicate the expected failure mode (e.g., 400 or 422 for validation errors). Catching
all HTTP errors (including 401/403) masks authentication/authorization failures and can
trigger unnecessary extra requests:

```python
# Wrong — any HTTP error triggers parent-field fallback, masking 401/403
try:
    self._update_epic_link(child_id, parent_id)
except HTTPError:
    self._set_parent_field(child_id, parent_id)

# Correct — fallback only for expected validation codes
try:
    self._update_epic_link(child_id, parent_id)
except HTTPError as exc:
    if exc.response.status_code not in (400, 422):
        raise
    self._set_parent_field(child_id, parent_id)
```

*Rationale: based on audit findings in PR #2777 (jira_provider.py link_subissue).*

### Transient Error Detection Must Use Explicit HTTP Code Patterns

Substring matching against raw error messages (e.g., `"429" in stderr`) is fragile: it can
misclassify unrelated errors whose text happens to contain those digits (e.g., "error code 1429").
Use an explicit `"HTTP <code>"` pattern or check the numeric status code directly:

```python
import re

# Wrong — matches "1429", "5029", or any string containing these digits
TRANSIENT_CODES = ("429", "502", "503")
if any(code in str(error_message) for code in TRANSIENT_CODES):
    raise TransientError(...)

# Correct — require the "HTTP <code>" prefix to avoid false positives
_HTTP_STATUS_RE = re.compile(r"\bHTTP (429|502|503)\b")
if _HTTP_STATUS_RE.search(str(error_message)):
    raise TransientError(...)
```

When checking `gh` CLI stderr directly, parse the JSON output and inspect the status code
numerically rather than performing substring matching on the raw string.

Apply the same pattern consistently wherever transient HTTP errors are detected (e.g.,
in `_exec_gh()`, `create_issue()`, `is_transient_error()`) — do not mix substring and
regex checks in different methods.

*Rationale: based on audit findings in PRs #2777, #2801 (5+ observations across
github_provider.py and retry.py).*

### All Network Calls Must Include a Timeout

HTTP requests without an explicit timeout can block indefinitely when a server stalls,
causing background tasks to hang and preventing graceful shutdown. Always pass a `timeout`
argument to `requests` calls and `subprocess`/`gh` CLI invocations:

```python
# Wrong — can block indefinitely
response = self._session.get(url)

# Correct — bound wait time explicitly
response = self._session.get(url, timeout=30)
```

Use the repository's established timeout constants or shared helpers where they exist
(e.g., `_SDK_TIMEOUT_SECONDS` in `agentic_devtools.cli.jira.sdk`) rather than inventing
new magic numbers.

*Rationale: based on audit findings in PR #2777 (jira_provider.py _request).*

### Preserve Original Tracebacks When Re-Raising After Retry Exhaustion

Using `raise last_exc` after a retry loop re-raises the exception with a new traceback
starting at the `raise` statement, losing the original call-site context. Use a bare
`raise` from within the `except` block, or capture the traceback and re-raise with
`last_exc.with_traceback(last_tb)` when re-raising outside the handler:

```python
# Wrong — loses original traceback
raise last_exc

# Correct — preserves original traceback for debugging
raise last_exc.with_traceback(last_tb)
```

*Rationale: based on audit findings in PR #2777 (retry.py).*

## Dry-Run Mode

In dry-run mode, validate **all** inputs that the real execution path validates (issue
types, required config, parameter ranges, identifier formats) — only skip the actual
side effects. A dry-run that returns `"success"` when the real run would raise
`IssueTypeMappingError` or `ValueError` defeats the purpose of dry-run previews and
can mislead callers.

```python
def create_issue(self, issue_type: str, ..., dry_run: bool = False) -> ...:
    # ✅ Validate issue_type BEFORE the dry_run guard
    normalized = self._normalize_issue_type(issue_type)  # raises IssueTypeMappingError if invalid

    if dry_run:
        return ProviderIssueResult(identifier="", url="", status="dry-run")

    # proceed with real call
    ...
```

*Rationale: based on audit findings in PR #2777 (4 observations about dry-run mode
returning "success" for inputs that would fail at runtime).*

## Docstring and Contract Accuracy Requirements

### Docstrings Must Match the Implementation Exactly

Docstrings that describe edge direction, parameter semantics, or module purpose must be
verified against the implementation before committing. A mismatch between a docstring and
the code misleads callers and can cause integration bugs that are hard to trace:

- `detect_cycles`: the docstring must use the same edge-direction convention as the code
  ("source must come before target" / "source is a prerequisite for target") — not the
  opposite.
- Module docstrings must not overstate behavior (e.g., claiming a function "builds a
  hierarchical JSON manifest" when it simply returns the provided lists unchanged).
- Protocol docstrings (`IssueProvider.create_issue`, etc.) must reflect the actual
  parameter contract — including whether `orchestration_key` is a separate parameter or
  must be embedded in `body`.

When the PR description and the docstring disagree with the implementation, update the
docstring and/or PR description to match the code. Open questions about the intended
behavior belong in an explicit note, not in contradictory documentation.

*Rationale: based on audit findings in PRs #2777, #2786 (6+ observations).*

### Keep Adapter Implementation Docstrings Accurate Across Review Iterations

> Based on audit of PRs #2838, #2820 (batch 1a7a4733)

Adapter methods frequently evolve through multiple review iterations. Keep docstrings
**accurate to the actual implementation**, not to the original design intention.

1. **`raw` dict contents**: if a method stores only a subset of the API response in the
   `raw` field (e.g. `IssueDetailWithRaw.raw`), the docstring must list the actual keys
   preserved, not claim the "full parsed response":

   ```python
   # ✅ CORRECT
   def get_issue(self, issue_id: str) -> IssueDetailWithRaw:
       """Fetch a GitHub issue via ``gh issue view``.

       Returns an :class:`IssueDetailWithRaw` whose ``raw`` dict contains
       only the keys ``createdAt``, ``updatedAt``, and ``assignees``
       from the parsed GitHub response.
       """


   # ❌ WRONG — misrepresents what raw contains
   def get_issue(self, issue_id: str) -> IssueDetailWithRaw:
       """Returns an IssueDetailWithRaw with the full parsed GitHub response."""
   ```

2. **Config/parameter usage**: when a function accepts an optional config/parameter object
   and only uses *some* of its fields, document exactly which fields are consumed:

   ```python
   # ✅ CORRECT
   def build_jira_client(config: JiraConfig | None = None):
       """Build a configured Jira SDK client.

       Args:
           config: When provided, only ``config.base_url``,
               ``config.ssl_verify``, and the ``Authorization`` value
               from ``config.headers`` are used. Other headers in
               ``config.headers`` are ignored.
       """
   ```

3. **`Raises:` sections**: list *all* exceptions that the function (or any function it
   calls) can propagate to the caller, including exceptions from helper functions:

   ```python
   # ✅ CORRECT
   def _ensure_sdk_client(self) -> object:
       """Lazily create and cache the Atlassian SDK client.

       Raises:
           ImportError: If ``atlassian-python-api`` is not installed.
           ValueError: If the configured base URL is empty (raised by
               :func:`build_jira_client`).
           OSError: If the Authorization header is missing or empty
               (raised by :func:`build_jira_client`).
       """


   # ❌ WRONG — incomplete Raises section
   def _ensure_sdk_client(self) -> object:
       """Lazily create and cache the Atlassian SDK client.

       Raises:
           ImportError: If ``atlassian-python-api`` is not installed.
       """
   ```

4. **Interface contract alignment**: when `PropertySchema`, `IssueTypeInfo`, or other
   provider-agnostic TypedDicts have a documented semantic for a field (e.g. `name` is a
   stable machine-readable identifier, not a display name), the implementation must honour
   that contract — or the type definition must be updated and the discrepancy documented
   explicitly:

   ```python
   # ✅ CORRECT — use the stable field key, not the locale-dependent display name
   name = field_key  # e.g. "summary", "priority", "customfield_10001"

   # ❌ WRONG — display name varies by locale and is not a stable identifier
   name = str(field_meta.get("name", field_key))
   ```

### GitHub GraphQL Responses Require Error-Array Checking

GitHub GraphQL endpoints return HTTP 200 even when the operation failed; the actual error
is reported inside a top-level `"errors"` array in the JSON body. Always check for this
array and treat its presence as a failure:

```python
import json

from agentic_devtools.cli.subprocess_utils import run_safe

result = run_safe(["gh", "api", "graphql", "-f", f"query={query}"], capture_output=True, text=True, shell=False)
if result.returncode != 0:
    raise RuntimeError(result.stderr)

data = json.loads(result.stdout)
if data.get("errors"):
    raise RuntimeError(f"GraphQL errors: {data['errors']}")
```

Returning `status="linked"` or similar success indicators based solely on the exit code is
incorrect for GraphQL calls.

*Rationale: based on audit findings in PR #2777 (github_provider.py add_blocked_by).*

## Type Coercion for External API Responses

When constructing TypedDicts or dataclasses (e.g., `IssueTypeInfo`, `PropertySchema`,
`NormalizedIssue`, `Comment`) from external API JSON responses (`gh api`, Jira REST API, etc.),
always coerce field values to the expected Python type. Never pass raw API values through
unvalidated — API responses can legitimately contain `None`, non-string, or other unexpected types
even for fields your TypedDict declares as `str` or `list[str]`.

**Rules:**

- **String fields declared as `str`**: Accept `str` only. Use `""` (or a sentinel like `"unknown"`)
  for `None`. For unexpected non-string, non-None values, default to `""` rather than coercing with
  `str()` — `str(value)` silently converts dicts and lists into meaningless strings and hides
  upstream contract violations.

  ```python
  # ✅ CORRECT — accept str only; default to "" for None or unexpected types
  name_raw = item.get("name")
  name = name_raw if isinstance(name_raw, str) else ""

  # ❌ WRONG — None propagates when the key exists with a None value
  name = item.get("name", "")

  # ❌ ALSO WRONG — str(name_raw) silently converts dicts/lists to misleading strings
  name = name_raw if isinstance(name_raw, str) else (str(name_raw) if name_raw is not None else "")
  ```

- **List fields declared as `list[str]`**: Filter the raw list to entries that are already `str`.
  Do not coerce non-string entries with `str()` — it converts dicts and objects into misleading
  string representations. Skip non-string entries instead:

  ```python
  # ✅ CORRECT — keep only actual str entries; skip dicts, None, and other non-string values
  labels_raw = issue_detail.get("labels")
  labels = [item for item in labels_raw if isinstance(item, str)] if isinstance(labels_raw, list) else []

  # ❌ WRONG — str(item) converts dict/object entries to misleading strings
  labels = [str(item) for item in labels_raw if item is not None] if isinstance(labels_raw, list) else []

  # ❌ ALSO WRONG — non-string entries (dicts, None) can remain in the list
  labels = labels_raw if isinstance(labels_raw, list) else []
  ```

- **Nested list-of-dict entries** (e.g., comments, assignees, options): Validate each entry is the
  expected type (`isinstance(entry, dict)`) before accessing keys, and coerce all string subfields.
  Skip or use a fallback for entries that do not match.

- **Enum-like string fields** (e.g., `type`, `status`): Default to a defined sentinel string (e.g.,
  `"string"`, `"unknown"`) when the API returns `None` or an unexpected type. Never allow `None` to
  propagate into a `str`-typed field.

- **After `strip()` validation, use the normalized value**: If you validate a string argument with
  `strip()` to check it is non-empty, use the stripped value for all subsequent operations
  (caching keys, comparisons, downstream calls). Do not validate with the stripped form and then
  use the original unstripped value — this creates silent cache misses and comparison failures.

*Rationale: based on audit findings in PRs #2838, #2820 (10+ observations across
`github_adapter.py` and `jira_adapter.py` of non-string or None values propagating into
TypedDict fields declared as `str` or `list[str]`).*

## Adapter Method Docstring Accuracy

Docstrings for adapter methods must accurately describe what the method *actually* does, not what
it ideally would do or what a future version might do. Before finalizing a method's docstring,
verify it against the implementation:

1. **`raw` / partial-response fields**: If the method retains only a subset of the API response in
   a `raw` dict, say so explicitly. List the preserved keys or describe the subset. Do not write
   "full parsed response" when only specific keys are carried forward.

2. **Config and auth parameter usage**: If a docstring describes which config fields are consumed
   (e.g., `config.headers`), the description must match the implementation exactly. If only
   `config.headers['Authorization']` is read, say that — not "uses `config.headers` directly".

3. **Field semantic contracts**: If `PropertySchema.name` is documented as a field identifier
   (e.g., `"summary"`, `"priority"`) but the implementation populates it with a display name (e.g.,
   `"Summary"`, `"Priority"`), that is a semantic inconsistency that must be resolved before the PR
   ships — either fix the implementation or fix the type contract documentation.

4. **`Raises` sections**: Document only the exception types that are intentionally part of the
   method's contract — those raised directly by the method or deliberately re-raised/wrapped from
   helpers it delegates to. Do not attempt to list every exception that could theoretically
   propagate from third-party library calls; that is neither achievable nor useful to callers.
   An inaccurate or bloated `Raises` section is worse than a concise, correct one — it misleads
   callers into writing error-handling code against a phantom contract.

*Rationale: based on audit findings in PRs #2838, #2820 (5+ observations of docstrings
inconsistent with implementation behavior in `github_adapter.py`, `jira_adapter.py`,
and `cli/jira/sdk.py`).*

## Type-Safe Extraction from External API Payloads

When mapping values from external API response dicts (GitHub JSON, Jira API responses, SDK
return values) into typed structures (TypedDicts, dataclasses, or function parameters),
**never rely solely on `.get()` defaults to protect against `None`**.

### The Problem

```python
# ❌ INCORRECT — protects against missing key, but NOT against key present with None value
name = item.get("name", "")  # name can still be None if API returns {"name": null}
description = item.get("description") or ""  # None-safe but also silently accepts truthy non-str (e.g., dict)
```

When an external API returns `{"name": null}`, `item.get("name", "")` returns `None`, not
`""`. Assigning `None` to a TypedDict field declared as `str` violates the type contract and
can cause downstream `AttributeError` (e.g., calling `.lower()` on `None`).

### The Correct Pattern

Always use an explicit `isinstance` check before assigning a value from an API payload to a
typed field:

```python
# ✅ CORRECT — handles missing key, null value, and wrong type
raw_name = item.get("name", "")
name = raw_name if isinstance(raw_name, str) else ""

raw_description = item.get("description")
description = raw_description if isinstance(raw_description, str) else ""
```

For provider-payload string fields that may be absent, null, or wrong-typed, but are written
into `NormalizedIssue` or similar contracts with fields declared as `str` (not `str | None`),
coerce `None` and other non-string values to `""`. This guidance applies to values being
written into typed fields; it does not prohibit converting a non-`None` value to a display
string after you have already decided that the fallback priority is correct.

### `schema.get()` Nested Fields

When reading a nested field (e.g., `schema.get("type")` from a field meta dict), the key may
exist but hold `None`, `""`, or another non-string value:

```python
# ❌ INCORRECT — .get("type", "string") returns None when key is present with null value
field_type = schema.get("type", "string") if isinstance(schema, dict) else "string"

# ✅ CORRECT
raw_field_type = schema.get("type") if isinstance(schema, dict) else None
field_type = raw_field_type if isinstance(raw_field_type, str) and raw_field_type else "string"
```

### Falsy-chain Pitfall for `allowedValues`

Avoid using `val.get("name") or val.get("value") or val.get("id", "")` to select a display
string from an API dict. This pattern treats valid falsy values (e.g., `0`, `False`) as
missing and can also *produce* `None` when a key is present-but-null (a subsequent `str()`
coercion then turns that `None` into the literal `"None"`). Instead, check each key
explicitly for `None`:

```python
# ❌ INCORRECT — falsy values treated as missing, may produce None
v = val.get("name") or val.get("value") or val.get("id", "")

# ✅ CORRECT — explicit None checks, stable priority, scalar-only display-string conversion
v = val.get("name")
if v is None:
    v = val.get("value")
if v is None:
    v = val.get("id")
if isinstance(v, str):
    result = v
elif isinstance(v, int) and not isinstance(v, bool):
    result = str(v)
else:
    result = ""
```

*Rationale: based on audit findings in PRs #2838 and #2820 (6+ observations where `.get()`
defaults failed to protect against `None` values in API responses, violating TypedDict
contracts and causing AttributeError in downstream code).*

## Exception Chaining When Translating SDK Errors

When catching an exception from an external SDK or library and raising a different exception
type (e.g., translating SDK errors into `RuntimeError`), always chain the original exception
using `raise NewException(...) from original_exception`:

```python
# ❌ INCORRECT — original traceback is lost
try:
    response = client.get_project_issueTypes(project_key)
except Exception as err:
    raise self._translate_sdk_error(err, f"get_issue_types for {project_key}")

# ✅ CORRECT — original traceback preserved for debugging
try:
    response = client.get_project_issueTypes(project_key)
except Exception as err:
    raise self._translate_sdk_error(err, f"get_issue_types for {project_key}") from err
```

Exception chaining preserves the full call chain in tracebacks, making unexpected SDK failures
significantly easier to diagnose without losing the user-friendly translated message.

*Rationale: based on audit findings in PR #2820 (3 occurrences of unchained exception
re-raise in jira_adapter.py).*
