Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!-- ArticleID: XXX -->` 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.
Expand Down
63 changes: 63 additions & 0 deletions docs/commands/articles.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<!-- ArticleID: DOCS-A-1 -->`` 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: <!-- ArticleID: DOCS-A-789 -->
yt articles fetch --file my-article.md # Auto-detects DOCS-A-789

publish
~~~~~~~

Expand Down
117 changes: 117 additions & 0 deletions tests/test_articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -1484,3 +1484,120 @@ def test_fetch_command_finds_existing_file(self):
assert "<!-- ArticleID: DOCS-A-789 -->" 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("<!-- ArticleID: DOCS-A-789 -->\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 "<!-- ArticleID: DOCS-A-789 -->" 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()
50 changes: 44 additions & 6 deletions youtrack_cli/commands/articles.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ def edit(


@articles.command()
@click.argument("article_id")
@click.argument("article_id", required=False)
@click.option(
"--file",
"-f",
Expand All @@ -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:
Expand All @@ -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 (<!-- ArticleID: DOCS-A-1 -->).

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)
Expand All @@ -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")

Expand Down
Loading