diff --git a/.gitignore b/.gitignore index afa35534..fdea2ca3 100644 --- a/.gitignore +++ b/.gitignore @@ -307,5 +307,8 @@ Cargo.lock .claude +# Local docker compose overrides (env vars, port remaps, secrets) +docker-compose.override.yaml + # docs output book/ diff --git a/docs/user/manage.md b/docs/user/manage.md index 36ed4225..11077fba 100644 --- a/docs/user/manage.md +++ b/docs/user/manage.md @@ -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. diff --git a/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json b/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json index c824783c..2ee45783 100644 --- a/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json +++ b/osprey_worker/src/osprey/worker/lib/acls/definitions/super_user.json @@ -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"] diff --git a/osprey_worker/src/osprey/worker/lib/osprey_engine.py b/osprey_worker/src/osprey/worker/lib/osprey_engine.py index 2e6318d4..45618ae8 100644 --- a/osprey_worker/src/osprey/worker/lib/osprey_engine.py +++ b/osprey_worker/src/osprey/worker/lib/osprey_engine.py @@ -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 diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/app.py b/osprey_worker/src/osprey/worker/ui_api/osprey/app.py index 407462fe..4292b3df 100644 --- a/osprey_worker/src/osprey/worker/ui_api/osprey/app.py +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/app.py @@ -68,6 +68,7 @@ def create_app() -> Flask: events, features, queries, + rule_drafts, rules, rules_visualizer, saved_queries, @@ -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) diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py b/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py index e56fffd2..3bdf1748 100644 --- a/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/lib/abilities.py @@ -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: diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_backend.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_backend.py new file mode 100644 index 00000000..3d0d2a66 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_backend.py @@ -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, + ) diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_git_common.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_git_common.py new file mode 100644 index 00000000..5f9ad575 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_git_common.py @@ -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 diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_github.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_github.py new file mode 100644 index 00000000..1e9678e6 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_github.py @@ -0,0 +1,311 @@ +"""GitHub submission backend. + +Implements RuleSubmissionBackend by opening a pull request against a configured +repo. Works with github.com and with GitHub Enterprise (set OSPREY_GITHUB_API_URL +to the Enterprise API root, e.g. https://github.mycompany.com/api/v3). +""" + +from __future__ import annotations + +import base64 +import os +from dataclasses import dataclass +from typing import Any + +import requests + +from . import _rule_drafts_git_common as git_common +from ._rule_drafts_backend import ( + PendingDraft, + RuleDraftBackendError, + SubmissionResult, +) + +DEFAULT_GITHUB_API = 'https://api.github.com' + + +@dataclass(frozen=True) +class GitHubConfig: + api_url: str + repo: str + base_branch: str + rules_path: str + token: str + + @property + def repo_url(self) -> str: + return f'{self.api_url.rstrip("/")}/repos/{self.repo}' + + @classmethod + def from_env(cls) -> 'GitHubConfig': + api_url = (os.environ.get('OSPREY_GITHUB_API_URL') or DEFAULT_GITHUB_API).strip() + repo = os.environ.get('OSPREY_RULES_REPO', '').strip() + base = os.environ.get('OSPREY_RULES_BASE_BRANCH', 'main').strip() or 'main' + rules_path = os.environ.get('OSPREY_RULES_PATH_IN_REPO', '').strip().strip('/') + token = os.environ.get('OSPREY_GITHUB_TOKEN', '').strip() + if not repo: + raise RuleDraftBackendError( + 'OSPREY_RULES_REPO is not configured; set it to "owner/name" to enable PR submission.', + status_code=503, + ) + if not token: + raise RuleDraftBackendError( + 'OSPREY_GITHUB_TOKEN is not configured; set a service-account PAT with repo write access.', + status_code=503, + ) + return cls(api_url=api_url, repo=repo, base_branch=base, rules_path=rules_path, token=token) + + +def _headers(cfg: GitHubConfig) -> dict[str, str]: + return { + 'Authorization': f'Bearer {cfg.token}', + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + + +def _full_path(cfg: GitHubConfig, draft_path: str) -> str: + draft_path = draft_path.lstrip('/') + if cfg.rules_path: + return f'{cfg.rules_path}/{draft_path}' + return draft_path + + +def _get(cfg: GitHubConfig, url: str, **kwargs: Any) -> requests.Response: + return git_common.request('GET', url, error_action='contacting GitHub', headers=_headers(cfg), **kwargs) + + +def _post(cfg: GitHubConfig, url: str, json: dict[str, Any]) -> requests.Response: + return git_common.request('POST', url, error_action='contacting GitHub', headers=_headers(cfg), json=json) + + +def _put(cfg: GitHubConfig, url: str, json: dict[str, Any]) -> requests.Response: + return git_common.request('PUT', url, error_action='contacting GitHub', headers=_headers(cfg), json=json) + + +def _raise_for_github(response: requests.Response, action: str) -> None: + if response.ok: + return + body = response.text[:500] + # 4xx from GitHub is still a backend failure from the caller's POV. + raise RuleDraftBackendError( + f'GitHub returned {response.status_code} while {action}: {body}', + status_code=502, + ) + + +def _get_base_sha(cfg: GitHubConfig) -> str: + res = _get(cfg, f'{cfg.repo_url}/git/ref/heads/{cfg.base_branch}') + _raise_for_github(res, f'looking up base branch {cfg.base_branch!r}') + return res.json()['object']['sha'] + + +def _get_file_sha(cfg: GitHubConfig, path_in_repo: str, ref: str) -> str | None: + res = _get(cfg, f'{cfg.repo_url}/contents/{path_in_repo}', params={'ref': ref}) + if res.status_code == 404: + return None + _raise_for_github(res, f'reading {path_in_repo!r} on {ref!r}') + payload = res.json() + if isinstance(payload, list): + return None + return payload.get('sha') + + +def _create_branch(cfg: GitHubConfig, branch: str, sha: str) -> None: + res = _post(cfg, f'{cfg.repo_url}/git/refs', json={'ref': f'refs/heads/{branch}', 'sha': sha}) + if res.status_code == 422: + raise RuleDraftBackendError( + f'Branch {branch!r} already exists on {cfg.repo}. Pick a different name.', + status_code=409, + ) + _raise_for_github(res, f'creating branch {branch!r}') + + +def _commit_file( + cfg: GitHubConfig, + branch: str, + path_in_repo: str, + contents: str, + message: str, + existing_sha: str | None, +) -> None: + payload: dict[str, Any] = { + 'message': message, + 'content': base64.b64encode(contents.encode('utf-8')).decode('ascii'), + 'branch': branch, + } + if existing_sha is not None: + payload['sha'] = existing_sha + res = _put(cfg, f'{cfg.repo_url}/contents/{path_in_repo}', json=payload) + _raise_for_github(res, f'committing {path_in_repo!r} to {branch!r}') + + +def _open_pr(cfg: GitHubConfig, branch: str, title: str, body: str) -> dict[str, Any]: + res = _post( + cfg, + f'{cfg.repo_url}/pulls', + json={'title': title, 'head': branch, 'base': cfg.base_branch, 'body': body}, + ) + _raise_for_github(res, f'opening PR from {branch!r}') + return res.json() + + +def _main_sml_path(cfg: GitHubConfig) -> str: + return _full_path(cfg, 'main.sml') + + +def _fetch_file_on_ref(cfg: GitHubConfig, path_in_repo: str, ref: str) -> tuple[str, str] | None: + res = _get(cfg, f'{cfg.repo_url}/contents/{path_in_repo}', params={'ref': ref}) + if res.status_code == 404: + return None + _raise_for_github(res, f'reading {path_in_repo!r} on {ref!r}') + payload = res.json() + if isinstance(payload, list): + return None + encoded = payload.get('content', '') + sha = payload.get('sha') + if sha is None: + return None + try: + decoded = base64.b64decode(encoded).decode('utf-8') + except Exception as exc: + raise RuleDraftBackendError(f'could not decode {path_in_repo!r}: {exc}', status_code=502) + return decoded, sha + + +class GitHubBackend: + name = 'github' + + def __init__(self, cfg: GitHubConfig): + self._cfg = cfg + + @classmethod + def from_env(cls) -> 'GitHubBackend': + return cls(GitHubConfig.from_env()) + + 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: + cfg = self._cfg + path_in_repo = _full_path(cfg, draft_path) + base_sha = _get_base_sha(cfg) + existing_sha = _get_file_sha(cfg, path_in_repo, cfg.base_branch) + + if is_new_rule and existing_sha is not None: + raise RuleDraftBackendError( + f'A file already exists at {path_in_repo!r} on {cfg.base_branch}. ' + 'Pick a different filename, or edit the existing rule instead of creating a new one.', + status_code=409, + ) + + branch = git_common.generate_branch_name(rule_name, author_email) + _create_branch(cfg, branch, base_sha) + + verb = 'Add' if is_new_rule else 'Update' + commit_message = f'{verb} rule {rule_name}\n\nAuthored via Osprey UI by {author_email}.' + _commit_file( + cfg, + branch=branch, + path_in_repo=path_in_repo, + contents=sml_source, + message=commit_message, + existing_sha=existing_sha, + ) + + main_sml_updated = False + if wire_into_main: + main_path = _main_sml_path(cfg) + fetched = _fetch_file_on_ref(cfg, main_path, cfg.base_branch) + if fetched is None: + raise RuleDraftBackendError( + f'wire_into_main requested but {main_path!r} does not exist on {cfg.base_branch}.', + status_code=409, + ) + main_contents, main_sha = fetched + if not git_common.require_already_present(main_contents, draft_path): + new_main = git_common.append_require_to_main(main_contents, draft_path) + _commit_file( + cfg, + branch=branch, + path_in_repo=main_path, + contents=new_main, + message=f'Wire {rule_name} into main.sml\n\nAuthored via Osprey UI by {author_email}.', + existing_sha=main_sha, + ) + main_sml_updated = True + + title = f'{verb} rule {rule_name}' + touched = f'`{path_in_repo}`' + if main_sml_updated: + touched += f', `{_main_sml_path(cfg)}`' + body = ( + f'{summary.strip() or "_(no summary provided)_"}\n\n' + f'---\n' + f'Drafted in the Osprey rules UI by `{author_email}`.\n' + f'Touches: {touched}.' + ) + pr = _open_pr(cfg, branch=branch, title=title, body=body) + pr_number = pr.get('number') + pr_url = pr.get('html_url') + return SubmissionResult( + title=f'Pull request #{pr_number} opened', + url=pr_url, + main_sml_updated=main_sml_updated, + extras={ + 'pr_number': pr_number, + 'pr_url': pr_url, + 'branch': branch, + 'path_in_repo': path_in_repo, + }, + ) + + def list_pending_drafts(self) -> list[PendingDraft]: + cfg = self._cfg + res = _get( + cfg, + f'{cfg.repo_url}/pulls', + params={'state': 'open', 'base': cfg.base_branch, 'per_page': 30}, + ) + _raise_for_github(res, 'listing open pull requests') + open_prs = res.json() + + out: list[PendingDraft] = [] + for pr in open_prs: + number = pr.get('number') + if number is None: + continue + files_res = _get(cfg, f'{cfg.repo_url}/pulls/{number}/files', params={'per_page': 50}) + if not files_res.ok: + continue + files = files_res.json() + touched = [ + f['filename'] + for f in files + if isinstance(f.get('filename'), str) + and (not cfg.rules_path or f['filename'].startswith(cfg.rules_path + '/')) + and f['filename'].endswith('.sml') + ] + if not touched: + continue + out.append( + PendingDraft( + title=pr.get('title', ''), + url=pr.get('html_url', ''), + author=(pr.get('user') or {}).get('login', ''), + created_at=pr.get('created_at', ''), + touched_files=touched, + extras={ + 'pr_number': number, + 'branch': (pr.get('head') or {}).get('ref', ''), + }, + ) + ) + return out diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_gitlab.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_gitlab.py new file mode 100644 index 00000000..a15884f6 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_gitlab.py @@ -0,0 +1,316 @@ +"""GitLab submission backend. + +Implements RuleSubmissionBackend against the GitLab REST v4 API. Works with +gitlab.com and with self-hosted GitLab; set OSPREY_GITLAB_URL to the instance +root (default https://gitlab.com). + +GitLab's model maps cleanly onto the same abstraction as GitHub, just with +different endpoint shapes: + * "merge requests" instead of "pull requests" + * `source_branch`/`target_branch` instead of `head`/`base` + * `PRIVATE-TOKEN` header for PATs + * file paths in URLs are percent-encoded (slashes and all) +""" + +from __future__ import annotations + +import base64 +import os +from dataclasses import dataclass +from typing import Any +from urllib.parse import quote + +import requests + +from . import _rule_drafts_git_common as git_common +from ._rule_drafts_backend import ( + PendingDraft, + RuleDraftBackendError, + SubmissionResult, +) + +DEFAULT_GITLAB_URL = 'https://gitlab.com' + + +@dataclass(frozen=True) +class GitLabConfig: + gitlab_url: str + project: str + base_branch: str + rules_path: str + token: str + + @property + def project_url(self) -> str: + # GitLab lets you reference a project by URL-encoded namespace/name. + return f'{self.gitlab_url.rstrip("/")}/api/v4/projects/{quote(self.project, safe="")}' + + @classmethod + def from_env(cls) -> 'GitLabConfig': + gitlab_url = (os.environ.get('OSPREY_GITLAB_URL') or DEFAULT_GITLAB_URL).strip() + project = os.environ.get('OSPREY_GITLAB_PROJECT', '').strip() + base = os.environ.get('OSPREY_RULES_BASE_BRANCH', 'main').strip() or 'main' + rules_path = os.environ.get('OSPREY_RULES_PATH_IN_REPO', '').strip().strip('/') + token = os.environ.get('OSPREY_GITLAB_TOKEN', '').strip() + if not project: + raise RuleDraftBackendError( + 'OSPREY_GITLAB_PROJECT is not configured; set it to "namespace/project" ' + '(e.g. "example-org/osprey-rules").', + status_code=503, + ) + if not token: + raise RuleDraftBackendError( + 'OSPREY_GITLAB_TOKEN is not configured; set a project or personal access token with the `api` scope.', + status_code=503, + ) + return cls(gitlab_url=gitlab_url, project=project, base_branch=base, rules_path=rules_path, token=token) + + +def _headers(cfg: GitLabConfig) -> dict[str, str]: + return { + 'PRIVATE-TOKEN': cfg.token, + 'Accept': 'application/json', + } + + +def _full_path(cfg: GitLabConfig, draft_path: str) -> str: + draft_path = draft_path.lstrip('/') + if cfg.rules_path: + return f'{cfg.rules_path}/{draft_path}' + return draft_path + + +def _encoded_file_url(cfg: GitLabConfig, path_in_repo: str) -> str: + # GitLab's files endpoint takes the full path percent-encoded, slashes included. + return f'{cfg.project_url}/repository/files/{quote(path_in_repo, safe="")}' + + +def _get(cfg: GitLabConfig, url: str, **kwargs: Any) -> requests.Response: + return git_common.request('GET', url, error_action='contacting GitLab', headers=_headers(cfg), **kwargs) + + +def _post(cfg: GitLabConfig, url: str, json: dict[str, Any] | None = None, **kwargs: Any) -> requests.Response: + return git_common.request('POST', url, error_action='contacting GitLab', headers=_headers(cfg), json=json, **kwargs) + + +def _put(cfg: GitLabConfig, url: str, json: dict[str, Any]) -> requests.Response: + return git_common.request('PUT', url, error_action='contacting GitLab', headers=_headers(cfg), json=json) + + +def _raise_for_gitlab(response: requests.Response, action: str) -> None: + if response.ok: + return + body = response.text[:500] + raise RuleDraftBackendError( + f'GitLab returned {response.status_code} while {action}: {body}', + status_code=502, + ) + + +def _get_file_on_ref(cfg: GitLabConfig, path_in_repo: str, ref: str) -> tuple[str, str] | None: + """Fetch (decoded_content, blob_sha) for a file on `ref`, or None if it doesn't exist.""" + res = _get(cfg, _encoded_file_url(cfg, path_in_repo), params={'ref': ref}) + if res.status_code == 404: + return None + _raise_for_gitlab(res, f'reading {path_in_repo!r} on {ref!r}') + payload = res.json() + encoded = payload.get('content', '') + blob_id = payload.get('blob_id') or payload.get('last_commit_id') + if not blob_id: + return None + try: + decoded = base64.b64decode(encoded).decode('utf-8') + except Exception as exc: + raise RuleDraftBackendError(f'could not decode {path_in_repo!r}: {exc}', status_code=502) + return decoded, blob_id + + +def _file_exists_on_ref(cfg: GitLabConfig, path_in_repo: str, ref: str) -> bool: + return _get_file_on_ref(cfg, path_in_repo, ref) is not None + + +def _create_branch(cfg: GitLabConfig, branch: str, ref: str) -> None: + res = _post(cfg, f'{cfg.project_url}/repository/branches', params={'branch': branch, 'ref': ref}) + if res.status_code == 400 and 'already exists' in res.text.lower(): + raise RuleDraftBackendError( + f'Branch {branch!r} already exists on {cfg.project}. Pick a different name.', + status_code=409, + ) + _raise_for_gitlab(res, f'creating branch {branch!r}') + + +def _commit_file( + cfg: GitLabConfig, + branch: str, + path_in_repo: str, + contents: str, + message: str, + is_update: bool, +) -> None: + payload = { + 'branch': branch, + 'content': contents, + 'commit_message': message, + } + url = _encoded_file_url(cfg, path_in_repo) + if is_update: + res = _put(cfg, url, json=payload) + else: + res = _post(cfg, url, json=payload) + _raise_for_gitlab(res, f'committing {path_in_repo!r} to {branch!r}') + + +def _open_mr(cfg: GitLabConfig, source_branch: str, title: str, description: str) -> dict[str, Any]: + res = _post( + cfg, + f'{cfg.project_url}/merge_requests', + json={ + 'source_branch': source_branch, + 'target_branch': cfg.base_branch, + 'title': title, + 'description': description, + }, + ) + _raise_for_gitlab(res, f'opening MR from {source_branch!r}') + return res.json() + + +def _main_sml_path(cfg: GitLabConfig) -> str: + return _full_path(cfg, 'main.sml') + + +class GitLabBackend: + name = 'gitlab' + + def __init__(self, cfg: GitLabConfig): + self._cfg = cfg + + @classmethod + def from_env(cls) -> 'GitLabBackend': + return cls(GitLabConfig.from_env()) + + 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: + cfg = self._cfg + path_in_repo = _full_path(cfg, draft_path) + existing = _file_exists_on_ref(cfg, path_in_repo, cfg.base_branch) + + if is_new_rule and existing: + raise RuleDraftBackendError( + f'A file already exists at {path_in_repo!r} on {cfg.base_branch}. ' + 'Pick a different filename, or edit the existing rule instead of creating a new one.', + status_code=409, + ) + + branch = git_common.generate_branch_name(rule_name, author_email) + _create_branch(cfg, branch, cfg.base_branch) + + verb = 'Add' if is_new_rule else 'Update' + commit_message = f'{verb} rule {rule_name}\n\nAuthored via Osprey UI by {author_email}.' + _commit_file( + cfg, + branch=branch, + path_in_repo=path_in_repo, + contents=sml_source, + message=commit_message, + is_update=existing, + ) + + main_sml_updated = False + if wire_into_main: + main_path = _main_sml_path(cfg) + fetched = _get_file_on_ref(cfg, main_path, cfg.base_branch) + if fetched is None: + raise RuleDraftBackendError( + f'wire_into_main requested but {main_path!r} does not exist on {cfg.base_branch}.', + status_code=409, + ) + main_contents, _blob = fetched + if not git_common.require_already_present(main_contents, draft_path): + new_main = git_common.append_require_to_main(main_contents, draft_path) + _commit_file( + cfg, + branch=branch, + path_in_repo=main_path, + contents=new_main, + message=f'Wire {rule_name} into main.sml\n\nAuthored via Osprey UI by {author_email}.', + is_update=True, + ) + main_sml_updated = True + + title = f'{verb} rule {rule_name}' + touched = f'`{path_in_repo}`' + if main_sml_updated: + touched += f', `{_main_sml_path(cfg)}`' + description = ( + f'{summary.strip() or "_(no summary provided)_"}\n\n' + f'---\n' + f'Drafted in the Osprey rules UI by `{author_email}`.\n' + f'Touches: {touched}.' + ) + mr = _open_mr(cfg, source_branch=branch, title=title, description=description) + mr_iid = mr.get('iid') + mr_url = mr.get('web_url') + return SubmissionResult( + title=f'Merge request !{mr_iid} opened', + url=mr_url, + main_sml_updated=main_sml_updated, + extras={ + 'mr_iid': mr_iid, + 'mr_url': mr_url, + 'branch': branch, + 'path_in_repo': path_in_repo, + }, + ) + + def list_pending_drafts(self) -> list[PendingDraft]: + cfg = self._cfg + res = _get( + cfg, + f'{cfg.project_url}/merge_requests', + params={'state': 'opened', 'target_branch': cfg.base_branch, 'per_page': 30}, + ) + _raise_for_gitlab(res, 'listing open merge requests') + open_mrs = res.json() + + out: list[PendingDraft] = [] + for mr in open_mrs: + iid = mr.get('iid') + if iid is None: + continue + files_res = _get(cfg, f'{cfg.project_url}/merge_requests/{iid}/diffs', params={'per_page': 50}) + if not files_res.ok: + continue + diffs = files_res.json() + touched = [ + d['new_path'] + for d in diffs + if isinstance(d.get('new_path'), str) + and (not cfg.rules_path or d['new_path'].startswith(cfg.rules_path + '/')) + and d['new_path'].endswith('.sml') + ] + if not touched: + continue + out.append( + PendingDraft( + title=mr.get('title', ''), + url=mr.get('web_url', ''), + author=(mr.get('author') or {}).get('username', ''), + created_at=mr.get('created_at', ''), + touched_files=touched, + extras={ + 'mr_iid': iid, + 'branch': mr.get('source_branch', ''), + }, + ) + ) + return out diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_local.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_local.py new file mode 100644 index 00000000..2d815eb6 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_local.py @@ -0,0 +1,110 @@ +"""Filesystem submission backend for self-hosted setups. + +Writes the SML straight to a configured rules directory. No review, no PR. +Adopters whose deploy pipeline already syncs a rules directory into the engine +(etcd push, file watcher, etc.) wire this backend up so the UI drops the SML +in the right place and lets the downstream pipeline take it from there. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + +from . import _rule_drafts_git_common as git_common +from ._rule_drafts_backend import ( + PendingDraft, + RuleDraftBackendError, + SubmissionResult, +) + + +@dataclass(frozen=True) +class LocalConfig: + rules_dir: Path + + @classmethod + def from_env(cls) -> 'LocalConfig': + raw = os.environ.get('OSPREY_RULES_LOCAL_PATH', '').strip() + if not raw: + raise RuleDraftBackendError( + 'OSPREY_RULES_LOCAL_PATH is not configured; set it to the directory the local backend should write to.', + status_code=503, + ) + path = Path(raw) + if not path.is_dir(): + raise RuleDraftBackendError( + f'OSPREY_RULES_LOCAL_PATH {raw!r} is not a directory.', + status_code=503, + ) + return cls(rules_dir=path) + + +class LocalBackend: + name = 'local' + + def __init__(self, cfg: LocalConfig): + self._cfg = cfg + + @classmethod + def from_env(cls) -> 'LocalBackend': + return cls(LocalConfig.from_env()) + + def _resolve(self, draft_path: str) -> Path: + """Resolve a draft path within rules_dir, refusing anything that would + escape via `..` or symlink traversal.""" + candidate = (self._cfg.rules_dir / draft_path).resolve() + try: + candidate.relative_to(self._cfg.rules_dir.resolve()) + except ValueError as exc: + raise RuleDraftBackendError( + f'Draft path {draft_path!r} escapes the configured rules directory.', + status_code=400, + ) from exc + return candidate + + 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: + target = self._resolve(draft_path) + if is_new_rule and target.exists(): + raise RuleDraftBackendError( + f'A file already exists at {draft_path!r}. Pick a different filename or edit the existing rule.', + status_code=409, + ) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(sml_source, encoding='utf-8') + + main_sml_updated = False + if wire_into_main: + main_path = self._cfg.rules_dir / 'main.sml' + if not main_path.exists(): + raise RuleDraftBackendError( + f'wire_into_main requested but main.sml does not exist at {main_path}.', + status_code=409, + ) + main_contents = main_path.read_text(encoding='utf-8') + if not git_common.require_already_present(main_contents, draft_path): + main_path.write_text(git_common.append_require_to_main(main_contents, draft_path), encoding='utf-8') + main_sml_updated = True + + verb = 'Created' if is_new_rule else 'Updated' + return SubmissionResult( + title=f'{verb} {draft_path} in local rules directory', + url=None, + main_sml_updated=main_sml_updated, + extras={'path_on_disk': str(target)}, + ) + + def list_pending_drafts(self) -> list[PendingDraft]: + # Local backend has no review queue; submissions take effect immediately. + return [] diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_null.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_null.py new file mode 100644 index 00000000..161d5495 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_null.py @@ -0,0 +1,41 @@ +"""Default submission backend: nothing is configured, so every call fails fast. + +Ships as the default so an unconfigured upstream install never opens a PR or +writes a file without an adopter explicitly opting into a backend. +""" + +from __future__ import annotations + +from ._rule_drafts_backend import ( + PendingDraft, + RuleDraftBackendError, + SubmissionResult, +) + + +class NullBackend: + name = 'null' + + @staticmethod + def _err() -> RuleDraftBackendError: + return RuleDraftBackendError( + 'No rule-submission backend is configured. Set OSPREY_RULES_SUBMISSION_BACKEND ' + 'to one of: github, gitlab, local. See docs for what each one needs.', + status_code=503, + ) + + 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: + raise self._err() + + def list_pending_drafts(self) -> list[PendingDraft]: + raise self._err() diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/rule_drafts.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/rule_drafts.py new file mode 100644 index 00000000..588a9942 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/rule_drafts.py @@ -0,0 +1,602 @@ +from __future__ import annotations + +import logging +import re +from collections.abc import Iterable +from typing import Any + +from flask import Blueprint, jsonify, request +from osprey.engine.ast.error_utils import SpanWithHint +from osprey.engine.ast.grammar import ( + Assign, + BinaryComparison, + Call, + FormatString, + Name, + Not, + Number, + Source, + String, + UnaryOperation, +) +from osprey.engine.ast.grammar import ( + List as AstList, +) +from osprey.engine.ast.sources import Sources +from osprey.engine.ast_validator import validate_sources +from osprey.engine.ast_validator.validation_context import ( + ValidationError, + ValidationFailed, + ValidationWarning, +) +from osprey.worker.lib.singletons import ENGINE +from osprey.worker.ui_api.osprey.lib.abilities import CanEditRuleDrafts, require_ability +from osprey.worker.ui_api.osprey.lib.auth import get_current_user_email + +from . import _rule_drafts_backend as backends +from ._engine_ast_utils import get_func_identifier + +logger = logging.getLogger(__name__) + +blueprint = Blueprint('rule_drafts', __name__) + +_VALID_PATH = re.compile(r'^[A-Za-z0-9_./-]+\.sml$') +_VALID_RULE_NAME = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') + + +def _format_validation_message(msg: ValidationError | ValidationWarning) -> dict[str, Any]: + identifier: str | None = None + try: + node = msg.span.ast_node + if isinstance(node, Name): + identifier = node.identifier + except Exception: + pass + + defined_in: list[str] = [] + for additional in msg.additional_spans: + span = additional.span if isinstance(additional, SpanWithHint) else additional + defined_in.append(span.source.path) + + return { + 'message': msg.message, + 'hint': msg.hint, + 'source_path': msg.source.path, + 'line': msg.span.start_line, + 'column': msg.span.start_pos, + 'rendered': msg.rendered(), + 'identifier': identifier, + 'defined_in_source_paths': defined_in, + } + + +def _suggest_imports_from_errors( + draft_path: str, + errors: list[dict[str, Any]], +) -> list[str]: + """Collect source paths the draft references but doesn't import. + + Pulled from each error's `defined_in_source_paths`. main.sml is the engine + entry point and is never importable; the draft can't import itself either. + """ + suggested: set[str] = set() + for err in errors: + for path in err.get('defined_in_source_paths') or []: + if path == 'main.sml' or path == draft_path: + continue + suggested.add(path) + return sorted(suggested) + + +def _validate_path(path: str) -> str | None: + if not _VALID_PATH.match(path): + return f'Path {path!r} is not a valid SML source path (must end .sml and contain only [A-Za-z0-9_./-]).' + if '..' in path.split('/'): + return f'Path {path!r} contains a parent-directory segment.' + return None + + +def _current_sources_dict() -> dict[str, str]: + engine = ENGINE.instance() + return engine.execution_graph.validated_sources.sources.to_dict() + + +@blueprint.route('/rule-drafts/source', methods=['GET']) +@require_ability(CanEditRuleDrafts) +def get_source() -> Any: + path = request.args.get('path', '').strip() + err = _validate_path(path) + if err: + return jsonify({'error': err}), 400 + + engine = ENGINE.instance() + source: Source | None = engine.execution_graph.validated_sources.sources.get_by_path(path) + if source is None: + return jsonify({'error': f'No source found at {path!r}.'}), 404 + return jsonify({'path': source.path, 'contents': source.contents}) + + +@blueprint.route('/rule-drafts/validate', methods=['POST']) +@require_ability(CanEditRuleDrafts) +def validate_draft() -> Any: + """Splice the draft into the engine's sources and re-run AST validation. + + A 200 with `{ok: false, errors: [...]}` means the SML failed validation; the + response is still JSON so the editor can render structured errors inline. + A 400 means the request itself was malformed (bad path, missing source). + """ + payload = request.get_json(silent=True) or {} + path = (payload.get('path') or '').strip() + source_text = payload.get('source', '') + + path_err = _validate_path(path) + if path_err: + return jsonify({'error': path_err}), 400 + if not isinstance(source_text, str): + return jsonify({'error': 'source must be a string.'}), 400 + + spliced = _current_sources_dict() + spliced[path] = source_text + + try: + sources = Sources.from_dict(spliced) + except Exception as exc: + # Sources.from_dict asserts on shape (e.g., missing main.sml). Surface as a structured error + # so the editor can show "you broke main.sml" without crashing. + return jsonify( + { + 'ok': False, + 'errors': [{'message': str(exc), 'hint': '', 'source_path': path, 'line': 0, 'column': 0}], + 'warnings': [], + } + ), 400 + + engine = ENGINE.instance() + try: + validated = validate_sources( + sources, + udf_registry=engine.udf_registry, + validator_registry=engine.validator_registry, + ) + except ValidationFailed as exc: + formatted_errors = [_format_validation_message(e) for e in exc.errors] + return jsonify( + { + 'ok': False, + 'errors': formatted_errors, + 'warnings': [_format_validation_message(w) for w in exc.warnings], + 'suggested_imports': _suggest_imports_from_errors(path, formatted_errors), + } + ) + + return jsonify( + { + 'ok': True, + 'errors': [], + 'warnings': [_format_validation_message(w) for w in validated.warnings], + 'suggested_imports': [], + } + ) + + +def _iter_top_level_assigns(sources: Iterable[Source]) -> Iterable[tuple[Source, Assign]]: + for source in sources: + for statement in source.ast_root.statements: + if isinstance(statement, Assign): + yield source, statement + + +def _is_rule_call(node: Any) -> bool: + return isinstance(node, Call) and get_func_identifier(node) == 'Rule' + + +def _collect_features(sources: Iterable[Source]) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for source, assign in _iter_top_level_assigns(sources): + name = assign.target.identifier + if name in seen: + continue + # Skip `MyRule = Rule(...)` assigns; the builder dropdown is for values + # a user can reference inside conditions, not for rule definitions. + if _is_rule_call(assign.value): + continue + seen.add(name) + out.append( + { + 'name': name, + 'source_path': source.path, + 'source_line': assign.span.start_line, + } + ) + out.sort(key=lambda item: item['name']) + return out + + +def _collect_udfs() -> list[dict[str, Any]]: + engine = ENGINE.instance() + udf_registry = engine.udf_registry + out: list[dict[str, Any]] = [] + for func in sorted(udf_registry.iter_functions(), key=lambda f: f.__name__): + try: + args_type = func.get_arguments_type() + rvalue_type = func.get_rvalue_type() + except Exception: + continue + arguments: list[dict[str, Any]] = [] + try: + items = args_type.items().items() + except Exception: + items = [] + for arg_name, arg_type in items: + arguments.append( + { + 'name': arg_name, + 'type_name': getattr(arg_type, '__name__', str(arg_type)), + } + ) + out.append( + { + 'name': func.__name__, + 'return_type': getattr(rvalue_type, '__name__', str(rvalue_type)), + 'arguments': arguments, + } + ) + return out + + +def _collect_effects(sources: Iterable[Source]) -> list[str]: + """Names of UDFs that appear inside a `WhenRules(then=[...])` block. + + Used as the effect dropdown. Sourced from real usage rather than from the + UDF registry because the registry holds every UDF and we want a shortlist + of things users actually use as actions. + """ + seen: set[str] = set() + + def _walk(node: Any) -> None: + if isinstance(node, Call): + ident = get_func_identifier(node) + if ident: + seen.add(ident) + for arg in node.arguments: + _walk(arg.value) + elif isinstance(node, AstList): + for item in node.items: + _walk(item) + elif isinstance(node, Assign): + _walk(node.value) + + for source in sources: + for statement in source.ast_root.statements: + call_node: Call | None = None + if isinstance(statement, Call) and get_func_identifier(statement) == 'WhenRules': + call_node = statement + elif ( + isinstance(statement, Assign) + and isinstance(statement.value, Call) + and get_func_identifier(statement.value) == 'WhenRules' + ): + call_node = statement.value + if call_node is None: + continue + then_arg = call_node.find_argument('then') + if then_arg is None: + continue + _walk(then_arg.value) + + return sorted(seen) + + +@blueprint.route('/rule-drafts/vocabulary', methods=['GET']) +@require_ability(CanEditRuleDrafts) +def vocabulary() -> Any: + engine = ENGINE.instance() + sources = list(engine.execution_graph.validated_sources.sources) + features = _collect_features(sources) + udfs = _collect_udfs() + effects = _collect_effects(sources) + source_files = sorted(s.path for s in sources) + return jsonify( + { + 'features': features, + 'udfs': udfs, + 'effects': effects, + 'source_files': source_files, + } + ) + + +@blueprint.route('/rule-drafts/submit', methods=['POST']) +@require_ability(CanEditRuleDrafts) +def submit_draft() -> Any: + payload = request.get_json(silent=True) or {} + path = (payload.get('path') or '').strip() + source_text = payload.get('source', '') + rule_name = (payload.get('rule_name') or '').strip() + summary = (payload.get('summary') or '').strip() + is_new_rule = bool(payload.get('is_new_rule', False)) + wire_into_main = bool(payload.get('wire_into_main', False)) + + path_err = _validate_path(path) + if path_err: + return jsonify({'error': path_err}), 400 + if path == 'main.sml': + # main.sml is the engine entry point. Submitting a draft *as* main.sml + # would wholesale-replace it (immediate live effect on the local + # backend). Wiring a rule in is a controlled one-line append handled by + # the wire_into_main option, not a draft submission. + return jsonify( + { + 'error': 'main.sml is the engine entry point and cannot be submitted as a draft. ' + 'Use "turn this rule on" to add a Require line instead.' + } + ), 400 + if not isinstance(source_text, str) or not source_text.strip(): + return jsonify({'error': 'source must be a non-empty string.'}), 400 + if not _VALID_RULE_NAME.match(rule_name): + return jsonify({'error': 'rule_name must be a valid SML identifier ([A-Za-z_][A-Za-z0-9_]*).'}), 400 + + # Re-validate server-side so a client that skips the validate step still cannot push uncompilable SML. + spliced = _current_sources_dict() + spliced[path] = source_text + try: + sources = Sources.from_dict(spliced) + validate_sources( + sources, + udf_registry=ENGINE.instance().udf_registry, + validator_registry=ENGINE.instance().validator_registry, + ) + except ValidationFailed as exc: + return jsonify( + { + 'error': 'Validation failed; fix errors before submitting.', + 'errors': [_format_validation_message(e) for e in exc.errors], + } + ), 400 + except Exception as exc: + return jsonify({'error': f'Could not assemble sources: {exc}'}), 400 + + try: + backend = backends.load_backend() + result = backend.submit_draft( + draft_path=path, + sml_source=source_text, + rule_name=rule_name, + summary=summary, + author_email=get_current_user_email(), + is_new_rule=is_new_rule, + wire_into_main=wire_into_main, + ) + except backends.RuleDraftBackendError as exc: + return jsonify({'error': exc.message}), exc.status_code + + return jsonify(result.to_json()) + + +@blueprint.route('/rule-drafts/pending', methods=['GET']) +@require_ability(CanEditRuleDrafts) +def pending_drafts() -> Any: + try: + backend = backends.load_backend() + drafts = backend.list_pending_drafts() + except backends.RuleDraftBackendError as exc: + return jsonify({'error': exc.message, 'pending': []}), exc.status_code + return jsonify({'pending': [d.to_json() for d in drafts]}) + + +# The set of comparator strings the Rule Builder UI can render. +_BUILDER_COMPARATORS = {'==', '!=', '>', '<', '>=', '<='} + + +def _condition_from_value(node: Any, feature: str, operator: str) -> dict[str, Any] | None: + """Build a builder Condition row from an RHS node, returning None if the node + isn't a literal or a bare Name (the only RHS shapes the builder supports).""" + if isinstance(node, Name): + return {'feature': feature, 'operator': operator, 'rhs': node.identifier, 'rhsIsFeature': True} + if isinstance(node, String): + return {'feature': feature, 'operator': operator, 'rhs': node.value, 'rhsIsFeature': False} + if isinstance(node, Number): + return {'feature': feature, 'operator': operator, 'rhs': str(node.value), 'rhsIsFeature': False} + return None + + +def _parse_text_contains_call(call: Call, operator: str) -> dict[str, Any] | None: + """Convert a `TextContains(text=Name, phrase=...)` call to an includes/excludes row. + + Returns None if the call doesn't have the exact shape the builder emits. + """ + if get_func_identifier(call) != 'TextContains': + return None + text_arg = call.find_argument('text') + phrase_arg = call.find_argument('phrase') + if text_arg is None or phrase_arg is None: + return None + if not isinstance(text_arg.value, Name): + return None + feature = text_arg.value.identifier + return _condition_from_value(phrase_arg.value, feature, operator) + + +def _parse_condition(node: Any) -> dict[str, Any] | None: + if isinstance(node, UnaryOperation) and isinstance(node.operator, Not) and isinstance(node.operand, Call): + return _parse_text_contains_call(node.operand, 'excludes') + if isinstance(node, Call): + return _parse_text_contains_call(node, 'includes') + if isinstance(node, BinaryComparison): + if not isinstance(node.left, Name): + return None + operator = node.comparator.original_comparator + if operator not in _BUILDER_COMPARATORS: + return None + return _condition_from_value(node.right, node.left.identifier, operator) + return None + + +def _parse_outcome_arg(arg: Any) -> dict[str, Any] | None: + """Convert one `Call.arguments[i]` into a builder OutcomeArg, or None for + anything richer than a literal or bare Name reference.""" + val = arg.value + if isinstance(val, Name): + return {'name': arg.name, 'value': val.identifier, 'isFeature': True} + if isinstance(val, String): + return {'name': arg.name, 'value': val.value, 'isFeature': False} + if isinstance(val, Number): + return {'name': arg.name, 'value': str(val.value), 'isFeature': False} + return None + + +def _parse_outcome(node: Any) -> dict[str, Any] | None: + if not isinstance(node, Call): + return None + effect = get_func_identifier(node) + if effect is None: + return None + args: list[dict[str, Any]] = [] + for arg in node.arguments: + parsed = _parse_outcome_arg(arg) + if parsed is None: + return None + args.append(parsed) + return {'effect': effect, 'args': args} + + +def _parse_into_builder_model(source: Source) -> dict[str, Any]: + """Walk the AST of a single draft Source and either return a populated + builder model JSON or `{supported: False, reason: ...}`. + + The builder's expressible subset is deliberately narrow: optional Import + and Require statements (ignored for the model), exactly one + `RuleName = Rule(when_all=[...], description='...')`, and an optional + `WhenRules(rules_any=[RuleName], then=[...])` whose `then` entries are + UDF calls with literal or Name arguments. Anything richer means the file + can't round-trip and the user must use Code Editor. + """ + try: + statements = source.ast_root.statements + except Exception as exc: + return {'supported': False, 'reason': f'could not parse SML: {exc}'} + + rule_assign: Assign | None = None + when_rules_call: Call | None = None + + for stmt in statements: + if isinstance(stmt, Call): + ident = get_func_identifier(stmt) + if ident in ('Import', 'Require'): + continue + if ident == 'WhenRules': + if when_rules_call is not None: + return { + 'supported': False, + 'reason': 'multiple WhenRules blocks; Rule Builder edits one rule at a time', + } + when_rules_call = stmt + continue + return {'supported': False, 'reason': f'top-level call to `{ident}` is not supported by Rule Builder'} + if isinstance(stmt, Assign) and isinstance(stmt.value, Call) and get_func_identifier(stmt.value) == 'Rule': + if rule_assign is not None: + return { + 'supported': False, + 'reason': 'multiple Rule definitions in one file; Rule Builder edits one rule at a time', + } + rule_assign = stmt + continue + if isinstance(stmt, Assign): + return { + 'supported': False, + 'reason': f'helper assignment `{stmt.target.identifier} = ...` is not supported by Rule Builder', + } + return {'supported': False, 'reason': f'unsupported top-level statement: {type(stmt).__name__}'} + + if rule_assign is None: + return {'supported': False, 'reason': 'no Rule(...) definition found in this file'} + + rule_name = rule_assign.target.identifier + rule_call = rule_assign.value + assert isinstance(rule_call, Call) + + description = '' + description_arg = rule_call.find_argument('description') + if description_arg is not None: + if isinstance(description_arg.value, String): + description = description_arg.value.value + elif isinstance(description_arg.value, FormatString): + # Round-trip the raw template; the builder doesn't expose format-string editing. + description = description_arg.value.format_string + else: + return {'supported': False, 'reason': 'rule description must be a string literal'} + + when_all_arg = rule_call.find_argument('when_all') + if when_all_arg is None or not isinstance(when_all_arg.value, AstList): + return {'supported': False, 'reason': 'Rule must have `when_all=[...]`'} + + conditions: list[dict[str, Any]] = [] + for item in when_all_arg.value.items: + cond = _parse_condition(item) + if cond is None: + return { + 'supported': False, + 'reason': 'one or more conditions use expressions Rule Builder cannot represent', + } + conditions.append(cond) + if not conditions: + # Builder needs at least one row to render anything sensible; matching the EMPTY_BUILDER_MODEL default. + conditions = [{'feature': '', 'operator': '==', 'rhs': '', 'rhsIsFeature': False}] + + outcomes: list[dict[str, Any]] = [] + if when_rules_call is not None: + rules_any_arg = when_rules_call.find_argument('rules_any') + if rules_any_arg is not None and isinstance(rules_any_arg.value, AstList): + for item in rules_any_arg.value.items: + if not isinstance(item, Name) or item.identifier != rule_name: + return { + 'supported': False, + 'reason': 'WhenRules.rules_any must reference only the rule being edited', + } + then_arg = when_rules_call.find_argument('then') + if then_arg is not None and isinstance(then_arg.value, AstList): + for item in then_arg.value.items: + outcome = _parse_outcome(item) + if outcome is None: + return { + 'supported': False, + 'reason': 'one or more outcomes use expressions Rule Builder cannot represent', + } + outcomes.append(outcome) + if not outcomes: + outcomes = [{'effect': '', 'args': []}] + + return { + 'supported': True, + 'model': { + 'ruleName': rule_name, + 'description': description, + 'conditions': conditions, + 'outcomes': outcomes, + }, + } + + +@blueprint.route('/rule-drafts/parse-into-builder', methods=['POST']) +@require_ability(CanEditRuleDrafts) +def parse_into_builder() -> Any: + """Attempt to render an existing SML file as a Rule Builder model. + + Returns `{supported: true, model: {...}}` if the file fits the builder's + expressible subset, or `{supported: false, reason: "..."}` otherwise. The + UI uses this to decide whether to enable the Rule Builder toggle when + editing an existing rule. + """ + payload = request.get_json(silent=True) or {} + path = (payload.get('path') or '').strip() + source_text = payload.get('source', '') + + path_err = _validate_path(path) + if path_err: + return jsonify({'error': path_err}), 400 + if not isinstance(source_text, str): + return jsonify({'error': 'source must be a string.'}), 400 + + source = Source(path=path, contents=source_text) + return jsonify(_parse_into_builder_model(source)) diff --git a/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rule_drafts.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rule_drafts.py new file mode 100644 index 00000000..99fde27b --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rule_drafts.py @@ -0,0 +1,1068 @@ +import base64 +import json +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest +import requests +import requests_mock as requests_mock_module +from flask import Response, url_for +from flask.testing import FlaskClient +from osprey.worker.lib.snowflake import Snowflake + + +@pytest.fixture(autouse=True) +def _mock_audit_snowflake(): + # The after_request audit hook mints a snowflake id, which normally means an + # HTTP call to the snowflake-id-worker service. Tests that wrap a request in + # a requests_mock.Mocker would otherwise trip NoMockAddress on that call, so + # neutralize it here (the audit log's persist() is already mocked in the + # shared conftest). + with patch('osprey.worker.ui_api.osprey.lib.audit.generate_snowflake', return_value=Snowflake(1)): + yield + + +def _set_github_backend(monkeypatch: pytest.MonkeyPatch, **overrides: str) -> None: + """Configure the github backend with sensible defaults for tests; overrides win.""" + defaults = { + 'OSPREY_RULES_SUBMISSION_BACKEND': 'github', + 'OSPREY_RULES_REPO': 'roostorg/osprey-rules', + 'OSPREY_GITHUB_TOKEN': 'gh_fake_token', + 'OSPREY_RULES_BASE_BRANCH': 'main', + } + for k, v in {**defaults, **overrides}.items(): + monkeypatch.setenv(k, v) + + +_acl_with_draft_ability = json.dumps( + { + 'ui_config': {}, + 'labels': {}, + 'acl': { + 'users': { + 'local-dev@localhost': { + 'abilities': [ + {'name': 'CAN_VIEW_DOCS', 'allow_all': True}, + {'name': 'CAN_EDIT_RULE_DRAFTS', 'allow_all': True}, + ], + }, + }, + }, + } +) + +_acl_without_draft_ability = json.dumps( + { + 'ui_config': {}, + 'labels': {}, + 'acl': { + 'users': { + 'local-dev@localhost': {'abilities': [{'name': 'CAN_VIEW_DOCS', 'allow_all': True}]}, + }, + }, + } +) + +_base_sources = { + 'config.yaml': _acl_with_draft_ability, + 'models/base.sml': """ + UserId: str = JsonData(path='$.user_id') + PostText: str = JsonData(path='$.post_text') + """, + 'main.sml': """ + Import(rules=['models/base.sml']) + + ContainsHello = Rule( + when_all=[PostText == 'hello'], + description='Post contains hello', + ) + + WhenRules( + rules_any=[ContainsHello], + then=[DeclareVerdict(verdict=UserId)], + ) + """, +} + + +@pytest.mark.use_rules_sources(_base_sources) +def test_get_source_returns_contents(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': 'main.sml'}) + assert res.status_code == 200 + assert res.json is not None + assert res.json['path'] == 'main.sml' + assert 'ContainsHello' in res.json['contents'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_get_source_rejects_bad_path(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': '../etc/passwd.sml'}) + assert res.status_code == 400 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_get_source_404_for_unknown_path(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': 'rules/does_not_exist.sml'}) + assert res.status_code == 404 + + +@pytest.mark.use_rules_sources( + { + 'config.yaml': _acl_without_draft_ability, + 'main.sml': "UserId: str = JsonData(path='$.user_id')", + } +) +def test_endpoints_require_can_edit_rule_drafts(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.get_source'), query_string={'path': 'main.sml'}) + assert res.status_code == 401 + res = client.post( + url_for('rule_drafts.validate_draft'), + json={'path': 'rules/x.sml', 'source': ''}, + ) + assert res.status_code == 401 + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/x.sml', 'source': ''}, + ) + assert res.status_code == 401 + res = client.get(url_for('rule_drafts.vocabulary')) + assert res.status_code == 401 + res = client.get(url_for('rule_drafts.pending_drafts')) + assert res.status_code == 401 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_clean_draft_returns_ok(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + }, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['ok'] is True + assert body['errors'] == [] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_broken_draft_returns_structured_errors(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/broken.sml', + 'source': 'this is not valid SML at all *** !!!', + }, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['ok'] is False + assert len(body['errors']) >= 1 + err = body['errors'][0] + assert set(err.keys()) >= {'message', 'hint', 'source_path', 'line', 'column', 'rendered'} + + +@pytest.mark.use_rules_sources( + { + 'config.yaml': _acl_with_draft_ability, + 'main.sml': "Import(rules=['models/post.sml'])", + 'models/post.sml': "PostText: str = JsonData(path='$.post_text')", + } +) +def test_validate_returns_suggested_imports_for_unimported_identifier(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/uses_post_text.sml', + 'source': "MyRule = Rule(when_all=[PostText == 'hi'], description='hi')", + }, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['ok'] is False + assert body['suggested_imports'] == ['models/post.sml'] + assert any(e.get('identifier') == 'PostText' for e in body['errors']) + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_clean_draft_has_empty_suggested_imports(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + }, + ) + assert res.status_code == 200 + assert res.json is not None + assert res.json['suggested_imports'] == [] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_validate_rejects_bad_path(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.validate_draft'), + json={'path': 'rules/x.txt', 'source': ''}, + ) + assert res.status_code == 400 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_vocabulary_returns_features_udfs_effects(client: 'FlaskClient[Response]') -> None: + res = client.get(url_for('rule_drafts.vocabulary')) + assert res.status_code == 200 + body = res.json + assert body is not None + assert set(body.keys()) == {'features', 'udfs', 'effects', 'source_files'} + + feature_names = {f['name'] for f in body['features']} + assert {'UserId', 'PostText'}.issubset(feature_names) + assert 'ContainsHello' not in feature_names + + udf_names = {u['name'] for u in body['udfs']} + assert 'JsonData' in udf_names + assert 'Rule' in udf_names + assert 'DeclareVerdict' in body['effects'] + + assert 'main.sml' in body['source_files'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_returns_503_when_no_backend_configured( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv('OSPREY_RULES_SUBMISSION_BACKEND', raising=False) + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': 'demo', + 'is_new_rule': True, + }, + ) + assert res.status_code == 503 + body = res.json + assert body is not None + assert 'No rule-submission backend is configured' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_returns_503_when_github_missing_required_env( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv('OSPREY_RULES_SUBMISSION_BACKEND', 'github') + monkeypatch.delenv('OSPREY_RULES_REPO', raising=False) + monkeypatch.delenv('OSPREY_GITHUB_TOKEN', raising=False) + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': 'demo', + 'is_new_rule': True, + }, + ) + assert res.status_code == 503 + body = res.json + assert body is not None + assert 'OSPREY_RULES_REPO' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_blocks_invalid_sml_before_calling_github( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch) + + with requests_mock_module.Mocker() as m: + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/bad.sml', + 'source': 'this is not valid SML !!!', + 'rule_name': 'BadRule', + 'summary': 'should not submit', + 'is_new_rule': True, + }, + ) + + assert res.status_code == 400 + assert m.call_count == 0 + body = res.json + assert body is not None + assert body['error'].startswith('Validation failed') + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_rejects_bad_rule_name(client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch) -> None: + _set_github_backend(monkeypatch) + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new.sml', + 'source': "X = Rule(when_all=[PostText == 'x'], description='x')", + 'rule_name': '1-not-an-identifier', + 'summary': '', + 'is_new_rule': True, + }, + ) + assert res.status_code == 400 + + +def test_submission_result_extras_cannot_shadow_canonical_fields() -> None: + from osprey.worker.ui_api.osprey.views._rule_drafts_backend import SubmissionResult + + result = SubmissionResult( + title='real title', + url='https://real.example/pr/1', + extras={'title': 'spoofed', 'url': 'https://evil.example', 'pr_number': 7}, + ) + out = result.to_json() + assert out['title'] == 'real title' + assert out['url'] == 'https://real.example/pr/1' + assert out['main_sml_updated'] is False + # Non-colliding extras still pass through for adopters that want them. + assert out['pr_number'] == 7 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_rejects_main_sml_as_draft_path( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch) + with requests_mock_module.Mocker() as m: + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'main.sml', + 'source': 'Import(rules=[])', + 'rule_name': 'Whatever', + 'summary': '', + 'is_new_rule': False, + }, + ) + # Guard fires before any network call: the entry point is never a draft. + assert m.call_count == 0 + assert res.status_code == 400 + body = res.json + assert body is not None + assert 'main.sml' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_surfaces_github_connection_error_as_502( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch) + repo_url = 'https://api.github.com/repos/roostorg/osprey-rules' + with requests_mock_module.Mocker() as m: + m.get(f'{repo_url}/git/ref/heads/main', exc=requests.exceptions.ConnectTimeout) + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + }, + ) + # A forge outage is a structured 502, not an unhandled 500. + assert res.status_code == 502 + body = res.json + assert body is not None + assert 'Could not reach the git host' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_happy_path_creates_branch_commits_and_opens_pr( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch, OSPREY_RULES_PATH_IN_REPO='rules') + + repo_url = 'https://api.github.com/repos/roostorg/osprey-rules' + + with requests_mock_module.Mocker() as m: + m.get(f'{repo_url}/git/ref/heads/main', json={'object': {'sha': 'BASE_SHA'}}) + m.get(f'{repo_url}/contents/rules/new_rule.sml', status_code=404) + m.post(f'{repo_url}/git/refs', status_code=201, json={}) + m.put(f'{repo_url}/contents/rules/new_rule.sml', status_code=201, json={}) + m.post( + f'{repo_url}/pulls', + status_code=201, + json={'number': 42, 'html_url': 'https://github.com/roostorg/osprey-rules/pull/42'}, + ) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + # Bare filename: OSPREY_RULES_PATH_IN_REPO='rules' prepends the + # subdirectory, so the file lands at rules/new_rule.sml. + 'path': 'new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': 'add bye rule', + 'is_new_rule': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['title'] == 'Pull request #42 opened' + assert body['url'] == 'https://github.com/roostorg/osprey-rules/pull/42' + assert body['main_sml_updated'] is False + # GitHub-specific extras are surfaced for adopters that want them. + assert body['pr_number'] == 42 + assert body['pr_url'] == 'https://github.com/roostorg/osprey-rules/pull/42' + assert body['path_in_repo'] == 'rules/new_rule.sml' + assert body['branch'].startswith('rule-draft/local-dev/AnotherRule-') + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_wire_into_main_appends_require_line( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch) + + repo_url = 'https://api.github.com/repos/roostorg/osprey-rules' + existing_main = "Import(rules=['models/post.sml'])\n\nRequire(rule='rules/post_contains_hello.sml')\n" + encoded_main = base64.b64encode(existing_main.encode('utf-8')).decode('ascii') + + with requests_mock_module.Mocker() as m: + m.get(f'{repo_url}/git/ref/heads/main', json={'object': {'sha': 'BASE_SHA'}}) + m.get(f'{repo_url}/contents/rules/new_rule.sml', status_code=404) + m.post(f'{repo_url}/git/refs', status_code=201, json={}) + m.put(f'{repo_url}/contents/rules/new_rule.sml', status_code=201, json={}) + m.get(f'{repo_url}/contents/main.sml', json={'sha': 'MAIN_SHA', 'content': encoded_main, 'type': 'file'}) + main_put = m.put(f'{repo_url}/contents/main.sml', status_code=200, json={}) + m.post( + f'{repo_url}/pulls', + status_code=201, + json={'number': 99, 'html_url': 'https://github.com/roostorg/osprey-rules/pull/99'}, + ) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': 'add bye rule and wire it in', + 'is_new_rule': True, + 'wire_into_main': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['main_sml_updated'] is True + + main_put_request = main_put.last_request + assert main_put_request is not None + posted = main_put_request.json() + decoded_new_main = base64.b64decode(posted['content']).decode('utf-8') + assert "Require(rule='rules/new_rule.sml')" in decoded_new_main + # The existing Require for post_contains_hello.sml should still be present untouched. + assert "Require(rule='rules/post_contains_hello.sml')" in decoded_new_main + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_wire_into_main_skips_when_require_already_present( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch) + + repo_url = 'https://api.github.com/repos/roostorg/osprey-rules' + existing_main = "Require(rule='rules/already_here.sml')\n" + encoded_main = base64.b64encode(existing_main.encode('utf-8')).decode('ascii') + + with requests_mock_module.Mocker() as m: + m.get(f'{repo_url}/git/ref/heads/main', json={'object': {'sha': 'BASE_SHA'}}) + m.get(f'{repo_url}/contents/rules/already_here.sml', status_code=404) + m.post(f'{repo_url}/git/refs', status_code=201, json={}) + m.put(f'{repo_url}/contents/rules/already_here.sml', status_code=201, json={}) + m.get(f'{repo_url}/contents/main.sml', json={'sha': 'MAIN_SHA', 'content': encoded_main, 'type': 'file'}) + main_put = m.put(f'{repo_url}/contents/main.sml', status_code=200, json={}) + m.post( + f'{repo_url}/pulls', + status_code=201, + json={'number': 100, 'html_url': 'https://github.com/roostorg/osprey-rules/pull/100'}, + ) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/already_here.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + 'wire_into_main': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['main_sml_updated'] is False + # main.sml fetch happens but the PUT to update it must not. + assert main_put.call_count == 0 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_409_if_new_rule_file_already_exists( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch) + + repo_url = 'https://api.github.com/repos/roostorg/osprey-rules' + + with requests_mock_module.Mocker() as m: + m.get(f'{repo_url}/git/ref/heads/main', json={'object': {'sha': 'BASE_SHA'}}) + m.get( + f'{repo_url}/contents/new_rule.sml', + json={'sha': 'EXISTING_BLOB_SHA', 'type': 'file'}, + ) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + }, + ) + + assert res.status_code == 409 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_pending_filters_to_rules_path_and_sml( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_github_backend(monkeypatch, OSPREY_RULES_PATH_IN_REPO='rules') + + repo_url = 'https://api.github.com/repos/roostorg/osprey-rules' + + with requests_mock_module.Mocker() as m: + m.get( + f'{repo_url}/pulls', + json=[ + { + 'number': 1, + 'title': 'Add rule X', + 'html_url': 'https://github.com/roostorg/osprey-rules/pull/1', + 'head': {'ref': 'rule-draft/local-dev/X-1'}, + 'user': {'login': 'someone'}, + 'created_at': '2026-06-30T12:00:00Z', + }, + { + 'number': 2, + 'title': 'Update README', + 'html_url': 'https://github.com/roostorg/osprey-rules/pull/2', + 'head': {'ref': 'docs/readme'}, + 'user': {'login': 'someone'}, + 'created_at': '2026-06-30T13:00:00Z', + }, + ], + ) + m.get( + f'{repo_url}/pulls/1/files', + json=[{'filename': 'rules/x.sml'}], + ) + m.get( + f'{repo_url}/pulls/2/files', + json=[{'filename': 'README.md'}], + ) + + res = client.get(url_for('rule_drafts.pending_drafts')) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert len(body['pending']) == 1 + entry = body['pending'][0] + assert entry['title'] == 'Add rule X' + assert entry['url'] == 'https://github.com/roostorg/osprey-rules/pull/1' + assert entry['touched_files'] == ['rules/x.sml'] + # GitHub-specific extras carried through for adopters that want them. + assert entry['pr_number'] == 1 + + +@pytest.mark.use_rules_sources(_base_sources) +def test_parse_into_builder_round_trips_a_builder_shaped_rule(client: 'FlaskClient[Response]') -> None: + source = """ +Import(rules=['models/post.sml']) + +ContainsCat = Rule( + when_all=[ + TextContains(text=PostText, phrase='cat'), + EventType == 'create_post', + ], + description='looks for cat', +) + +WhenRules( + rules_any=[ContainsCat], + then=[ + LabelAdd(entity=UserId, label='meow'), + ], +) +""" + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/contains_cat.sml', 'source': source}, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['supported'] is True + model = body['model'] + assert model['ruleName'] == 'ContainsCat' + assert model['description'] == 'looks for cat' + assert model['conditions'] == [ + {'feature': 'PostText', 'operator': 'includes', 'rhs': 'cat', 'rhsIsFeature': False}, + {'feature': 'EventType', 'operator': '==', 'rhs': 'create_post', 'rhsIsFeature': False}, + ] + assert model['outcomes'] == [ + { + 'effect': 'LabelAdd', + 'args': [ + {'name': 'entity', 'value': 'UserId', 'isFeature': True}, + {'name': 'label', 'value': 'meow', 'isFeature': False}, + ], + } + ] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_parse_into_builder_handles_excludes(client: 'FlaskClient[Response]') -> None: + source = "BlocksCat = Rule(when_all=[not TextContains(text=PostText, phrase='cat')], description='no cat')\n" + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/blocks_cat.sml', 'source': source}, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['supported'] is True + assert body['model']['conditions'] == [ + {'feature': 'PostText', 'operator': 'excludes', 'rhs': 'cat', 'rhsIsFeature': False}, + ] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_parse_into_builder_rejects_multiple_rules(client: 'FlaskClient[Response]') -> None: + source = ( + "A = Rule(when_all=[PostText == 'a'], description='a')\nB = Rule(when_all=[PostText == 'b'], description='b')\n" + ) + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/multi.sml', 'source': source}, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['supported'] is False + assert 'multiple Rule definitions' in body['reason'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_parse_into_builder_rejects_helper_assigns(client: 'FlaskClient[Response]') -> None: + source = "Helper = 'cat'\nA = Rule(when_all=[PostText == Helper], description='a')\n" + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/helper.sml', 'source': source}, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['supported'] is False + assert 'helper assignment' in body['reason'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_parse_into_builder_rejects_complex_condition(client: 'FlaskClient[Response]') -> None: + # A boolean operator inside `when_all` would need Code Editor; the builder is AND-only via row repetition. + source = "A = Rule(when_all=[PostText == 'a' and EventType == 'create_post'], description='a')\n" + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/complex.sml', 'source': source}, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['supported'] is False + assert 'Rule Builder cannot represent' in body['reason'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_parse_into_builder_rejects_file_with_no_rule(client: 'FlaskClient[Response]') -> None: + source = "Import(rules=['models/post.sml'])\n" + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/empty.sml', 'source': source}, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['supported'] is False + assert 'no Rule(...)' in body['reason'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_parse_into_builder_rejects_syntax_error(client: 'FlaskClient[Response]') -> None: + res = client.post( + url_for('rule_drafts.parse_into_builder'), + json={'path': 'rules/broken.sml', 'source': 'this is not valid SML at all !!!'}, + ) + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['supported'] is False + assert 'could not parse SML' in body['reason'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_unknown_backend_value_returns_500(client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv('OSPREY_RULES_SUBMISSION_BACKEND', 'gerrit') + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + }, + ) + assert res.status_code == 500 + body = res.json + assert body is not None + assert "Unknown OSPREY_RULES_SUBMISSION_BACKEND 'gerrit'" in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_pending_returns_empty_list_for_null_backend( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.delenv('OSPREY_RULES_SUBMISSION_BACKEND', raising=False) + res = client.get(url_for('rule_drafts.pending_drafts')) + assert res.status_code == 503 + body = res.json + assert body is not None + assert body['pending'] == [] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_submit_github_enterprise_url_is_threaded_into_requests( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + """GitHub Enterprise customers point OSPREY_GITHUB_API_URL at their own host; every API call must use it.""" + _set_github_backend( + monkeypatch, + OSPREY_GITHUB_API_URL='https://github.acme.test/api/v3', + OSPREY_RULES_REPO='acme/rules', + ) + + repo_url = 'https://github.acme.test/api/v3/repos/acme/rules' + + with requests_mock_module.Mocker() as m: + m.get(f'{repo_url}/git/ref/heads/main', json={'object': {'sha': 'BASE_SHA'}}) + m.get(f'{repo_url}/contents/rules/new_rule.sml', status_code=404) + m.post(f'{repo_url}/git/refs', status_code=201, json={}) + m.put(f'{repo_url}/contents/rules/new_rule.sml', status_code=201, json={}) + m.post( + f'{repo_url}/pulls', + status_code=201, + json={'number': 5, 'html_url': 'https://github.acme.test/acme/rules/pull/5'}, + ) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['url'] == 'https://github.acme.test/acme/rules/pull/5' + # If any call had hit api.github.com instead, the Mocker would have raised NoMockAddress. + + +@pytest.mark.use_rules_sources(_base_sources) +def test_local_backend_writes_file_and_returns_no_url( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + rules_dir = Path(tmpdir) + (rules_dir / 'main.sml').write_text("Import(rules=['models/post.sml'])\n", encoding='utf-8') + + monkeypatch.setenv('OSPREY_RULES_SUBMISSION_BACKEND', 'local') + monkeypatch.setenv('OSPREY_RULES_LOCAL_PATH', str(rules_dir)) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': 'add bye rule', + 'is_new_rule': True, + 'wire_into_main': False, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['url'] is None + assert 'rules/new_rule.sml' in body['title'] + assert body['main_sml_updated'] is False + + written = (rules_dir / 'rules' / 'new_rule.sml').read_text(encoding='utf-8') + assert 'AnotherRule' in written + + +@pytest.mark.use_rules_sources(_base_sources) +def test_local_backend_wires_into_main_when_requested( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + rules_dir = Path(tmpdir) + (rules_dir / 'main.sml').write_text("Import(rules=['models/post.sml'])\n", encoding='utf-8') + + monkeypatch.setenv('OSPREY_RULES_SUBMISSION_BACKEND', 'local') + monkeypatch.setenv('OSPREY_RULES_LOCAL_PATH', str(rules_dir)) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + 'wire_into_main': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['main_sml_updated'] is True + updated_main = (rules_dir / 'main.sml').read_text(encoding='utf-8') + assert "Require(rule='rules/new_rule.sml')" in updated_main + + +@pytest.mark.use_rules_sources(_base_sources) +def test_local_backend_rejects_path_traversal(client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + rules_dir = Path(tmpdir) + monkeypatch.setenv('OSPREY_RULES_SUBMISSION_BACKEND', 'local') + monkeypatch.setenv('OSPREY_RULES_LOCAL_PATH', str(rules_dir)) + + # The view layer already rejects '..' in the path, so to actually exercise the + # local backend's own guard we bypass the view-level check by going around the + # API: instantiate the backend and call submit_draft directly with a traversal path. + from osprey.worker.ui_api.osprey.views._rule_drafts_backend import RuleDraftBackendError + from osprey.worker.ui_api.osprey.views._rule_drafts_local import LocalBackend, LocalConfig + + backend = LocalBackend(LocalConfig(rules_dir=rules_dir)) + with pytest.raises(RuleDraftBackendError) as exc_info: + backend.submit_draft( + draft_path='../escape.sml', + sml_source='x = 1', + rule_name='X', + summary='', + author_email='test@local', + is_new_rule=True, + wire_into_main=False, + ) + assert 'escapes the configured rules directory' in exc_info.value.message + + +def _set_gitlab_backend(monkeypatch: pytest.MonkeyPatch, **overrides: str) -> None: + defaults = { + 'OSPREY_RULES_SUBMISSION_BACKEND': 'gitlab', + 'OSPREY_GITLAB_PROJECT': 'alice/osprey-rules', + 'OSPREY_GITLAB_TOKEN': 'gl_fake_token', + 'OSPREY_RULES_BASE_BRANCH': 'main', + } + for k, v in {**defaults, **overrides}.items(): + monkeypatch.setenv(k, v) + + +@pytest.mark.use_rules_sources(_base_sources) +def test_gitlab_submit_returns_503_when_missing_required_env( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv('OSPREY_RULES_SUBMISSION_BACKEND', 'gitlab') + monkeypatch.delenv('OSPREY_GITLAB_PROJECT', raising=False) + monkeypatch.delenv('OSPREY_GITLAB_TOKEN', raising=False) + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + }, + ) + assert res.status_code == 503 + body = res.json + assert body is not None + assert 'OSPREY_GITLAB_PROJECT' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_gitlab_submit_happy_path_opens_merge_request( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_gitlab_backend(monkeypatch, OSPREY_RULES_PATH_IN_REPO='rules') + + project_url = 'https://gitlab.com/api/v4/projects/alice%2Fosprey-rules' + # OSPREY_RULES_PATH_IN_REPO='rules' prepends the subdir to the bare filename, + # so the file lands at rules/new_rule.sml -> 'rules%2Fnew_rule.sml' in the URL. + file_url = f'{project_url}/repository/files/rules%2Fnew_rule.sml' + + with requests_mock_module.Mocker() as m: + m.get(file_url, status_code=404) + m.post(f'{project_url}/repository/branches', status_code=201, json={}) + m.post(file_url, status_code=201, json={}) + m.post( + f'{project_url}/merge_requests', + status_code=201, + json={'iid': 7, 'web_url': 'https://gitlab.com/alice/osprey-rules/-/merge_requests/7'}, + ) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': 'add bye rule', + 'is_new_rule': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['title'] == 'Merge request !7 opened' + assert body['url'] == 'https://gitlab.com/alice/osprey-rules/-/merge_requests/7' + assert body['main_sml_updated'] is False + assert body['mr_iid'] == 7 + assert body['path_in_repo'] == 'rules/new_rule.sml' + + +@pytest.mark.use_rules_sources(_base_sources) +def test_gitlab_self_hosted_url_is_threaded_into_requests( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_gitlab_backend( + monkeypatch, + OSPREY_GITLAB_URL='https://gitlab.acme.test', + OSPREY_GITLAB_PROJECT='acme/rules', + ) + + project_url = 'https://gitlab.acme.test/api/v4/projects/acme%2Frules' + file_url = f'{project_url}/repository/files/new_rule.sml' + + with requests_mock_module.Mocker() as m: + m.get(file_url, status_code=404) + m.post(f'{project_url}/repository/branches', status_code=201, json={}) + m.post(file_url, status_code=201, json={}) + m.post( + f'{project_url}/merge_requests', + status_code=201, + json={'iid': 1, 'web_url': 'https://gitlab.acme.test/acme/rules/-/merge_requests/1'}, + ) + + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'new_rule.sml', + 'source': "Import(rules=['models/base.sml'])\nAnotherRule = Rule(when_all=[PostText == 'bye'], description='bye')", + 'rule_name': 'AnotherRule', + 'summary': '', + 'is_new_rule': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['url'] == 'https://gitlab.acme.test/acme/rules/-/merge_requests/1' + + +@pytest.mark.use_rules_sources(_base_sources) +def test_gitlab_pending_filters_to_rules_path_and_sml( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_gitlab_backend(monkeypatch, OSPREY_RULES_PATH_IN_REPO='rules') + + project_url = 'https://gitlab.com/api/v4/projects/alice%2Fosprey-rules' + + with requests_mock_module.Mocker() as m: + m.get( + f'{project_url}/merge_requests', + json=[ + { + 'iid': 1, + 'title': 'Add rule X', + 'web_url': 'https://gitlab.com/alice/osprey-rules/-/merge_requests/1', + 'source_branch': 'rule-draft/alice/X-1', + 'author': {'username': 'alice'}, + 'created_at': '2026-07-01T12:00:00Z', + }, + { + 'iid': 2, + 'title': 'Update README', + 'web_url': 'https://gitlab.com/alice/osprey-rules/-/merge_requests/2', + 'source_branch': 'docs/readme', + 'author': {'username': 'alice'}, + 'created_at': '2026-07-01T13:00:00Z', + }, + ], + ) + m.get( + f'{project_url}/merge_requests/1/diffs', + json=[{'new_path': 'rules/x.sml'}], + ) + m.get( + f'{project_url}/merge_requests/2/diffs', + json=[{'new_path': 'README.md'}], + ) + + res = client.get(url_for('rule_drafts.pending_drafts')) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert len(body['pending']) == 1 + entry = body['pending'][0] + assert entry['title'] == 'Add rule X' + assert entry['url'] == 'https://gitlab.com/alice/osprey-rules/-/merge_requests/1' + assert entry['touched_files'] == ['rules/x.sml'] + assert entry['mr_iid'] == 1