Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -307,5 +307,8 @@ Cargo.lock

.claude

# Local docker compose overrides (env vars, port remaps, secrets)
docker-compose.override.yaml

# docs output
book/
48 changes: 48 additions & 0 deletions docs/user/manage.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,3 +56,51 @@ The list is paginated (50 per page) and can be filtered and sorted:
- **Sort**: by name, most referenced, or least referenced

Each row shows the rule's name, source file, description, reference count, and line number within the source file.

## Rule Authoring (Experimental feature)

Users can draft SML rules directly in the UI. Submit opens a review unit against a configured git remote so authoring, review, and merge use the same tools users already have.

The editor validates every keystroke against the same AST validator the running engine uses, so compile-time errors surface before the pull request opens. The Rule Builder view expresses the common shape (name, conditions, outcomes) as a form and generates SML; the Code Editor view accepts arbitrary SML for anything the builder can't represent.

### Rule submission backends

The Submit button routes drafts through a pluggable backend. Pick one for your deployment by setting `OSPREY_RULES_SUBMISSION_BACKEND` on the `osprey-ui-api` process:

| Value | What it does | Required env vars |
|---|---|---|
| `null` (default) | Returns 503 on any submit or list call. Ships as the default so an unconfigured install never writes anything. | none |
| `github` | Opens a pull request on a configured repo. Works with github.com and GitHub Enterprise. | `OSPREY_RULES_REPO`, `OSPREY_GITHUB_TOKEN` (+ optionals) |
| `gitlab` | Opens a merge request on a configured project. Works with gitlab.com and self-hosted GitLab. | `OSPREY_GITLAB_PROJECT`, `OSPREY_GITLAB_TOKEN` (+ optionals) |
| `local` | Writes SML directly to a mounted directory. For self-hosted setups whose deploy pipeline already syncs a rules directory into the engine. | `OSPREY_RULES_LOCAL_PATH` |

Env vars shared across every backend that targets a git host:

- `OSPREY_RULES_BASE_BRANCH` (default `main`) — the branch the review targets.
- `OSPREY_RULES_PATH_IN_REPO` (default empty) — subdirectory inside the target repo where rule files live, e.g. `example_rules`. Leave empty if rules sit at the repo root.

#### `github`

| Var | Default | Notes |
|---|---|---|
| `OSPREY_RULES_REPO` | _required_ | `owner/name` of the repo to PR against. |
| `OSPREY_GITHUB_TOKEN` | _required_ | Fine-grained PAT with `Contents: read/write` and `Pull requests: read/write` on the repo. |
| `OSPREY_GITHUB_API_URL` | `https://api.github.com` | Set for GitHub Enterprise: e.g. `https://github.acme.example/api/v3`. |

#### `gitlab`

| Var | Default | Notes |
|---|---|---|
| `OSPREY_GITLAB_PROJECT` | _required_ | `namespace/project` of the project to MR against. |
| `OSPREY_GITLAB_TOKEN` | _required_ | Project or personal access token with the `api` scope. |
| `OSPREY_GITLAB_URL` | `https://gitlab.com` | Set for self-hosted GitLab: e.g. `https://gitlab.mycompany.example`. |

#### `local`

| Var | Default | Notes |
|---|---|---|
| `OSPREY_RULES_LOCAL_PATH` | _required_ | Absolute path to the directory the backend writes SML into. Must already exist. Submissions take effect immediately; there's no review queue. |

### Adding a rule submission backend

Add a Python module next to `_rule_drafts_github.py` that implements the `RuleSubmissionBackend` Protocol defined in `_rule_drafts_backend.py`, then wire it into `load_backend()`. See the module docstring on `_rule_drafts_backend.py` for the contract; the existing HTTP-backed modules (`_rule_drafts_github.py`, `_rule_drafts_gitlab.py`) are working templates.
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@
{
"name": "CAN_VIEW_EVENTS_BY_ACTION",
"allow_all": true
},
{
"name": "CAN_EDIT_RULE_DRAFTS",
"allow_all": true
}
],
"ability_groups": ["CAN_VIEW_BASIC_USER_DATA"]
Expand Down
8 changes: 8 additions & 0 deletions osprey_worker/src/osprey/worker/lib/osprey_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,14 @@ def _handle_updated_sources(self) -> None:
def execution_graph(self) -> ExecutionGraph:
return self._execution_graph

@property
def udf_registry(self) -> UDFRegistry:
return self._udf_registry

@property
def validator_registry(self) -> ValidatorRegistry:
return self._validator_registry

@property
def config(self) -> SourcesConfig:
return self._execution_graph.validated_sources.sources.config
Expand Down
2 changes: 2 additions & 0 deletions osprey_worker/src/osprey/worker/ui_api/osprey/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ def create_app() -> Flask:
events,
features,
queries,
rule_drafts,
rules,
rules_visualizer,
saved_queries,
Expand Down Expand Up @@ -111,6 +112,7 @@ def create_app() -> Flask:
_register_with_prefix(app, events.blueprint)
_register_with_prefix(app, features.blueprint)
_register_with_prefix(app, rules.blueprint)
_register_with_prefix(app, rule_drafts.blueprint)
_register_with_prefix(app, queries.blueprint)
_register_with_prefix(app, config.blueprint)
_register_with_prefix(app, docs.blueprint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,7 @@ def _get_query_filter(self) -> dict[str, Any] | None:
CanViewSavedQueries = register_ability('CAN_VIEW_SAVED_QUERIES')(make_marker_ability())
CanCreateAndEditSavedQueries = register_ability('CAN_CREATE_AND_EDIT_SAVED_QUERIES')(make_marker_ability())
CanBulkAction = register_ability('CAN_BULK_ACTION')(make_marker_ability())
CanEditRuleDrafts = register_ability('CAN_EDIT_RULE_DRAFTS')(make_marker_ability())


def require_ability_with_request(request_model: ModelT, ability_class: Type[Ability[ModelT, ItemT]]) -> None:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Backend abstraction for rule-draft submission.

The Osprey engine doesn't care where rules live. Different deployments use
different hosting: GitHub or Enterprise, GitLab, Tangled, an internal Gerrit,
or a filesystem on a shared volume. Each is one implementation of the
RuleSubmissionBackend Protocol below.

`load_backend()` reads `OSPREY_RULES_SUBMISSION_BACKEND` and instantiates the
chosen backend with its own env vars. Defaults to `null` so an unconfigured
install ships safe; adopters opt into a backend explicitly.

Adopter docs (env vars per backend, how to choose one): see
`docs/user/manage.md`.

Adding a new backend: implement a class with `submit_draft` and
`list_pending_drafts` matching the Protocol below, add a case in
`load_backend()`, and update the "unknown backend" error message here plus
the "no backend configured" message in `_rule_drafts_null.py`. The existing
`_rule_drafts_github.py` and `_rule_drafts_gitlab.py` modules are working
templates for HTTP-backed adapters; `_rule_drafts_local.py` for filesystem.
"""

from __future__ import annotations

import os
from dataclasses import dataclass, field
from typing import Any, Protocol


class RuleDraftBackendError(Exception):
"""Raised by any backend method when the operation cannot complete."""

def __init__(self, message: str, status_code: int = 502):
super().__init__(message)
self.message = message
self.status_code = status_code


@dataclass(frozen=True)
class SubmissionResult:
"""Backend-neutral submit_draft return value.

`title` and `url` are what the UI surfaces in the success banner; `extras`
carries backend-specific fields (PR number, branch, etc.) for adopters
whose UI variants want to render more detail.
"""

title: str
url: str | None
main_sml_updated: bool = False
extras: dict[str, Any] = field(default_factory=dict)

def to_json(self) -> dict[str, Any]:
# Spread extras first so the canonical fields always win: a backend that
# happens to name an extra `title`/`url`/`main_sml_updated` can't shadow
# the contract fields the UI depends on.
return {
**self.extras,
'title': self.title,
'url': self.url,
'main_sml_updated': self.main_sml_updated,
}


@dataclass(frozen=True)
class PendingDraft:
"""Backend-neutral entry for the pending-drafts list."""

title: str
url: str
author: str
created_at: str
touched_files: list[str]
extras: dict[str, Any] = field(default_factory=dict)

def to_json(self) -> dict[str, Any]:
# Spread extras first so backend-specific keys can't shadow the
# canonical fields the UI depends on.
return {
**self.extras,
'title': self.title,
'url': self.url,
'author': self.author,
'created_at': self.created_at,
'touched_files': self.touched_files,
}


class RuleSubmissionBackend(Protocol):
"""The contract every submission backend implements.

Implementations:
- submit a draft (create whatever the backend's review unit is)
- optionally wire the new rule into main.sml as part of the same submission
- list whatever's currently in review

Implementations raise `RuleDraftBackendError` for any failure path.
"""

name: str

def submit_draft(
self,
*,
draft_path: str,
sml_source: str,
rule_name: str,
summary: str,
author_email: str,
is_new_rule: bool,
wire_into_main: bool,
) -> SubmissionResult: ...

def list_pending_drafts(self) -> list[PendingDraft]: ...


def load_backend() -> RuleSubmissionBackend:
"""Select and instantiate the configured backend.

`OSPREY_RULES_SUBMISSION_BACKEND` picks one of: github, gitlab, local, null.
Unset or empty defaults to `null`. Unknown values raise so a typo doesn't
silently degrade to no-op submission.
"""
name = (os.environ.get('OSPREY_RULES_SUBMISSION_BACKEND') or 'null').strip().lower()

# Imports are deferred to keep the Protocol module dependency-free.
if name == 'null':
from ._rule_drafts_null import NullBackend

return NullBackend()
if name == 'github':
from ._rule_drafts_github import GitHubBackend

return GitHubBackend.from_env()
if name == 'gitlab':
from ._rule_drafts_gitlab import GitLabBackend

return GitLabBackend.from_env()
if name == 'local':
from ._rule_drafts_local import LocalBackend

return LocalBackend.from_env()
raise RuleDraftBackendError(
f'Unknown OSPREY_RULES_SUBMISSION_BACKEND {name!r}; valid values are github, gitlab, local, null.',
status_code=500,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Shared helpers for git-forge submission backends.

The GitHub, GitLab, and Tangled adapters all need the same three things: a
cosmetic branch name, a check for whether main.sml already wires a rule in, and
the append that adds the wiring. They also all talk to a remote over HTTP and
must turn a dropped connection into the same structured error the UI renders
rather than an unhandled 500. This module is the one place those live.
"""

from __future__ import annotations

import re
import time
from typing import Any

import requests

from ._rule_drafts_backend import RuleDraftBackendError

DEFAULT_TIMEOUT_SECONDS = 15


def request(method: str, url: str, *, error_action: str, **kwargs: Any) -> requests.Response:
"""Issue an HTTP request, converting transport failures to RuleDraftBackendError.

A forge outage (connection refused, DNS failure, timeout) is an expected
operational state for a backend whose job is talking to a remote host, so it
should surface as the 502 JSON shape the editor knows how to display, not as
an unhandled Flask 500. HTTP status errors are left for the caller to map,
since the right status code depends on what was being attempted.
"""
kwargs.setdefault('timeout', DEFAULT_TIMEOUT_SECONDS)
try:
return requests.request(method, url, **kwargs)
except requests.RequestException as exc:
raise RuleDraftBackendError(
f'Could not reach the git host while {error_action}: {exc}',
status_code=502,
) from exc


def generate_branch_name(rule_name: str, author_email: str, *, prefix: str = 'rule-draft') -> str:
"""Cosmetic source-branch label. Timestamped so retries don't collide."""
short_email = author_email.split('@', 1)[0]
slug = re.sub(r'[^A-Za-z0-9_-]+', '-', short_email).strip('-') or 'osprey-ui'
rule_slug = re.sub(r'[^A-Za-z0-9_-]+', '-', rule_name).strip('-') or 'rule'
return f'{prefix}/{slug}/{rule_slug}-{int(time.time())}'


def require_already_present(main_sml: str, draft_path: str) -> bool:
pattern = re.compile(
r"Require\s*\(\s*rule\s*=\s*['\"]" + re.escape(draft_path) + r"['\"]\s*\)",
re.MULTILINE,
)
return bool(pattern.search(main_sml))


def append_require_to_main(main_sml: str, draft_path: str) -> str:
suffix = f"\nRequire(rule='{draft_path}')\n"
if not main_sml.endswith('\n'):
suffix = '\n' + suffix
return main_sml + suffix
Loading
Loading