Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions packages/python/ess-service-now-incident/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
# ess-service-now-incident

Fetch incident records from a [ServiceNow](https://www.servicenow.com/)
instance via an authenticated browser session.

The library wraps the ServiceNow Table API and runs the call from
inside a persistent Chrome profile (managed by `ess-browser`) so that
SSO cookies survive between runs. No API tokens or basic-auth
credentials are required -- the user authenticates once in the browser,
and subsequent runs reuse the same session.

## What you get

- `get_incident()` -- programmatic access to a single incident record.
- `parse_incident_input()` -- parse an incident number or URL into a
structured query.
- `build_cli()` -- factory that returns a `click.Command`, suitable
for building organization-specific CLI wrappers with a baked-in
default instance.
- An `ess-service-now-incident` console script with no organization
defaults: callers must specify the instance via flag, environment
variable, or a full ServiceNow URL.

## Installation

In a `uv` workspace, declare the dependency in your `pyproject.toml`:

```toml
[project]
dependencies = ["ess-service-now-incident"]

[tool.uv.sources]
ess-service-now-incident = { workspace = true }
```

Then run `uv sync --all-packages` from the workspace root.

## Library usage

```python
from ess_service_now_incident import get_incident

record = get_incident(
"INC0000001",
instance="example.service-now.com",
)
print(record["description"])
```

Passing a full ServiceNow URL lets the library extract the instance
hostname automatically:

```python
record = get_incident(
"https://example.service-now.com/now/sow/record/incident/<sys_id>",
)
```

The returned dict contains `number`, `sys_id`, `short_description`,
and `description`.

## CLI usage

The package ships an `ess-service-now-incident` console script with
no built-in default for `--instance`. The instance hostname is
resolved in this order:

1. The host embedded in the `IDENTIFIER` argument when it is a URL.
2. The `--instance` flag.
3. The `SERVICENOW_INSTANCE` environment variable.

```bash
# By incident number with explicit instance
uv run ess-service-now-incident --instance example.service-now.com INC0000001

# By URL (instance derived from the URL host)
uv run ess-service-now-incident \
"https://example.service-now.com/now/sow/record/incident/<sys_id>"

# Headed mode (show the browser window for first-time SSO login)
uv run ess-service-now-incident --headed \
--instance example.service-now.com INC0000001

# Emit the full record as JSON
uv run ess-service-now-incident --json \
--instance example.service-now.com INC0000001
```

### Options

| Option | Description |
| --------------- | ------------------------------------------------------------ |
| `--headed` | Show the browser window. Use for first-time SSO login. |
| `--profile-dir` | Browser profile directory for session persistence. |
| `--instance` | ServiceNow instance hostname (overridden by URL host). |
| `--json` | Emit the full record as JSON instead of the description. |
| `-v/--verbose` | Verbose logging to stderr. |

## Building a wrapper with a default instance

Wrappers can use `build_cli()` to construct a CLI with an
organization-specific default. The user can still override with
`--instance` or `SERVICENOW_INSTANCE`.

```python
# tools/python/my-team-tool/src/my_team_tool/__main__.py
from ess_service_now_incident import build_cli

main = build_cli(default_instance="my-org.service-now.com")

if __name__ == "__main__":
main()
```

Wire that up in your wrapper's `pyproject.toml`:

```toml
[project]
dependencies = ["ess-service-now-incident"]

[tool.uv.sources]
ess-service-now-incident = { workspace = true }

[project.scripts]
my-team-tool = "my_team_tool.__main__:main"
```

## Errors

All library errors derive from `ServiceNowIncidentError`:

| Exception | Raised when |
| ------------------------ | ----------------------------------------------------- |
| `InputParseError` | The input is not a recognisable URL or `INC...` number, or no instance is supplied. |
| `AuthenticationError` | SSO login times out or the Table API returns 401/403. |
| `IncidentNotFoundError` | The Table API returns an empty result set. |
| `APIError` | The Table API returns a non-success HTTP status, or the response is not JSON. |

## Hostname safety

The library validates instance hostnames against the
`*.service-now.com` suffix using an exact suffix match. Hostnames like
`evilservice-now.com` or `service-now.com.evil.com` are rejected,
mitigating subdomain-confusion attacks against URL inputs.

## Testing

```bash
uv run pytest packages/python/ess-service-now-incident/tests
```

The tests stub out the browser session and never touch a real
ServiceNow instance.
28 changes: 28 additions & 0 deletions packages/python/ess-service-now-incident/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[project]
name = "ess-service-now-incident"
version = "0.1.0"
description = "Fetch ServiceNow incident records via an authenticated browser session"
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"ess-browser",
"click>=8.1",
]

[tool.uv.sources]
ess-browser = { workspace = true }

[project.scripts]
ess-service-now-incident = "ess_service_now_incident.__main__:main"

[dependency-groups]
dev = [
"pytest>=8.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/ess_service_now_incident"]
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Fetch ServiceNow incident records via an authenticated browser session."""

from .cli import INSTANCE_ENV_VAR, build_cli
from .client import (
DEFAULT_PROFILE_DIR,
IncidentQuery,
get_incident,
parse_incident_input,
)
from .exceptions import (
APIError,
AuthenticationError,
IncidentNotFoundError,
InputParseError,
ServiceNowIncidentError,
)

__all__ = [
"APIError",
"AuthenticationError",
"DEFAULT_PROFILE_DIR",
"INSTANCE_ENV_VAR",
"IncidentNotFoundError",
"IncidentQuery",
"InputParseError",
"ServiceNowIncidentError",
"build_cli",
"get_incident",
"parse_incident_input",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""CLI entry point for ess-service-now-incident.

The standalone CLI has no built-in default instance; users must either
pass ``--instance``, set the ``SERVICENOW_INSTANCE`` environment
variable, or supply a full ServiceNow URL as the identifier argument.
Build a customized CLI with :func:`ess_service_now_incident.build_cli`
to bake in an organization-specific default.
"""

from __future__ import annotations

from .cli import build_cli

main = build_cli()


if __name__ == "__main__":
main() # pylint: disable=no-value-for-parameter # Click supplies args at runtime
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""Click-based CLI factory for ess-service-now-incident.

Wrappers can call :func:`build_cli` with a ``default_instance`` to bake
in an organization-specific hostname while keeping the same command
shape as the standalone CLI.
"""

from __future__ import annotations

import json
import logging
import sys

import click

from .client import DEFAULT_PROFILE_DIR, get_incident
from .exceptions import ServiceNowIncidentError

INSTANCE_ENV_VAR = "SERVICENOW_INSTANCE"


def build_cli(*, default_instance: str | None = None) -> click.Command:
"""Build a Click command for fetching ServiceNow incidents.

Args:
default_instance: Hostname baked in as the ``--instance``
default. At invocation time the ``SERVICENOW_INSTANCE``
environment variable still takes precedence, and an
explicit ``--instance`` flag wins over both. Pass ``None``
to require the user to supply an instance (either via the
flag, the environment variable, or a full URL identifier).

Returns:
A :class:`click.Command` ready to be exposed as a console
script. The command exits with a non-zero status on errors.
"""
show_default = bool(default_instance)

@click.command()
@click.argument("identifier")
@click.option(
"--headed",
is_flag=True,
default=False,
help="Show the browser window. Use for first-time SSO login.",
)
@click.option(
"--profile-dir",
default=None,
help=(
f"Browser profile directory for session persistence. "
f"[default: {DEFAULT_PROFILE_DIR}]"
),
)
@click.option(
"--instance",
default=default_instance,
envvar=INSTANCE_ENV_VAR,
show_default=show_default,
help=(
"ServiceNow instance hostname (e.g. example.service-now.com). "
f"Falls back to the {INSTANCE_ENV_VAR} environment variable."
),
)
@click.option(
"--json",
"output_json",
is_flag=True,
default=False,
help="Output full incident data as JSON.",
)
@click.option(
"-v",
"--verbose",
is_flag=True,
default=False,
help="Enable verbose logging.",
)
def main( # noqa: PLR0913
identifier: str,
headed: bool,
profile_dir: str | None,
instance: str | None,
output_json: bool,
verbose: bool,
) -> None:
"""Fetch the description of a ServiceNow incident.

IDENTIFIER is an incident number (e.g. INC0000001) or a full
ServiceNow URL.
"""
logging.basicConfig(
level=logging.DEBUG if verbose else logging.WARNING,
format="%(levelname)s: %(message)s",
stream=sys.stderr,
)

try:
result = get_incident(
identifier,
instance=instance,
headed=headed,
profile_dir=profile_dir,
)
except ServiceNowIncidentError as exc:
click.echo(f"Error: {exc}", err=True)
sys.exit(1)
except Exception as exc: # noqa: BLE001 -- last-resort CLI guard
click.echo(f"Error: {exc}", err=True)
sys.exit(1)

if output_json:
click.echo(json.dumps(result, indent=2))
return

description = result.get("description", "").strip()
if description:
click.echo(description)
return

short_description = result.get("short_description", "").strip()
if short_description:
click.echo(
"Note: description is empty, showing short_description instead.",
err=True,
)
click.echo(short_description)
return

click.echo("No description or short_description found.", err=True)
sys.exit(1)

return main
Loading
Loading