diff --git a/docuchango/cli.py b/docuchango/cli.py index 8580a72..57217c3 100644 --- a/docuchango/cli.py +++ b/docuchango/cli.py @@ -797,11 +797,15 @@ def migrate( \b - Adds missing 'project_id' field - Generates 'doc_uuid' (UUID v4) if missing - - Migrates legacy 'date' field to 'created'/'updated' - - Adds 'created'/'updated' from git history if missing + - Migrates legacy 'date' field to 'created' + - Adds 'created' from git history if missing + - Removes deprecated/derived fields ('updated', 'date') - Normalizes 'id' field to lowercase format - Normalizes tags to lowercase with hyphens + Note: The 'updated' field is removed because it can be derived from + git history. Use 'docuchango bulk timestamps' to compute it on demand. + Examples: \b @@ -819,7 +823,7 @@ def migrate( Agent instructions to generate required fields: \b - # Generate created/updated datetime (ISO 8601 UTC): + # Generate created datetime (ISO 8601 UTC): python -c "from datetime import datetime, timezone; print(datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'))" # Or: date -u +%Y-%m-%dT%H:%M:%SZ @@ -934,34 +938,36 @@ def migrate( changes.append(f"Generated doc_uuid: {new_uuid}") modified = True - # 3. Migrate legacy 'date' field to 'created'/'updated' - if "date" in post.metadata and "created" not in post.metadata: - date_val = post.metadata["date"] - date_str = date_val.strftime("%Y-%m-%d") if hasattr(date_val, "strftime") else str(date_val) - - # Get git dates for updated field - created_date, updated_date = get_git_dates(file_path) - - post.metadata["created"] = date_str - post.metadata["updated"] = updated_date or date_str + # 3. Remove legacy 'date' field (will get created from git) + if "date" in post.metadata: del post.metadata["date"] - changes.append(f"Migrated date → created: {date_str}, updated: {updated_date or date_str}") + changes.append("Removed deprecated 'date' field") modified = True + # Also remove created if it exists so it gets refreshed from git + if "created" in post.metadata: + del post.metadata["created"] + + # 4. Add or update created from git (ensures datetime format) + created_datetime, _ = get_git_dates(file_path) + if created_datetime: + old_created = post.metadata.get("created") + # Normalize to datetime format from git + if old_created != created_datetime: + old_val = str(old_created) if old_created else "None" + post.metadata["created"] = created_datetime + if old_created: + changes.append(f"Normalized created: {old_val} → {created_datetime}") + else: + changes.append(f"Added created: {created_datetime} (from git)") + modified = True - # 4. Add created/updated from git if missing - if "created" not in post.metadata or "updated" not in post.metadata: - created_date, updated_date = get_git_dates(file_path) - if created_date: - if "created" not in post.metadata: - post.metadata["created"] = created_date - changes.append(f"Added created: {created_date} (from git)") - modified = True - if "updated" not in post.metadata: - post.metadata["updated"] = updated_date - changes.append(f"Added updated: {updated_date} (from git)") - modified = True + # 5. Remove 'updated' field (derived from git history) + if "updated" in post.metadata: + del post.metadata["updated"] + changes.append("Removed 'updated' field (derived from git)") + modified = True - # 5. Normalize id field to lowercase + # 6. Normalize id field to lowercase if "id" in post.metadata: old_id = post.metadata["id"] new_id = old_id.lower() @@ -980,7 +986,7 @@ def migrate( changes.append(f"Generated id: {new_id}") modified = True - # 6. Normalize tags + # 7. Normalize tags if "tags" in post.metadata: old_tags = post.metadata["tags"] if isinstance(old_tags, str): diff --git a/docuchango/schemas.py b/docuchango/schemas.py index 59d95a8..a404db9 100644 --- a/docuchango/schemas.py +++ b/docuchango/schemas.py @@ -185,13 +185,15 @@ class ADRFrontmatter(BaseModel): - title: Title without ADR prefix (e.g., "Use Rust for Proxy"). ID displayed by sidebar. - status: Current state (Proposed/Accepted/Implemented/Deprecated/Superseded) - created: Date ADR was first created in ISO 8601 format (YYYY-MM-DD) - - updated: Date ADR was last modified in ISO 8601 format (YYYY-MM-DD) - deciders: Person or team who made the decision (e.g., "Core Team", "Platform Team") - tags: List of lowercase hyphenated tags for categorization - id: Lowercase identifier matching filename (e.g., "adr-001" for ADR-001-rust-proxy.md) - project_id: Project identifier from docs-project.yaml (e.g., "my-project") - doc_uuid: Unique identifier for backend tracking (UUID v4 format) + DERIVED FIELDS (computed from git history): + - updated: Last modification date, derived from git commit history + DEPRECATED FIELDS (supported for backwards compatibility): - date: Legacy field, use 'created' instead. Will be auto-migrated. """ @@ -209,10 +211,6 @@ class ADRFrontmatter(BaseModel): ..., description="DateTime ADR was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", ) - updated: datetime.datetime | datetime.date | str = Field( - ..., - description="DateTime ADR was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", - ) deciders: str = Field( ..., description="Who made the decision. Use team name (e.g., 'Core Team') or individual name" ) @@ -280,11 +278,13 @@ class RFCFrontmatter(BaseModel): - status: Current state (Draft/Proposed/Accepted/Implemented/Rejected) - author: Document author (person or team who wrote the RFC) - created: Date RFC was first created in ISO 8601 format (YYYY-MM-DD) - - updated: Date RFC was last modified in ISO 8601 format (YYYY-MM-DD) - tags: List of lowercase hyphenated tags for categorization - id: Lowercase identifier matching filename (e.g., "rfc-015" for RFC-015-plugin-architecture.md) - project_id: Project identifier from docs-project.yaml (e.g., "my-project") - doc_uuid: Unique identifier for backend tracking (UUID v4 format) + + DERIVED FIELDS (computed from git history): + - updated: Last modification date, derived from git commit history """ title: str = Field( @@ -302,10 +302,6 @@ class RFCFrontmatter(BaseModel): ..., description="DateTime RFC was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", ) - updated: datetime.datetime | datetime.date | str | None = Field( - None, - description="DateTime RFC was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", - ) tags: list[str] = Field( default_factory=list, description="List of lowercase, hyphenated tags (e.g., ['design', 'api', 'backend'])" ) @@ -368,11 +364,13 @@ class MemoFrontmatter(BaseModel): - title: Title without MEMO prefix (e.g., "Load Test Results"). ID displayed by sidebar. - author: Document author (person or team who wrote the memo) - created: Date memo was first created in ISO 8601 format (YYYY-MM-DD) - - updated: Date memo was last modified in ISO 8601 format (YYYY-MM-DD) - tags: List of lowercase hyphenated tags for categorization - id: Lowercase identifier matching filename (e.g., "memo-010" for MEMO-010-loadtest-results.md) - project_id: Project identifier from docs-project.yaml (e.g., "my-project") - doc_uuid: Unique identifier for backend tracking (UUID v4 format) + + DERIVED FIELDS (computed from git history): + - updated: Last modification date, derived from git commit history """ title: str = Field( @@ -385,10 +383,6 @@ class MemoFrontmatter(BaseModel): ..., description="DateTime memo was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", ) - updated: datetime.datetime | datetime.date | str = Field( - ..., - description="DateTime memo was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", - ) tags: list[str] = Field( default_factory=list, description="List of lowercase, hyphenated tags (e.g., ['implementation', 'testing', 'performance'])", @@ -453,12 +447,14 @@ class PRDFrontmatter(BaseModel): - status: Current state (Draft/In Review/Approved/In Progress/Completed/Cancelled) - author: Document author (person or team who wrote the PRD) - created: Date PRD was first created in ISO 8601 format (YYYY-MM-DD) - - updated: Date PRD was last modified in ISO 8601 format (YYYY-MM-DD) - target_release: Target release version or date (e.g., "v2.0.0" or "Q2 2025") - tags: List of lowercase hyphenated tags for categorization - id: Lowercase identifier matching filename (e.g., "prd-005" for prd-005-user-auth.md) - project_id: Project identifier from docs-project.yaml (e.g., "my-project") - doc_uuid: Unique identifier for backend tracking (UUID v4 format) + + DERIVED FIELDS (computed from git history): + - updated: Last modification date, derived from git commit history """ title: str = Field( @@ -477,10 +473,6 @@ class PRDFrontmatter(BaseModel): ..., description="DateTime PRD was first created in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Do not change after initial creation", ) - updated: datetime.datetime | datetime.date | str = Field( - ..., - description="DateTime PRD was last modified in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ). Update whenever content changes", - ) target_release: str = Field( ..., description="Target release version or date (e.g., 'v2.0.0', 'Q2 2025', '2025-06-01')", diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 698bde5..44a9e56 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -1,8 +1,11 @@ """Tests for CLI commands in cli.py to improve coverage.""" +import subprocess + +import frontmatter from click.testing import CliRunner -from docuchango.cli import main, validate +from docuchango.cli import main, migrate, validate class TestValidateCommand: @@ -292,6 +295,205 @@ def test_all_subcommands_listed(self): assert "validate" in output_lower or "init" in output_lower +class TestMigrateCommand: + """Test the migrate command.""" + + def test_migrate_help(self): + """Test migrate command shows help.""" + runner = CliRunner() + result = runner.invoke(migrate, ["--help"]) + assert result.exit_code == 0 + assert "Migrate documents" in result.output + assert "--project-id" in result.output + assert "--dry-run" in result.output + + def test_migrate_removes_updated_field(self, tmp_path): + """Test that migrate removes the 'updated' field.""" + # Create a git repo + repo = tmp_path / "repo" + adr_dir = repo / "adr" + adr_dir.mkdir(parents=True) + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True, capture_output=True) + + # Create file with 'updated' field that should be removed + test_file = adr_dir / "adr-001-test.md" + content = """--- +id: adr-001 +title: "Test ADR Title" +status: Accepted +created: "2025-01-01" +updated: "2025-01-15" +deciders: "Core Team" +tags: + - test +project_id: test-project +doc_uuid: 12345678-1234-4123-8123-123456789abc +--- + +# Test ADR +""" + test_file.write_text(content, encoding="utf-8") + + # Commit it + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Add doc"], cwd=repo, check=True, capture_output=True) + + # Run migrate + runner = CliRunner() + result = runner.invoke(migrate, ["--project-id", "test-project", "--path", str(repo)]) + + assert result.exit_code == 0 + assert "Removed 'updated' field" in result.output + + # Verify updated field was removed + post = frontmatter.loads(test_file.read_text(encoding="utf-8")) + assert "updated" not in post.metadata + assert "created" in post.metadata + + def test_migrate_removes_date_field(self, tmp_path): + """Test that migrate removes the legacy 'date' field.""" + # Create a git repo + repo = tmp_path / "repo" + adr_dir = repo / "adr" + adr_dir.mkdir(parents=True) + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True, capture_output=True) + + # Create file with legacy 'date' field + test_file = adr_dir / "adr-001-test.md" + content = """--- +id: adr-001 +title: "Test ADR Title" +status: Accepted +date: "2025-01-01" +deciders: "Core Team" +tags: + - test +project_id: test-project +doc_uuid: 12345678-1234-4123-8123-123456789abc +--- + +# Test ADR +""" + test_file.write_text(content, encoding="utf-8") + + # Commit it + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Add doc"], cwd=repo, check=True, capture_output=True) + + # Run migrate + runner = CliRunner() + result = runner.invoke(migrate, ["--project-id", "test-project", "--path", str(repo)]) + + assert result.exit_code == 0 + assert "Removed deprecated 'date' field" in result.output + + # Verify date field was removed and created was added + post = frontmatter.loads(test_file.read_text(encoding="utf-8")) + assert "date" not in post.metadata + assert "created" in post.metadata + # created should be datetime format from git + assert "T" in post.metadata["created"] # ISO 8601 datetime has T separator + + def test_migrate_normalizes_created_to_datetime(self, tmp_path): + """Test that migrate normalizes date-only 'created' to datetime format.""" + # Create a git repo + repo = tmp_path / "repo" + adr_dir = repo / "adr" + adr_dir.mkdir(parents=True) + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True, capture_output=True) + + # Create file with date-only 'created' field + test_file = adr_dir / "adr-001-test.md" + content = """--- +id: adr-001 +title: "Test ADR Title" +status: Accepted +created: "2025-01-01" +deciders: "Core Team" +tags: + - test +project_id: test-project +doc_uuid: 12345678-1234-4123-8123-123456789abc +--- + +# Test ADR +""" + test_file.write_text(content, encoding="utf-8") + + # Commit it + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Add doc"], cwd=repo, check=True, capture_output=True) + + # Run migrate + runner = CliRunner() + result = runner.invoke(migrate, ["--project-id", "test-project", "--path", str(repo)]) + + assert result.exit_code == 0 + assert "Normalized created" in result.output + + # Verify created is now datetime format + post = frontmatter.loads(test_file.read_text(encoding="utf-8")) + assert "created" in post.metadata + # Should be ISO 8601 datetime format (YYYY-MM-DDTHH:MM:SSZ) + assert "T" in post.metadata["created"] + assert post.metadata["created"].endswith("Z") + + def test_migrate_dry_run_no_changes(self, tmp_path): + """Test that --dry-run doesn't modify files.""" + # Create a git repo + repo = tmp_path / "repo" + adr_dir = repo / "adr" + adr_dir.mkdir(parents=True) + + subprocess.run(["git", "init"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.email", "test@test.com"], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True, capture_output=True) + + # Create file with fields to be removed + test_file = adr_dir / "adr-001-test.md" + original_content = """--- +id: adr-001 +title: "Test ADR Title" +status: Accepted +created: "2025-01-01" +updated: "2025-01-15" +deciders: "Core Team" +tags: + - test +project_id: test-project +doc_uuid: 12345678-1234-4123-8123-123456789abc +--- + +# Test ADR +""" + test_file.write_text(original_content, encoding="utf-8") + + # Commit it + subprocess.run(["git", "add", "."], cwd=repo, check=True, capture_output=True) + subprocess.run(["git", "commit", "-m", "Add doc"], cwd=repo, check=True, capture_output=True) + + # Run migrate with --dry-run + runner = CliRunner() + result = runner.invoke(migrate, ["--project-id", "test-project", "--path", str(repo), "--dry-run"]) + + assert result.exit_code == 0 + assert "DRY RUN" in result.output + assert "Would modify" in result.output + + # Verify file was NOT modified + after_content = test_file.read_text(encoding="utf-8") + assert after_content == original_content + + class TestCLIErrorHandling: """Test CLI error handling and edge cases.""" diff --git a/tests/test_coverage_improvements.py b/tests/test_coverage_improvements.py index 3d89c37..8b81f0e 100644 --- a/tests/test_coverage_improvements.py +++ b/tests/test_coverage_improvements.py @@ -387,7 +387,6 @@ def test_adr_frontmatter_with_all_fields(self): status="Accepted", tags=["api", "database"], created="2024-01-01", - updated="2024-01-02", deciders="Core Team", project_id="test-project", doc_uuid="12345678-1234-4123-8123-123456789abc", @@ -406,7 +405,6 @@ def test_rfc_frontmatter_with_all_fields(self): tags=["api"], author="Test Author", created="2024-01-01", - updated="2024-01-02", project_id="test-project", doc_uuid="12345678-1234-4123-8123-123456789abc", ) @@ -419,12 +417,9 @@ def test_memo_frontmatter_with_all_fields(self): frontmatter = MemoFrontmatter( id="memo-001", title="Test Memo Title That Is Long Enough", - status="Final", tags=["note"], author="Test Author", - date="2024-01-01", created="2024-01-01", - updated="2024-01-02", project_id="test-project", doc_uuid="12345678-1234-4123-8123-123456789abc", ) @@ -441,7 +436,6 @@ def test_prd_frontmatter_with_all_fields(self): tags=["feature"], author="Test Author", created="2024-01-01", - updated="2024-01-02", target_release="Q1 2024", project_id="test-project", doc_uuid="12345678-1234-4123-8123-123456789abc", diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 11031b9..13f7b44 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -24,7 +24,6 @@ def test_valid_adr(self): title="Use gRPC for API Design", status="Accepted", created=date(2025, 10, 13), - updated=date(2025, 10, 14), deciders="Engineering Team", tags=["grpc", "api", "design"], id="adr-001", @@ -42,7 +41,6 @@ def test_adr_missing_required_field(self): title="Test ADR", status="Proposed", created=date(2025, 10, 13), - updated=date(2025, 10, 13), # Missing deciders tags=["test"], id="adr-001", @@ -58,7 +56,6 @@ def test_adr_invalid_status(self): title="Test ADR", status="Invalid", # Not in allowed values created=date(2025, 10, 13), - updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="adr-001", @@ -74,7 +71,6 @@ def test_adr_invalid_id_format(self): title="Test ADR", status="Proposed", created=date(2025, 10, 13), - updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="ADR-001", # Should be lowercase @@ -90,7 +86,6 @@ def test_adr_invalid_uuid(self): title="Test ADR", status="Proposed", created=date(2025, 10, 13), - updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="adr-001", @@ -106,7 +101,6 @@ def test_adr_invalid_tags(self): title="Test ADR", status="Proposed", created=date(2025, 10, 13), - updated=date(2025, 10, 13), deciders="Team", tags=["Invalid Tag"], # Should be lowercase with hyphens id="adr-001", @@ -122,7 +116,6 @@ def test_adr_short_title(self): title="Short", # Less than 10 characters status="Proposed", created=date(2025, 10, 13), - updated=date(2025, 10, 13), deciders="Team", tags=["test"], id="adr-001", @@ -142,7 +135,6 @@ def test_valid_rfc(self): status="Proposed", author="Engineering Team", created=date(2025, 10, 13), - updated=date(2025, 10, 14), tags=["vpc", "management"], id="rfc-001", project_id="test-project", @@ -167,21 +159,6 @@ def test_rfc_missing_author(self): ) assert "author" in str(exc_info.value).lower() - def test_rfc_optional_updated(self): - """Test that updated field is optional.""" - rfc = RFCFrontmatter( - title="Test RFC Title", - status="Draft", - author="Team", - created=date(2025, 10, 13), - # updated is optional - tags=["test"], - id="rfc-001", - project_id="test-project", - doc_uuid="046aa65f-f236-4221-9c19-6bf3e1e9f0f0", - ) - assert rfc.updated is None - def test_rfc_invalid_status(self): """Test that invalid status values are rejected.""" with pytest.raises(ValidationError): @@ -206,7 +183,6 @@ def test_valid_memo(self): title="Atlas TFC Agent Request Pattern", author="Engineering Team", created=date(2025, 10, 14), - updated=date(2025, 10, 14), tags=["atlas", "tfc", "agent"], id="memo-001", project_id="test-project", @@ -215,21 +191,6 @@ def test_valid_memo(self): assert memo.title == "Atlas TFC Agent Request Pattern" assert memo.id == "memo-001" - def test_memo_missing_updated(self): - """Test that missing updated field raises ValidationError.""" - with pytest.raises(ValidationError) as exc_info: - MemoFrontmatter( - title="Test Memo Title", - author="Team", - created=date(2025, 10, 14), - # Missing updated - it's required for memos - tags=["test"], - id="memo-001", - project_id="test-project", - doc_uuid="5c345ed0-a7e3-4104-832b-c0c5d7f2848d", - ) - assert "updated" in str(exc_info.value).lower() - def test_memo_invalid_id_format(self): """Test that invalid memo ID format is rejected.""" with pytest.raises(ValidationError): @@ -237,7 +198,6 @@ def test_memo_invalid_id_format(self): title="Test Memo Title", author="Team", created=date(2025, 10, 14), - updated=date(2025, 10, 14), tags=["test"], id="MEMO-001", # Should be lowercase project_id="test-project", @@ -302,7 +262,6 @@ def test_valid_prd(self): status="Draft", author="Product Team", created=date(2025, 10, 15), - updated=date(2025, 10, 20), target_release="v2.0.0", tags=["feature", "authentication", "security"], id="prd-001", @@ -362,23 +321,6 @@ def test_prd_invalid_id_format(self): ) assert "prd-xxx" in str(exc_info.value).lower() or "lowercase" in str(exc_info.value).lower() - def test_prd_updated_required(self): - """Test that PRD updated field is required (consistent with Memo).""" - with pytest.raises(ValidationError) as exc_info: - PRDFrontmatter( - title="Test PRD with no update", - status="Draft", - author="Team", - created=date(2025, 10, 15), - # updated is now required - missing it should fail - target_release="Q1 2026", - tags=["test"], - id="prd-002", - project_id="test-project", - doc_uuid="9a234567-1234-4abc-8def-123456789abc", - ) - assert "updated" in str(exc_info.value).lower() - class TestDocsProjectConfig: """Test DocsProjectConfig schema validation."""