feat: validate addon tags in ci
This commit is contained in:
parent
2cfa7805e6
commit
5cb3cd9794
66
.github/workflows/addon-tag-review.yml
vendored
Normal file
66
.github/workflows/addon-tag-review.yml
vendored
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
name: Addon Tag Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'addons/**'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.x
|
||||||
|
|
||||||
|
- name: Install Dependencies
|
||||||
|
run: |
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Determine addon files to review
|
||||||
|
id: changed_addons
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
git fetch origin "${{ github.base_ref }}" --depth=1
|
||||||
|
files=$(git diff --name-only --diff-filter=AM "origin/${{ github.base_ref }}" HEAD -- addons || true)
|
||||||
|
else
|
||||||
|
files=$(git ls-files addons)
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "changed_addons<<EOF"
|
||||||
|
echo "$files"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Review addon tags
|
||||||
|
if: steps.changed_addons.outputs.changed_addons != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
mapfile -t files < <(printf '%s\n' "${{ steps.changed_addons.outputs.changed_addons }}" | sed '/^$/d')
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "No addon files to review"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -m zotero_scraper.tag_review \
|
||||||
|
--files "${files[@]}" \
|
||||||
|
--summary-file "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: No addon changes
|
||||||
|
if: steps.changed_addons.outputs.changed_addons == ''
|
||||||
|
run: echo "No addon files require tag review"
|
||||||
38
.github/workflows/main.yml
vendored
38
.github/workflows/main.yml
vendored
@ -23,6 +23,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Checkout publish branch (for release_cache)
|
- name: Checkout publish branch (for release_cache)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@ -50,6 +52,40 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Determine changed addon files
|
||||||
|
id: changed_addons
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
before_sha="${{ github.event.before }}"
|
||||||
|
if [ -z "$before_sha" ] || [ "$before_sha" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
files=$(git diff-tree --no-commit-id --name-only -r "${{ github.sha }}" -- addons || true)
|
||||||
|
else
|
||||||
|
files=$(git diff --name-only --diff-filter=AM "$before_sha" "${{ github.sha }}" -- addons || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "changed_addons<<EOF"
|
||||||
|
echo "$files"
|
||||||
|
echo "EOF"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Validate changed addon tags
|
||||||
|
if: github.event_name == 'push' && steps.changed_addons.outputs.changed_addons != ''
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PYTHONPATH: src
|
||||||
|
run: |
|
||||||
|
mapfile -t files < <(printf '%s\n' "${{ steps.changed_addons.outputs.changed_addons }}" | sed '/^$/d')
|
||||||
|
if [ "${#files[@]}" -eq 0 ]; then
|
||||||
|
echo "No addon files changed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 -m zotero_scraper.tag_review \
|
||||||
|
--files "${files[@]}" \
|
||||||
|
--summary-file "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
- name: Run Scraper
|
- name: Run Scraper
|
||||||
run: |
|
run: |
|
||||||
python main.py \
|
python main.py \
|
||||||
@ -61,6 +97,7 @@ jobs:
|
|||||||
--create_release True
|
--create_release True
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PYTHONPATH: src
|
||||||
|
|
||||||
- name: Copy release_cache to published folder
|
- name: Copy release_cache to published folder
|
||||||
run: |
|
run: |
|
||||||
@ -77,4 +114,3 @@ jobs:
|
|||||||
user_email: 'github-actions[bot]@users.noreply.github.com'
|
user_email: 'github-actions[bot]@users.noreply.github.com'
|
||||||
publish_dir: ./published
|
publish_dir: ./published
|
||||||
force_orphan: true
|
force_orphan: true
|
||||||
|
|
||||||
|
|||||||
@ -13,6 +13,7 @@ including the following steps:
|
|||||||
- Retrieve [Zotero](https://www.zotero.org) add-on information specified in [addons](addons) folder
|
- Retrieve [Zotero](https://www.zotero.org) add-on information specified in [addons](addons) folder
|
||||||
- Parse add-on information into JSON data
|
- Parse add-on information into JSON data
|
||||||
- Publish the JSON data to the [`publish`](https://github.com/syt2/zotero-addons-scraper/blob/publish/addon_infos.json) branch and the [latest release](https://github.com/syt2/zotero-addons-scraper/releases/latest)
|
- Publish the JSON data to the [`publish`](https://github.com/syt2/zotero-addons-scraper/blob/publish/addon_infos.json) branch and the [latest release](https://github.com/syt2/zotero-addons-scraper/releases/latest)
|
||||||
|
- Validate changed addon tag metadata in CI
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
Adding a new add-on entry `{owner}@{repo}` in the [addons](addons) folder.
|
Adding a new add-on entry `{owner}@{repo}` in the [addons](addons) folder.
|
||||||
@ -35,7 +36,11 @@ Available tags:
|
|||||||
| `integration` | Integration with external services (Notion, Obsidian, Word, etc.) |
|
| `integration` | Integration with external services (Notion, Obsidian, Word, etc.) |
|
||||||
| `utility` | Zotero system tools (automation, deduplication, plugin management) |
|
| `utility` | Zotero system tools (automation, deduplication, plugin management) |
|
||||||
|
|
||||||
|
### Tag validation in CI
|
||||||
|
- `pull_request` on `addons/**`: validates changed addon files against the allowed tag taxonomy
|
||||||
|
- `push` to `master`: validates changed addon files before the scraper publishes data
|
||||||
|
- Each addon config must declare at least one tag, use only allowed tag values, avoid duplicates, and keep tags in canonical order
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
1. `fork` this repository
|
1. `fork` this repository
|
||||||
2. Enable actions and scheduled workflow in `Actions` page of forked repository
|
2. Enable actions and scheduled workflow in `Actions` page of forked repository
|
||||||
|
|
||||||
|
|||||||
@ -26,6 +26,7 @@ dev = [
|
|||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
zotero-scraper = "zotero_scraper.__main__:main"
|
zotero-scraper = "zotero_scraper.__main__:main"
|
||||||
|
zotero-tag-review = "zotero_scraper.tag_review:main"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|||||||
33
src/zotero_scraper/config/tags.py
Normal file
33
src/zotero_scraper/config/tags.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""Addon tag taxonomy shared by docs, validation, and AI review."""
|
||||||
|
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
ADDON_TAGS: Final[dict[str, str]] = {
|
||||||
|
"ai": "AI-powered features such as summarization, chat, or translation via LLMs.",
|
||||||
|
"metadata": (
|
||||||
|
"Metadata retrieval, citation counts, impact factor, formatting, "
|
||||||
|
"or library enrichment."
|
||||||
|
),
|
||||||
|
"reader": "PDF reading experience, annotation, and highlighting workflows.",
|
||||||
|
"notes": "Note-taking, markdown export, and knowledge management workflows.",
|
||||||
|
"attachment": "File management, attachment organization, OCR, and exports.",
|
||||||
|
"interface": "UI enhancements, themes, columns, and layout customizations.",
|
||||||
|
"integration": "Integration with external services such as Notion or Obsidian.",
|
||||||
|
"utility": "General Zotero tools such as automation and plugin management.",
|
||||||
|
}
|
||||||
|
|
||||||
|
VALID_ADDON_TAGS: Final[tuple[str, ...]] = tuple(ADDON_TAGS.keys())
|
||||||
|
MAX_ADDON_TAGS: Final[int] = 3
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tags(tags: list[str]) -> list[str]:
|
||||||
|
"""Return tags in canonical taxonomy order without duplicates."""
|
||||||
|
tag_set = set(tags)
|
||||||
|
return [tag for tag in VALID_ADDON_TAGS if tag in tag_set]
|
||||||
|
|
||||||
|
|
||||||
|
def format_tag_catalog() -> str:
|
||||||
|
"""Render the tag taxonomy as prompt-friendly text."""
|
||||||
|
return "\n".join(
|
||||||
|
f"- {tag}: {description}" for tag, description in ADDON_TAGS.items()
|
||||||
|
)
|
||||||
219
src/zotero_scraper/tag_review.py
Normal file
219
src/zotero_scraper/tag_review.py
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
"""CLI helpers for validating addon tag metadata."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .config.tags import ADDON_TAGS, MAX_ADDON_TAGS, VALID_ADDON_TAGS, normalize_tags
|
||||||
|
from .utils.logging import get_logger, setup_logging
|
||||||
|
|
||||||
|
logger = get_logger("tag_review")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AddonConfigEntry:
|
||||||
|
"""Addon config file loaded from the addons directory."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
repo: str
|
||||||
|
data: dict[str, Any]
|
||||||
|
tags: list[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: Path) -> AddonConfigEntry:
|
||||||
|
"""Load an addon config file."""
|
||||||
|
text = path.read_text(encoding="utf-8").strip()
|
||||||
|
data: dict[str, Any]
|
||||||
|
if not text:
|
||||||
|
data = {}
|
||||||
|
else:
|
||||||
|
parsed = json.loads(text)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("Addon config must be a JSON object")
|
||||||
|
data = parsed
|
||||||
|
|
||||||
|
repo = path.name.replace("@", "/", 1)
|
||||||
|
tags = data.get("tags", [])
|
||||||
|
if tags is None:
|
||||||
|
tags = []
|
||||||
|
if not isinstance(tags, list):
|
||||||
|
raise ValueError("'tags' must be a JSON array when present")
|
||||||
|
|
||||||
|
return cls(path=path, repo=repo, data=data, tags=list(tags))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileReviewResult:
|
||||||
|
"""Validation outcome for one addon config file."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
repo: str
|
||||||
|
status: str
|
||||||
|
current_tags: list[str]
|
||||||
|
messages: list[str]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ok(self) -> bool:
|
||||||
|
return self.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
|
class AddonTagReviewer:
|
||||||
|
"""Validate addon tags locally."""
|
||||||
|
|
||||||
|
def review_file(self, path: Path) -> FileReviewResult:
|
||||||
|
"""Review a single addon config file."""
|
||||||
|
try:
|
||||||
|
entry = AddonConfigEntry.load(path)
|
||||||
|
messages = self._validate(entry)
|
||||||
|
except Exception as exc:
|
||||||
|
return FileReviewResult(
|
||||||
|
path=path,
|
||||||
|
repo=path.name.replace("@", "/", 1),
|
||||||
|
status="failed",
|
||||||
|
current_tags=[],
|
||||||
|
messages=[str(exc)],
|
||||||
|
)
|
||||||
|
|
||||||
|
if messages:
|
||||||
|
return FileReviewResult(
|
||||||
|
path=path,
|
||||||
|
repo=entry.repo,
|
||||||
|
status="failed",
|
||||||
|
current_tags=entry.tags,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileReviewResult(
|
||||||
|
path=path,
|
||||||
|
repo=entry.repo,
|
||||||
|
status="ok",
|
||||||
|
current_tags=normalize_tags(entry.tags),
|
||||||
|
messages=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def _validate(self, entry: AddonConfigEntry) -> list[str]:
|
||||||
|
"""Validate file structure and tag taxonomy."""
|
||||||
|
messages: list[str] = []
|
||||||
|
|
||||||
|
unknown_keys = sorted(set(entry.data) - {"tags", "recommended"})
|
||||||
|
if unknown_keys:
|
||||||
|
messages.append(
|
||||||
|
f"Unsupported addon config keys: {', '.join(unknown_keys)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "recommended" in entry.data and not isinstance(
|
||||||
|
entry.data["recommended"], bool
|
||||||
|
):
|
||||||
|
messages.append("'recommended' must be a boolean when present")
|
||||||
|
|
||||||
|
if not entry.tags:
|
||||||
|
return messages
|
||||||
|
|
||||||
|
non_string_tags = [tag for tag in entry.tags if not isinstance(tag, str)]
|
||||||
|
if non_string_tags:
|
||||||
|
messages.append("All tags must be strings")
|
||||||
|
return messages
|
||||||
|
|
||||||
|
if len(entry.tags) != len(set(entry.tags)):
|
||||||
|
messages.append("Duplicate tags are not allowed")
|
||||||
|
|
||||||
|
invalid_tags = [tag for tag in entry.tags if tag not in VALID_ADDON_TAGS]
|
||||||
|
if invalid_tags:
|
||||||
|
messages.append(
|
||||||
|
"Unknown tags: "
|
||||||
|
f"{', '.join(invalid_tags)}. Allowed tags: "
|
||||||
|
f"{', '.join(VALID_ADDON_TAGS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(entry.tags) > MAX_ADDON_TAGS:
|
||||||
|
messages.append(f"At most {MAX_ADDON_TAGS} tags are allowed per addon")
|
||||||
|
|
||||||
|
normalized = normalize_tags(entry.tags)
|
||||||
|
if entry.tags != normalized:
|
||||||
|
messages.append(f"Tags should use canonical order: {normalized}")
|
||||||
|
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
"""Parse command-line arguments for tag review."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Validate addon tags for addon config files."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--files",
|
||||||
|
nargs="+",
|
||||||
|
help="Addon config files to review",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--summary-file",
|
||||||
|
default="",
|
||||||
|
help="Optional markdown file to append the review summary to",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--log-level",
|
||||||
|
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
||||||
|
default="INFO",
|
||||||
|
help="Logging level",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def render_summary(results: list[FileReviewResult]) -> str:
|
||||||
|
"""Render a GitHub-friendly markdown summary."""
|
||||||
|
if not results:
|
||||||
|
return "## Addon tag review\n\nNo addon files were provided for review.\n"
|
||||||
|
|
||||||
|
lines = ["## Addon tag review", ""]
|
||||||
|
lines.append("| File | Status | Tags |")
|
||||||
|
lines.append("| --- | --- | --- |")
|
||||||
|
for result in results:
|
||||||
|
tags = ", ".join(result.current_tags) or "-"
|
||||||
|
lines.append(f"| `{result.path}` | {result.status} | {tags} |")
|
||||||
|
for message in result.messages:
|
||||||
|
lines.append(f"- `{result.path}`: {message}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Allowed tags: " + ", ".join(f"`{tag}`" for tag in ADDON_TAGS))
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""CLI entry point used by GitHub Actions workflows."""
|
||||||
|
args = parse_args()
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
setup_logging(level=getattr(logging, args.log_level))
|
||||||
|
|
||||||
|
file_paths = [Path(path) for path in args.files or [] if path]
|
||||||
|
if not file_paths:
|
||||||
|
logger.info("No addon files to review")
|
||||||
|
if args.summary_file:
|
||||||
|
Path(args.summary_file).write_text(render_summary([]), encoding="utf-8")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
reviewer = AddonTagReviewer()
|
||||||
|
results: list[FileReviewResult] = []
|
||||||
|
for path in file_paths:
|
||||||
|
if not path.exists():
|
||||||
|
logger.info(f"Skipping deleted or missing addon file: {path}")
|
||||||
|
continue
|
||||||
|
logger.info(f"Reviewing addon tags: {path}")
|
||||||
|
results.append(reviewer.review_file(path))
|
||||||
|
|
||||||
|
summary = render_summary(results)
|
||||||
|
print(summary)
|
||||||
|
if args.summary_file:
|
||||||
|
Path(args.summary_file).write_text(summary, encoding="utf-8")
|
||||||
|
|
||||||
|
failures = [result for result in results if not result.ok]
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
71
tests/unit/test_tag_review.py
Normal file
71
tests/unit/test_tag_review.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""Unit tests for addon tag review."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from zotero_scraper.tag_review import AddonConfigEntry, AddonTagReviewer
|
||||||
|
|
||||||
|
|
||||||
|
def write_addon_file(path: Path, content: str) -> Path:
|
||||||
|
"""Write addon config content to a test file."""
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_addon_config_entry_loads_empty_file(temp_dir):
|
||||||
|
"""Empty addon entries should load as an empty config object."""
|
||||||
|
path = write_addon_file(temp_dir / "owner@repo", "")
|
||||||
|
|
||||||
|
entry = AddonConfigEntry.load(path)
|
||||||
|
|
||||||
|
assert entry.repo == "owner/repo"
|
||||||
|
assert entry.tags == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_allows_missing_tags(temp_dir):
|
||||||
|
"""Empty addon files should pass when tags are omitted."""
|
||||||
|
path = write_addon_file(temp_dir / "owner@repo", "")
|
||||||
|
reviewer = AddonTagReviewer()
|
||||||
|
|
||||||
|
result = reviewer.review_file(path)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert result.current_tags == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_rejects_unknown_tags(temp_dir):
|
||||||
|
"""Unknown taxonomy values should fail validation."""
|
||||||
|
path = write_addon_file(temp_dir / "owner@repo", '{"tags": ["unknown"]}\n')
|
||||||
|
reviewer = AddonTagReviewer()
|
||||||
|
|
||||||
|
result = reviewer.review_file(path)
|
||||||
|
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert "Unknown tags" in result.messages[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_rejects_non_canonical_order(temp_dir):
|
||||||
|
"""Tags should follow the configured canonical order."""
|
||||||
|
path = write_addon_file(
|
||||||
|
temp_dir / "owner@repo",
|
||||||
|
'{"tags": ["reader", "ai"]}\n',
|
||||||
|
)
|
||||||
|
reviewer = AddonTagReviewer()
|
||||||
|
|
||||||
|
result = reviewer.review_file(path)
|
||||||
|
|
||||||
|
assert result.status == "failed"
|
||||||
|
assert "canonical order" in result.messages[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_accepts_valid_tags(temp_dir):
|
||||||
|
"""A valid addon config should pass validation."""
|
||||||
|
path = write_addon_file(
|
||||||
|
temp_dir / "owner@repo",
|
||||||
|
'{"tags": ["ai", "notes"], "recommended": true}\n',
|
||||||
|
)
|
||||||
|
reviewer = AddonTagReviewer()
|
||||||
|
|
||||||
|
result = reviewer.review_file(path)
|
||||||
|
|
||||||
|
assert result.status == "ok"
|
||||||
|
assert result.current_tags == ["ai", "notes"]
|
||||||
Loading…
Reference in New Issue
Block a user