From afc839f4ab4df17f359d779e34ed37c93355d6df Mon Sep 17 00:00:00 2001 From: Ryan Cheley Date: Fri, 20 Mar 2026 19:23:00 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(#641):=20Add=20auto-detection?= =?UTF-8?q?=20of=20ArticleID=20in=20yt=20articles=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the article_id argument optional in the fetch command and enable auto-detection from markdown file's ArticleID comment. This improves UX by allowing users to fetch articles by simply specifying the local file, without needing to remember the article ID. Changes: - Make article_id argument optional in fetch command - Auto-detect article ID from markdown file's ArticleID comment - Add error handling for missing article ID - Add 3 comprehensive tests for auto-detection feature - Update documentation with new behavior and examples - Update CHANGELOG with feature description Fixes #641 Co-Authored-By: Claude Haiku 4.5 --- CHANGELOG.md | 9 +++ docs/commands/articles.rst | 63 ++++++++++++++++ tests/test_articles.py | 117 ++++++++++++++++++++++++++++++ youtrack_cli/commands/articles.py | 50 +++++++++++-- 4 files changed, 233 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73281ca..152e5a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- ✨ Auto-detect ArticleID from markdown files in `yt articles fetch` command (#641) + - The `article_id` argument is now optional + - When not provided, the command auto-detects the article ID from the markdown file's `` comment + - Improves UX by reducing required flags and making the workflow more ergonomic + - Examples: + - `yt articles fetch --file my-article.md` (auto-detects from file) + - `yt articles fetch ARTICLE-123 --file my-article.md` (explicit ID) + ## [0.22.1] - 2026-03-18 [YANKED] **YANKED** - This version was yanked from PyPI due to a bug in the custom fields feature. diff --git a/docs/commands/articles.rst b/docs/commands/articles.rst index 30279ee..1dcd33b 100644 --- a/docs/commands/articles.rst +++ b/docs/commands/articles.rst @@ -199,6 +199,69 @@ When editing articles with markdown files, the CLI automatically manages Article * The ArticleID helps track the relationship between local files and YouTrack articles * Use ``--no-article-id`` to disable automatic ArticleID insertion/updating +fetch +~~~~~ + +Fetch an article's content from YouTrack and save it to a local markdown file. + +.. code-block:: bash + + yt articles fetch [ARTICLE_ID] [OPTIONS] + +**Arguments:** + +* ``ARTICLE_ID`` - The ID of the article to fetch (optional when using --file with ArticleID comment) + +**Options:** + +.. list-table:: + :widths: 20 20 60 + :header-rows: 1 + + * - Option + - Type + - Description + * - ``--file, -f`` + - path + - Path to save article content (defaults to article ID + .md, or auto-detected from file) + * - ``--show-details`` + - flag + - Show detailed article information + +**Examples:** + +.. code-block:: bash + + # Fetch article and save to default filename (DOCS-A-1.md) + yt articles fetch DOCS-A-1 + + # Fetch article and save to specific file + yt articles fetch DOCS-A-1 --file my-article.md + + # Auto-detect article ID from file + yt articles fetch --file my-article.md + + # Fetch article and show details before saving + yt articles fetch DOCS-A-1 --show-details + + # Update existing local file with latest content + yt articles fetch DOCS-A-1 --file ./articles/api-guide.md + +**ArticleID Auto-detection:** + +When the ``article_id`` argument is not provided, the fetch command automatically detects it from the markdown file: + +* If a ``--file`` is specified and contains an ```` comment, that ID is used +* Otherwise, the command searches the current directory for a file containing an ArticleID comment +* If no file with ArticleID is found, an error is raised + +This auto-detection feature allows you to fetch article content by simply specifying the local markdown file, without needing to remember the article ID: + +.. code-block:: bash + + # File contains: + yt articles fetch --file my-article.md # Auto-detects DOCS-A-789 + publish ~~~~~~~ diff --git a/tests/test_articles.py b/tests/test_articles.py index 684ce3a..92beeb7 100644 --- a/tests/test_articles.py +++ b/tests/test_articles.py @@ -1484,3 +1484,120 @@ def test_fetch_command_finds_existing_file(self): assert "" in content finally: os.chdir(original_cwd) + + def test_fetch_command_auto_detects_article_id_from_file(self): + """Test fetch command auto-detects article ID from file content.""" + import tempfile + from pathlib import Path + from unittest.mock import AsyncMock, MagicMock, patch + + from click.testing import CliRunner + + from youtrack_cli.commands.articles import fetch + + runner = CliRunner() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("\n# Original Content\n") + temp_file = f.name + + try: + with patch("youtrack_cli.auth.AuthManager") as mock_auth: + with patch("youtrack_cli.articles.ArticleManager") as mock_manager_class: + mock_auth_instance = MagicMock() + mock_auth.return_value = mock_auth_instance + + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + # Mock successful fetch + mock_manager.fetch_article = AsyncMock( + return_value={ + "status": "success", + "data": { + "id": "123-456", + "idReadable": "DOCS-A-789", + "content": "# Updated Article Content\n\nNew body text.", + "title": "Test Article", + "summary": "Summary", + }, + } + ) + + # Run command without article_id argument, only with --file + result = runner.invoke(fetch, ["--file", temp_file], obj={"config": {}}) + + assert result.exit_code == 0 + assert "Auto-detected article ID from file: DOCS-A-789" in result.output + assert "Article saved" in result.output + + # Check that the correct article was fetched + mock_manager.fetch_article.assert_called_once_with("DOCS-A-789") + + # Check file was updated with new content + with open(temp_file) as f: + content = f.read() + + assert "" in content + assert "# Updated Article Content" in content + + finally: + Path(temp_file).unlink() + + def test_fetch_command_fails_without_article_id(self): + """Test fetch command fails when no article ID is provided or found.""" + from unittest.mock import MagicMock, patch + + from click.testing import CliRunner + + from youtrack_cli.commands.articles import fetch + + runner = CliRunner() + + with patch("youtrack_cli.auth.AuthManager") as mock_auth: + with patch("youtrack_cli.articles.ArticleManager") as mock_manager_class: + mock_auth_instance = MagicMock() + mock_auth.return_value = mock_auth_instance + + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + # Run command without article_id and without --file + result = runner.invoke(fetch, [], obj={"config": {}}) + + assert result.exit_code != 0 + assert "Article ID required" in result.output + + def test_fetch_command_fails_if_file_has_no_article_id(self): + """Test fetch command fails when file has no ArticleID comment.""" + import tempfile + from pathlib import Path + from unittest.mock import MagicMock, patch + + from click.testing import CliRunner + + from youtrack_cli.commands.articles import fetch + + runner = CliRunner() + + with tempfile.NamedTemporaryFile(mode="w", suffix=".md", delete=False) as f: + f.write("# Article without ID comment\n\nSome content\n") + temp_file = f.name + + try: + with patch("youtrack_cli.auth.AuthManager") as mock_auth: + with patch("youtrack_cli.articles.ArticleManager") as mock_manager_class: + mock_auth_instance = MagicMock() + mock_auth.return_value = mock_auth_instance + + mock_manager = MagicMock() + mock_manager_class.return_value = mock_manager + + # Run command without article_id, only with --file + result = runner.invoke(fetch, ["--file", temp_file], obj={"config": {}}) + + assert result.exit_code != 0 + assert "No ArticleID comment found" in result.output + + finally: + Path(temp_file).unlink() diff --git a/youtrack_cli/commands/articles.py b/youtrack_cli/commands/articles.py index 72c2155..7c4474d 100644 --- a/youtrack_cli/commands/articles.py +++ b/youtrack_cli/commands/articles.py @@ -365,7 +365,7 @@ def edit( @articles.command() -@click.argument("article_id") +@click.argument("article_id", required=False) @click.option( "--file", "-f", @@ -380,7 +380,7 @@ def edit( @click.pass_context def fetch( ctx: click.Context, - article_id: str, + article_id: Optional[str], file: Optional[Path], show_details: bool, ) -> None: @@ -389,9 +389,14 @@ def fetch( Downloads an article's content from YouTrack and saves it to a markdown file. The article ID is automatically embedded in the file as an HTML comment. - When no --file is specified, the command searches the current directory for - a file containing a matching ArticleID comment. If found, that file is updated. - Otherwise, a default filename is used (article-id.md). + The article ID can be provided as an argument or auto-detected from a markdown file + containing an ArticleID comment (). + + When no article ID is provided: + - If a --file is specified and contains an ArticleID comment, that ID is used + - Otherwise, the command searches the current directory for a file containing + a matching ArticleID comment. If found, that file is updated. + - If no file with ArticleID is found, an error is raised. Examples: # Fetch article and save to default filename (DOCS-A-1.md) @@ -400,18 +405,51 @@ def fetch( # Fetch article and save to specific file yt articles fetch DOCS-A-1 --file my-article.md + # Auto-detect article ID from file + yt articles fetch --file my-article.md + # Fetch article and show details before saving yt articles fetch DOCS-A-1 --show-details # Update existing local file with latest content yt articles fetch DOCS-A-1 --file ./articles/api-guide.md """ - from ..articles import ArticleManager, find_file_with_article_id, insert_or_update_article_id + from ..articles import ( + ArticleManager, + extract_article_id_from_content, + find_file_with_article_id, + insert_or_update_article_id, + ) console = get_console() auth_manager = AuthManager(ctx.obj.get("config")) article_manager = ArticleManager(auth_manager) + # Auto-detect article ID from file if not provided + if not article_id: + if file and file.exists(): + try: + file_content = file.read_text(encoding="utf-8") + extracted_id = extract_article_id_from_content(file_content) + if extracted_id: + article_id = extracted_id + console.print(f"🔍 Auto-detected article ID from file: {article_id}", style="blue") + else: + console.print( + f"❌ No ArticleID comment found in '{file}'. Please provide an article ID.", + style="red", + ) + raise click.ClickException("Article ID not found in file") + except UnicodeDecodeError as e: + console.print(f"❌ Could not read file '{file}'", style="red") + raise click.ClickException("Failed to read file") from e + else: + console.print( + "❌ Please provide an article ID as an argument or specify a file with --file", + style="red", + ) + raise click.ClickException("Article ID required") + try: console.print(f"📥 Fetching article '{article_id}'...", style="blue")