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..733a2381 100644 --- a/docs/user/manage.md +++ b/docs/user/manage.md @@ -56,3 +56,67 @@ 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) | +| `tangled` | Opens a Tangled pull as an ATProto `sh.tangled.repo.pull` record on the user's PDS. | `OSPREY_TANGLED_HANDLE`, `OSPREY_TANGLED_APP_PASSWORD`, `OSPREY_TANGLED_REPO`, `OSPREY_TANGLED_REPO_DID` | +| `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`. | + +#### `tangled` + +Tangled is an ATProto-native git host: pulls are records, not REST resources. The adapter authenticates the user's Bluesky identity, then writes an `sh.tangled.repo.pull` record (carrying a gzipped `git format-patch` blob) to the user's PDS via `com.atproto.repo.createRecord`. There is no tangled.org-side REST endpoint to write against. + +**Limitations of the current adapter:** new rules only. Edits and the "wire into main.sml" option return 501 because they'd require rendering a diff against an existing file or a multi-file patch. + +| Var | Default | Notes | +|---|---|---| +| `OSPREY_TANGLED_HANDLE` | _required_ | The user's ATProto handle (e.g. `alice.bsky.social`). | +| `OSPREY_TANGLED_APP_PASSWORD` | _required_ | Bluesky app password. Create at https://bsky.app/settings/app-passwords. | +| `OSPREY_TANGLED_REPO` | _required_ | `handle/repo-name` of the target Tangled repo. Used to construct the viewable URL. | +| `OSPREY_TANGLED_REPO_DID` | _required_ | The repo's DID (e.g. `did:plc:abcdefghijklmnopqrstuvwx`). Tangled shows this in the empty-repo onboarding under "Configure your remote to `git@tangled.org:`". | +| `OSPREY_TANGLED_PDS_URL` | `https://bsky.social` | Only override if the user's account lives on a non-Bluesky PDS. | +| `OSPREY_TANGLED_URL` | `https://tangled.org` | Used to construct the human-viewable pull URL. | + +#### `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..513388b0 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_backend.py @@ -0,0 +1,151 @@ +"""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; +`_rule_drafts_tangled.py` for anything that writes an ATProto record. +""" + +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, tangled, 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 == 'tangled': + from ._rule_drafts_tangled import TangledBackend + + return TangledBackend.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, tangled, 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..4cbe08f3 --- /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, tangled, 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_tangled.py b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_tangled.py new file mode 100644 index 00000000..51e3ee52 --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/_rule_drafts_tangled.py @@ -0,0 +1,425 @@ +"""Tangled submission backend. + +Tangled is an ATProto-native git host. Unlike GitHub/GitLab, there's no +`POST /pulls` REST endpoint on tangled.org itself. Pull requests are +`sh.tangled.repo.pull` records living on the submitter's PDS, carrying a +gzipped `git format-patch` blob as an ATProto blob reference. Auth is the +user's ATProto session (Bluesky app password), and the record is written +via `com.atproto.repo.createRecord` against the user's PDS. + +Experimental feature. Feature parity with the git-host backends is narrower: + * new rules only (`is_new_rule=True`); edits return 501 + * `wire_into_main` returns 501; the patch would need to touch two files + * PDS is bsky.social by default; override with OSPREY_TANGLED_PDS_URL if + the user's account lives on a different PDS + +Both 501s document real limits of the current adapter, not runtime failures. +An adopter can extend either surface by implementing the two follow-ups in +the class body (see the RuleDraftBackendError messages for the specifics). +""" + +from __future__ import annotations + +import gzip +import hashlib +import os +import re +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from email.utils import format_datetime +from typing import Any + +from . import _rule_drafts_git_common as git_common +from ._rule_drafts_backend import ( + PendingDraft, + RuleDraftBackendError, + SubmissionResult, +) + +DEFAULT_PDS_URL = 'https://bsky.social' +DEFAULT_TANGLED_URL = 'https://tangled.org' +PULL_COLLECTION = 'sh.tangled.repo.pull' + + +@dataclass(frozen=True) +class TangledConfig: + handle: str + app_password: str + repo_owner: str + repo_name: str + repo_did: str + base_branch: str + rules_path: str + pds_url: str + tangled_url: str + + @classmethod + def from_env(cls) -> 'TangledConfig': + handle = os.environ.get('OSPREY_TANGLED_HANDLE', '').strip() + app_password = os.environ.get('OSPREY_TANGLED_APP_PASSWORD', '').strip() + # `repo` in the env is "owner/name" like the github/gitlab backends, + # used to construct the viewable URL. Tangled's `sh.tangled.repo.pull` + # record identifies the repo by DID though (v1.14.0+), so we also + # require OSPREY_TANGLED_REPO_DID. Tangled surfaces this DID in the + # empty-repo onboarding message ("Configure your remote to + # git@tangled.org:did:plc:..."). + repo = os.environ.get('OSPREY_TANGLED_REPO', '').strip() + repo_did = os.environ.get('OSPREY_TANGLED_REPO_DID', '').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('/') + pds_url = (os.environ.get('OSPREY_TANGLED_PDS_URL') or DEFAULT_PDS_URL).strip() + tangled_url = (os.environ.get('OSPREY_TANGLED_URL') or DEFAULT_TANGLED_URL).strip() + + if not handle: + raise RuleDraftBackendError( + "OSPREY_TANGLED_HANDLE is not configured; set it to the user's ATProto handle " + '(e.g. "alice.bsky.social").', + status_code=503, + ) + if not app_password: + raise RuleDraftBackendError( + 'OSPREY_TANGLED_APP_PASSWORD is not configured; create an app password at ' + 'https://bsky.app/settings/app-passwords and set it here.', + status_code=503, + ) + if not repo or '/' not in repo: + raise RuleDraftBackendError( + 'OSPREY_TANGLED_REPO is not configured; set it to "owner-handle/repo-name" ' + '(e.g. "alice.bsky.social/osprey-rules").', + status_code=503, + ) + if not repo_did.startswith('did:'): + raise RuleDraftBackendError( + "OSPREY_TANGLED_REPO_DID is not configured; set it to the repo's DID " + '(e.g. "did:plc:abcdefghijklmnopqrstuvwx"). Tangled shows this in the empty-repo ' + 'onboarding message under "Configure your remote to git@tangled.org:".', + status_code=503, + ) + owner, name = repo.split('/', 1) + return cls( + handle=handle, + app_password=app_password, + repo_owner=owner, + repo_name=name, + repo_did=repo_did, + base_branch=base, + rules_path=rules_path, + pds_url=pds_url, + tangled_url=tangled_url, + ) + + +@dataclass(frozen=True) +class _Session: + did: str + access_jwt: str + + +def _create_session(cfg: TangledConfig) -> _Session: + res = git_common.request( + 'POST', + f'{cfg.pds_url}/xrpc/com.atproto.server.createSession', + error_action='authenticating with the PDS', + json={'identifier': cfg.handle, 'password': cfg.app_password}, + ) + if res.status_code == 401: + raise RuleDraftBackendError( + 'ATProto createSession returned 401; check that OSPREY_TANGLED_HANDLE and ' + 'OSPREY_TANGLED_APP_PASSWORD match a real Bluesky app password.', + status_code=502, + ) + if not res.ok: + raise RuleDraftBackendError( + f'ATProto createSession returned {res.status_code}: {res.text[:400]}', + status_code=502, + ) + body = res.json() + return _Session(did=body['did'], access_jwt=body['accessJwt']) + + +def _full_path(cfg: TangledConfig, draft_path: str) -> str: + draft_path = draft_path.lstrip('/') + if cfg.rules_path: + return f'{cfg.rules_path}/{draft_path}' + return draft_path + + +def _git_blob_sha1(contents: str) -> str: + """Git's blob hash: sha1("blob \\0").""" + encoded = contents.encode('utf-8') + header = f'blob {len(encoded)}\0'.encode('utf-8') + return hashlib.sha1(header + encoded).hexdigest() + + +def _rfc2822_now() -> str: + return format_datetime(datetime.now(timezone.utc)) + + +def _fake_commit_sha() -> str: + # git format-patch's "From" header hash is informational; git-am doesn't + # validate it. Any 40-char hex works. + return hashlib.sha1(str(time.time_ns()).encode()).hexdigest() + + +_PATCH_BREAK_RE = re.compile(r'^(diff -|Index: |---|From )') + + +def _quote_patch_break_lines(body: str) -> str: + """Neutralize body lines that git-am would read as patch structure. + + git mailinfo cuts the commit message at the first line that looks like a + diff header or three-dash separator, and mailsplit treats "From " lines as + mbox message boundaries. Left unescaped, a crafted summary could smuggle + hunks for arbitrary files into the patch a maintainer applies. Quoting + those lines keeps them visibly part of the user's text instead. + """ + return '\n'.join(f'> {line}' if _PATCH_BREAK_RE.match(line) else line for line in body.split('\n')) + + +def _format_new_file_patch( + *, + path_in_repo: str, + contents: str, + author_email: str, + subject: str, + body: str, +) -> str: + """Produce a git-format-patch string for adding a single new file. + + Structure follows the shape `git format-patch` emits so tangled's UI (and + a maintainer running `git am`) accept it as-is. The blob sha and the + "From" commit hash are informational and not validated by `git am`, so we + generate stable-looking placeholders rather than pretending to be a real + commit. + """ + # The SML content is safe (every line is `+`-prefixed inside the hunk), + # but the free-text body and the headers are not; keep every sanitization + # at this single choke point. + body = _quote_patch_break_lines(body) + author_email = re.sub(r'[\r\n<>]', '', author_email) + subject = re.sub(r'[\r\n]+', ' ', subject) + if not contents.endswith('\n'): + contents = contents + '\n' + lines = contents.split('\n') + if lines and lines[-1] == '': + lines = lines[:-1] + line_count = len(lines) + blob_hash = _git_blob_sha1(contents)[:7] + commit_sha = _fake_commit_sha() + date = _rfc2822_now() + + added_lines = '\n'.join(f'+{line}' for line in lines) + + return ( + f'From {commit_sha} Mon Sep 17 00:00:00 2001\n' + f'From: Osprey UI <{author_email}>\n' + f'Date: {date}\n' + f'Subject: [PATCH] {subject}\n' + f'\n' + f'{body}\n' + f'---\n' + f' {path_in_repo} | {line_count} {"+" * min(line_count, 30)}\n' + f' 1 file changed, {line_count} insertions(+)\n' + f' create mode 100644 {path_in_repo}\n' + f'\n' + f'diff --git a/{path_in_repo} b/{path_in_repo}\n' + f'new file mode 100644\n' + f'index 0000000..{blob_hash}\n' + f'--- /dev/null\n' + f'+++ b/{path_in_repo}\n' + f'@@ -0,0 +1,{line_count} @@\n' + f'{added_lines}\n' + f'-- \n' + f'2.42.0\n' + ) + + +def _upload_patch_blob(cfg: TangledConfig, session: _Session, patch: str) -> dict[str, Any]: + """Upload the gzipped patch to the PDS and return the ATProto blob ref. + + Tangled's `sh.tangled.repo.pull` schema carries the patch as an + `application/gzip` blob referenced from the record's `rounds[].patchBlob` + field, not as an inline string. Confirmed against a pull record created by + Tangled's own UI: patchBlob is a standard ATProto blob with a + CID-linked ref, mimeType, and size. + """ + gzipped = gzip.compress(patch.encode('utf-8')) + res = git_common.request( + 'POST', + f'{cfg.pds_url}/xrpc/com.atproto.repo.uploadBlob', + error_action='uploading the patch blob', + headers={ + 'Authorization': f'Bearer {session.access_jwt}', + 'Content-Type': 'application/gzip', + }, + data=gzipped, + ) + if not res.ok: + raise RuleDraftBackendError( + f'ATProto uploadBlob returned {res.status_code}: {res.text[:400]}', + status_code=502, + ) + payload = res.json() + blob = payload.get('blob') + if not blob: + raise RuleDraftBackendError( + f'ATProto uploadBlob returned no blob ref: {res.text[:400]}', + status_code=502, + ) + return blob + + +def _create_pull_record( + cfg: TangledConfig, + session: _Session, + *, + title: str, + body: str, + patch: str, + source_branch: str, +) -> dict[str, Any]: + """Write the sh.tangled.repo.pull record. + + Schema mirrors what Tangled's own UI produces: a `source.branch` string, + a `target` object holding {repo, branch, repoDid}, and a `rounds` array + where each round has a gzipped-patch blob reference. Round 0 is the + initial submission; revisions append a new round with an updated blob. + """ + patch_blob = _upload_patch_blob(cfg, session, patch) + now_iso = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') + record = { + '$type': PULL_COLLECTION, + 'title': title, + 'body': body, + 'source': {'branch': source_branch}, + 'target': { + 'repo': cfg.repo_did, + 'branch': cfg.base_branch, + 'repoDid': cfg.repo_did, + }, + 'rounds': [ + { + 'createdAt': now_iso, + 'patchBlob': patch_blob, + } + ], + 'createdAt': now_iso, + } + res = git_common.request( + 'POST', + f'{cfg.pds_url}/xrpc/com.atproto.repo.createRecord', + error_action='creating the pull record', + headers={'Authorization': f'Bearer {session.access_jwt}'}, + json={ + 'repo': session.did, + 'collection': PULL_COLLECTION, + 'record': record, + }, + ) + if not res.ok: + raise RuleDraftBackendError( + f'ATProto createRecord returned {res.status_code}: {res.text[:400]}', + status_code=502, + ) + return res.json() + + +def _pull_url(cfg: TangledConfig, rkey: str) -> str: + # Best-guess viewable URL for a tangled pull. If tangled's canonical URL + # scheme differs the user can still navigate via at:// URI. + return f'{cfg.tangled_url}/{cfg.repo_owner}/{cfg.repo_name}/pulls/{rkey}' + + +def _rkey_from_uri(uri: str) -> str: + return uri.rsplit('/', 1)[-1] + + +class TangledBackend: + name = 'tangled' + + def __init__(self, cfg: TangledConfig): + self._cfg = cfg + + @classmethod + def from_env(cls) -> 'TangledBackend': + return cls(TangledConfig.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: + if not is_new_rule: + raise RuleDraftBackendError( + 'The Tangled backend does not yet support editing existing rules. Edits require ' + 'rendering a diff against the existing file, which is a follow-up.', + status_code=501, + ) + if wire_into_main: + raise RuleDraftBackendError( + 'The Tangled backend does not yet support wire_into_main. Uncheck ' + '"Turn this rule on once the review is approved" and add the Require line to main.sml ' + 'in a follow-up patch.', + status_code=501, + ) + + cfg = self._cfg + session = _create_session(cfg) + + path_in_repo = _full_path(cfg, draft_path) + subject = f'Add rule {rule_name}' + body_text = ( + f'{summary.strip() or "_(no summary provided)_"}\n\n' + f'Drafted in the Osprey rules UI by {author_email}.\n' + f'Touches: {path_in_repo}.' + ) + patch = _format_new_file_patch( + path_in_repo=path_in_repo, + contents=sml_source, + author_email=author_email, + subject=subject, + body=body_text, + ) + # Cosmetic source-branch label for the pull record; Tangled displays it + # but doesn't require it to map to a real git ref on the knot. + source_branch = git_common.generate_branch_name(rule_name, author_email, prefix='osprey-ui') + created = _create_pull_record( + cfg, + session, + title=subject, + body=body_text, + patch=patch, + source_branch=source_branch, + ) + uri = created.get('uri', '') + rkey = _rkey_from_uri(uri) if uri else '' + # Tangled assigns a numeric pull id (`/pulls/N/round/0`) when Bobbin + # indexes the record, not at submit time. Link the user at the + # pulls list; the new pull appears there once Bobbin ingests (usually + # a few seconds). + pulls_list_url = f'{cfg.tangled_url}/{cfg.repo_owner}/{cfg.repo_name}/pulls' + return SubmissionResult( + title='Tangled pull opened', + url=pulls_list_url, + main_sml_updated=False, + extras={ + 'at_uri': uri, + 'rkey': rkey, + 'path_in_repo': path_in_repo, + }, + ) + + def list_pending_drafts(self) -> list[PendingDraft]: + # Reading pulls means either hitting the Bobbin appview (public XRPC + # for sh.tangled.* reads) or listing records off the user's PDS. + # This adapter surfaces an empty list rather than guess the appview + # endpoint; the UI degrades gracefully on empty. A follow-up can wire + # in the read side once Tangled's public read API surface stabilises. + return [] 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..899eea6f --- /dev/null +++ b/osprey_worker/src/osprey/worker/ui_api/osprey/views/tests/test_rule_drafts.py @@ -0,0 +1,1303 @@ +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 + + +def _set_tangled_backend(monkeypatch: pytest.MonkeyPatch, **overrides: str) -> None: + defaults = { + 'OSPREY_RULES_SUBMISSION_BACKEND': 'tangled', + 'OSPREY_TANGLED_HANDLE': 'alice.example.test', + 'OSPREY_TANGLED_APP_PASSWORD': 'test-app-pw', + 'OSPREY_TANGLED_REPO': 'alice.example.test/osprey-rules', + 'OSPREY_TANGLED_REPO_DID': 'did:plc:testrepodid', + 'OSPREY_TANGLED_PDS_URL': 'https://pds.example.test', + 'OSPREY_TANGLED_URL': 'https://tangled.example.test', + '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_tangled_submit_returns_503_when_missing_required_env( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv('OSPREY_RULES_SUBMISSION_BACKEND', 'tangled') + monkeypatch.delenv('OSPREY_TANGLED_HANDLE', raising=False) + monkeypatch.delenv('OSPREY_TANGLED_APP_PASSWORD', raising=False) + monkeypatch.delenv('OSPREY_TANGLED_REPO', 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_TANGLED_HANDLE' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_tangled_submit_happy_path_creates_pull_record( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_tangled_backend(monkeypatch) + + with requests_mock_module.Mocker() as m: + m.post( + 'https://pds.example.test/xrpc/com.atproto.server.createSession', + json={ + 'did': 'did:plc:alice', + 'accessJwt': 'fake_access_jwt', + 'refreshJwt': 'fake_refresh_jwt', + 'handle': 'alice.example.test', + }, + ) + upload_blob = m.post( + 'https://pds.example.test/xrpc/com.atproto.repo.uploadBlob', + json={ + 'blob': { + '$type': 'blob', + 'ref': {'$link': 'bafkreitestblobcid'}, + 'mimeType': 'application/gzip', + 'size': 512, + } + }, + ) + create_record = m.post( + 'https://pds.example.test/xrpc/com.atproto.repo.createRecord', + json={ + 'uri': 'at://did:plc:alice/sh.tangled.repo.pull/3lkabc123', + 'cid': 'bafyfakecid', + }, + ) + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/contains_tangled.sml', + 'source': "Import(rules=['models/base.sml'])\nContainsTangled = Rule(when_all=[PostText == 'tangled'], description='tangled test')", + 'rule_name': 'ContainsTangled', + 'summary': 'test rule from tangled adapter', + 'is_new_rule': True, + }, + ) + + assert res.status_code == 200 + body = res.json + assert body is not None + assert body['at_uri'] == 'at://did:plc:alice/sh.tangled.repo.pull/3lkabc123' + assert body['rkey'] == '3lkabc123' + # Tangled assigns numeric pull ids downstream during Bobbin indexing, so + # the adapter always links at the pulls list. + assert body['url'] == 'https://tangled.example.test/alice.example.test/osprey-rules/pulls' + + assert upload_blob.call_count == 1 + assert upload_blob.last_request.headers['Content-Type'] == 'application/gzip' + + posted = create_record.last_request.json() + assert posted['collection'] == 'sh.tangled.repo.pull' + assert posted['repo'] == 'did:plc:alice' + record = posted['record'] + assert record['$type'] == 'sh.tangled.repo.pull' + assert record['title'] == 'Add rule ContainsTangled' + # The record body carries the PR summary, not the rule's SML description. + assert 'test rule from tangled adapter' in record['body'] + # Nested target: {repo, branch, repoDid}; must not have the old flat fields. + assert record['target']['repo'] == 'did:plc:testrepodid' + assert record['target']['repoDid'] == 'did:plc:testrepodid' + assert record['target']['branch'] == 'main' + assert 'repo' not in {k for k in record if k != 'target'} + assert 'targetBranch' not in record + # source.branch is a cosmetic label; must be present. + assert record['source']['branch'].startswith('osprey-ui/') + # rounds[0].patchBlob references the blob we uploaded, not an inline patch. + assert 'patch' not in record + assert record['rounds'][0]['patchBlob']['ref']['$link'] == 'bafkreitestblobcid' + assert record['rounds'][0]['patchBlob']['mimeType'] == 'application/gzip' + + +@pytest.mark.use_rules_sources(_base_sources) +def test_tangled_backend_rejects_edits_with_501( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_tangled_backend(monkeypatch) + res = client.post( + url_for('rule_drafts.submit_draft'), + json={ + 'path': 'rules/contains_hello.sml', + 'source': "Import(rules=['models/base.sml'])\nMyRule = Rule(when_all=[PostText == 'hi'], description='hi')", + 'rule_name': 'MyRule', + 'summary': '', + 'is_new_rule': False, + }, + ) + assert res.status_code == 501 + body = res.json + assert body is not None + assert 'does not yet support editing existing rules' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_tangled_backend_rejects_wire_into_main_with_501( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_tangled_backend(monkeypatch) + 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 == 501 + body = res.json + assert body is not None + assert 'wire_into_main' in body['error'] + + +@pytest.mark.use_rules_sources(_base_sources) +def test_tangled_backend_surfaces_401_from_pds( + client: 'FlaskClient[Response]', monkeypatch: pytest.MonkeyPatch +) -> None: + _set_tangled_backend(monkeypatch) + with requests_mock_module.Mocker() as m: + m.post( + 'https://pds.example.test/xrpc/com.atproto.server.createSession', + status_code=401, + json={'error': 'AuthenticationRequired'}, + ) + 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 == 502 + body = res.json + assert body is not None + assert 'createSession returned 401' in body['error'] + + +def test_tangled_patch_neutralizes_patch_break_lines_in_summary() -> None: + from osprey.worker.ui_api.osprey.views._rule_drafts_tangled import _format_new_file_patch + + malicious_summary = ( + 'looks legit\n' + '---\n' + ' evil.yaml | 1 +\n' + 'diff --git a/evil.yaml b/evil.yaml\n' + 'Index: evil.yaml\n' + 'From attacker@example.test Mon Sep 17 00:00:00 2001' + ) + patch = _format_new_file_patch( + path_in_repo='rules/x.sml', + contents='x = 1', + author_email='author@example.test', + subject='Add rule X', + body=malicious_summary, + ) + + diff_headers = [line for line in patch.split('\n') if line.startswith('diff --git')] + assert diff_headers == ['diff --git a/rules/x.sml b/rules/x.sml'] + body_section = patch.split('\n---\n', 1)[0] + assert '\n> ---' in body_section + assert '\n> diff --git a/evil.yaml b/evil.yaml' in body_section + assert '\n> Index: evil.yaml' in body_section + assert '\n> From attacker@example.test' in body_section + assert '\nlooks legit\n' in body_section + + +def test_tangled_patch_strips_newlines_from_headers() -> None: + from osprey.worker.ui_api.osprey.views._rule_drafts_tangled import _format_new_file_patch + + patch = _format_new_file_patch( + path_in_repo='rules/x.sml', + contents='x = 1', + author_email='author@example.test>\nBcc: victim@example.test', + subject='Add rule X\nX-Injected: yes', + body='hi', + ) + + from_lines = [line for line in patch.split('\n') if line.startswith('From: ')] + assert from_lines == ['From: Osprey UI '] + subject_lines = [line for line in patch.split('\n') if line.startswith('Subject: ')] + assert subject_lines == ['Subject: [PATCH] Add rule X X-Injected: yes'] + assert 'Bcc: victim' not in '\n'.join(line for line in patch.split('\n') if not line.startswith('From: '))