diary-docs 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
diary/sync/engine.py ADDED
@@ -0,0 +1,404 @@
1
+ """Sync orchestrator — integrates git_utils, indexer, detector, merge, and protocol.
2
+
3
+ The :func:`run_sync` function is the main entry point for the diary sync
4
+ pipeline. It ties together branch detection, re-indexing, change detection,
5
+ AIMB conflict resolution, and report generation into a single callable API.
6
+
7
+ Usage::
8
+
9
+ from diary.sync.engine import SyncConfig, run_sync
10
+
11
+ config = SyncConfig(workspace_path=Path("."))
12
+ result = run_sync(config)
13
+ if result.success:
14
+ print(result.report)
15
+ else:
16
+ for err in result.errors:
17
+ print(err)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import subprocess
24
+ import time
25
+ from dataclasses import dataclass, field
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+
29
+ from diary.aimb.merge import (
30
+ MergeDecision,
31
+ decide_block_action,
32
+ generate_block_diff,
33
+ )
34
+ from diary.aimb.parser import parse_aimb_blocks
35
+ from diary.git_utils import (
36
+ get_current_branch,
37
+ is_git_repo,
38
+ sanitize_branch_name,
39
+ )
40
+ from diary.indexer.indexer import run_index
41
+ from diary.sync.detector import (
42
+ ChangeType,
43
+ classify_changes,
44
+ detect_code_changes,
45
+ detect_doc_changes,
46
+ )
47
+ from diary.sync.protocol import (
48
+ ChangeEntry,
49
+ ConflictEntry,
50
+ SyncReport,
51
+ SyncStats,
52
+ serialize_report,
53
+ )
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Dataclasses
60
+ # ---------------------------------------------------------------------------
61
+
62
+
63
+ @dataclass
64
+ class SyncConfig:
65
+ """Configuration for a single sync run.
66
+
67
+ Parameters
68
+ ----------
69
+ workspace_path : Path
70
+ Root of the workspace / repository to sync.
71
+ dry_run : bool
72
+ If ``True``, report changes to stdout without modifying any files
73
+ (default ``False``).
74
+ force : bool
75
+ If ``True``, skip safety prompts (default ``False``).
76
+ branch_override : str | None
77
+ Explicit branch name to use instead of auto-detecting from git.
78
+ output_path : str | None
79
+ File path to write the serialised report to instead of stdout.
80
+ """
81
+
82
+ workspace_path: Path
83
+ dry_run: bool = False
84
+ force: bool = False
85
+ branch_override: str | None = None
86
+ output_path: str | None = None
87
+
88
+
89
+ @dataclass
90
+ class SyncResult:
91
+ """Result produced by a single call to :func:`run_sync`.
92
+
93
+ Parameters
94
+ ----------
95
+ success : bool
96
+ ``True`` when the sync completed without errors.
97
+ report : SyncReport | None
98
+ The generated sync report, or ``None`` if pre-flight checks failed.
99
+ errors : list[str]
100
+ Human-readable error / warning messages collected during the run.
101
+ """
102
+
103
+ success: bool
104
+ report: SyncReport | None = None
105
+ errors: list[str] = field(default_factory=list)
106
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Internal helpers
110
+ # ---------------------------------------------------------------------------
111
+
112
+
113
+ def _git_show(workspace_path: Path, file_path: str) -> str:
114
+ """Return the committed content of *file_path* at HEAD.
115
+
116
+ Returns an empty string when the file is not tracked or on error.
117
+ """
118
+ try:
119
+ result = subprocess.run(
120
+ ["git", "show", f"HEAD:{file_path}"],
121
+ capture_output=True,
122
+ text=True,
123
+ cwd=str(workspace_path),
124
+ )
125
+ if result.returncode == 0:
126
+ return result.stdout
127
+ except (FileNotFoundError, OSError):
128
+ pass
129
+ return ""
130
+
131
+
132
+ def _resolve_branch(config: SyncConfig) -> tuple[str, list[str]]:
133
+ """Resolve the effective branch name.
134
+
135
+ Returns
136
+ -------
137
+ tuple[str, list[str]]
138
+ ``(branch_name, warnings)``
139
+ """
140
+ warnings: list[str] = []
141
+
142
+ if config.branch_override:
143
+ return config.branch_override, warnings
144
+
145
+ branch = get_current_branch(config.workspace_path)
146
+ if not branch:
147
+ warnings.append("Could not detect current branch — using 'unknown'")
148
+ return "unknown", warnings
149
+
150
+ if branch == "HEAD":
151
+ warnings.append(
152
+ "Detached HEAD state detected — using 'detached' for index isolation. "
153
+ "The knowledge database will be stored as knowledge-detached.db"
154
+ )
155
+ return "detached", warnings
156
+
157
+ return branch, warnings
158
+
159
+
160
+ def _build_change_entries(classification: dict) -> list[ChangeEntry]:
161
+ """Convert classified :class:`~diary.sync.detector.DetectedChange` objects
162
+ into protocol :class:`~diary.sync.protocol.ChangeEntry` instances."""
163
+ entries: list[ChangeEntry] = []
164
+ for _type_name, changes in classification.get("by_type", {}).items():
165
+ for change in changes:
166
+ entries.append(
167
+ ChangeEntry(
168
+ file_path=change.file_path,
169
+ change_type=change.change_type.value,
170
+ affected_docs=list(change.affected_aimb_blocks),
171
+ confidence=change.confidence,
172
+ )
173
+ )
174
+ return entries
175
+
176
+
177
+ def _resolve_conflicts(
178
+ workspace_path: Path,
179
+ doc_changes: list,
180
+ ) -> tuple[list[ConflictEntry], list[str]]:
181
+ """Detect merge conflicts for changed AIMB blocks.
182
+
183
+ For each documented change classified as ``AIMB_BLOCK_CHANGED``, compare
184
+ the current block hash (in the working tree) against the committed hash
185
+ (from git HEAD). If they differ, the block was manually edited since the
186
+ last AI write, and we flag it as a conflict.
187
+
188
+ Returns
189
+ -------
190
+ tuple[list[ConflictEntry], list[str]]
191
+ ``(conflict_entries, errors)``
192
+ """
193
+ conflicts: list[ConflictEntry] = []
194
+ errors: list[str] = []
195
+
196
+ for change in doc_changes:
197
+ if change.change_type != ChangeType.AIMB_BLOCK_CHANGED:
198
+ continue
199
+
200
+ file_path = workspace_path / change.file_path
201
+ if not file_path.is_file():
202
+ continue
203
+
204
+ # Read current working-tree content
205
+ try:
206
+ current_content = file_path.read_text(encoding="utf-8")
207
+ except (OSError, UnicodeDecodeError) as exc:
208
+ errors.append(f"Cannot read {change.file_path}: {exc}")
209
+ continue
210
+
211
+ # Get committed version from git HEAD
212
+ committed_content = _git_show(workspace_path, change.file_path)
213
+ if not committed_content:
214
+ # File is new (not yet committed) — no conflict possible
215
+ continue
216
+
217
+ # Parse AIMB blocks from both versions
218
+ committed_blocks = parse_aimb_blocks(committed_content)
219
+ committed_hashes = {b.id: b.hash for b in committed_blocks}
220
+
221
+ current_blocks = parse_aimb_blocks(current_content)
222
+ current_by_id = {b.id: b for b in current_blocks}
223
+
224
+ for block_id in change.affected_aimb_blocks:
225
+ stored_hash = committed_hashes.get(block_id)
226
+ if stored_hash is None:
227
+ # Block didn't exist in committed version — can't conflict
228
+ continue
229
+
230
+ block = current_by_id.get(block_id)
231
+ if block is None:
232
+ # Block was removed from the working tree — skip
233
+ continue
234
+
235
+ decision = decide_block_action(block, stored_hash, current_content)
236
+ if decision == MergeDecision.REPLACE:
237
+ # Hashes match — no manual edit, safe to overwrite
238
+ continue
239
+ if decision == MergeDecision.SKIP:
240
+ # Block marked for removal — skip
241
+ continue
242
+
243
+ # FLAG_CONFLICT — build a diff preview between the two versions
244
+ committed_block = None
245
+ for cb in committed_blocks:
246
+ if cb.id == block_id:
247
+ committed_block = cb
248
+ break
249
+
250
+ diff_preview = ""
251
+ if committed_block is not None:
252
+ diff_preview = generate_block_diff(committed_block.content, block.content)
253
+
254
+ conflicts.append(
255
+ ConflictEntry(
256
+ file_path=change.file_path,
257
+ block_id=block_id,
258
+ old_hash=stored_hash,
259
+ current_hash=block.hash,
260
+ diff_preview=diff_preview,
261
+ )
262
+ )
263
+
264
+ return conflicts, errors
265
+
266
+
267
+ # ---------------------------------------------------------------------------
268
+ # Public API
269
+ # ---------------------------------------------------------------------------
270
+
271
+
272
+ def run_sync(config: SyncConfig) -> SyncResult:
273
+ """Execute a full sync cycle.
274
+
275
+ Pipeline steps
276
+ --------------
277
+ 1. **Pre-flight checks** — verify the workspace is a git repository.
278
+ 2. **Branch detection** — resolve the current branch (detached HEAD is
279
+ handled gracefully and mapped to ``"detached"``).
280
+ 3. **Re-indexing** — rebuild the per-branch knowledge index via
281
+ :func:`~diary.indexer.indexer.run_index`.
282
+ 4. **Change detection** — scan for code changes and documentation changes
283
+ via :func:`~diary.sync.detector.detect_code_changes` and
284
+ :func:`~diary.sync.detector.detect_doc_changes`.
285
+ 5. **Classification** — group and summarise all detected changes via
286
+ :func:`~diary.sync.detector.classify_changes`.
287
+ 6. **AIMB merge decisions** — for each changed documentation block, call
288
+ :func:`~diary.aimb.merge.decide_block_action` to detect conflicts.
289
+ 7. **Build report** — construct a :class:`~diary.sync.protocol.SyncReport`
290
+ with change entries, conflicts, and aggregate statistics.
291
+ 8. **Output** — serialise the report to JSON and write it to stdout (or
292
+ to a file if ``config.output_path`` is set).
293
+ If ``config.dry_run`` is ``True``, no files are modified.
294
+
295
+ Parameters
296
+ ----------
297
+ config : SyncConfig
298
+ Sync configuration.
299
+
300
+ Returns
301
+ -------
302
+ SyncResult
303
+ """
304
+ errors: list[str] = []
305
+ start_time = time.monotonic()
306
+
307
+ # ── 1. Pre-flight checks ────────────────────────────────
308
+ if not is_git_repo(config.workspace_path):
309
+ msg = (
310
+ f"Not a git repository: {config.workspace_path}\n"
311
+ "Run ``git init`` to initialise a repository, or set "
312
+ "``workspace_path`` to a valid git workspace."
313
+ )
314
+ errors.append(msg)
315
+ logger.error(msg)
316
+ return SyncResult(success=False, errors=errors)
317
+
318
+ # ── 2. Branch detection ─────────────────────────────────
319
+ branch, branch_warnings = _resolve_branch(config)
320
+ errors.extend(branch_warnings)
321
+
322
+ for w in branch_warnings:
323
+ logger.warning(w)
324
+
325
+ sanitized = sanitize_branch_name(branch)
326
+
327
+ # ── 3. Re-indexing ──────────────────────────────────────
328
+ logger.info("Re-indexing workspace for branch '%s'", branch)
329
+ try:
330
+ db = run_index(config.workspace_path, branch_name=sanitized)
331
+ except Exception as exc:
332
+ msg = f"Indexing failed: {exc}"
333
+ errors.append(msg)
334
+ logger.error(msg)
335
+ return SyncResult(success=False, errors=errors)
336
+
337
+ # ── 4. Change detection ─────────────────────────────────
338
+ logger.info("Detecting code changes")
339
+ code_changes = detect_code_changes(config.workspace_path, db)
340
+
341
+ logger.info("Detecting documentation changes")
342
+ doc_changes = detect_doc_changes(config.workspace_path)
343
+
344
+ all_changes = code_changes + doc_changes
345
+
346
+ # ── 5. Classification ───────────────────────────────────
347
+ classification = classify_changes(all_changes)
348
+ logger.info("Sync classification: %s", classification.get("summary", "unknown"))
349
+
350
+ # ── 6. AIMB merge decisions ─────────────────────────────
351
+ conflict_entries, conflict_errors = _resolve_conflicts(
352
+ config.workspace_path,
353
+ doc_changes,
354
+ )
355
+ errors.extend(conflict_errors)
356
+
357
+ # ── 7. Build report ─────────────────────────────────────
358
+ row = db.conn.execute("SELECT COUNT(*) FROM files").fetchone()
359
+ files_scanned = row[0] if row else 0
360
+
361
+ duration_ms = int((time.monotonic() - start_time) * 1000)
362
+
363
+ change_entries = _build_change_entries(classification)
364
+
365
+ # Count unique doc files with detected changes
366
+ doc_paths: set[str] = set()
367
+ for change in doc_changes:
368
+ doc_paths.add(change.file_path)
369
+ docs_updated = len(doc_paths)
370
+
371
+ report = SyncReport(
372
+ branch=branch,
373
+ timestamp=datetime.now(timezone.utc).isoformat(),
374
+ changes=change_entries,
375
+ conflicts=conflict_entries,
376
+ stats=SyncStats(
377
+ files_scanned=files_scanned,
378
+ docs_updated=docs_updated,
379
+ conflicts=len(conflict_entries),
380
+ duration_ms=duration_ms,
381
+ ),
382
+ )
383
+
384
+ # ── 8. Output ───────────────────────────────────────────
385
+ serialized = serialize_report(report)
386
+
387
+ if config.output_path:
388
+ output = Path(config.output_path)
389
+ try:
390
+ output.parent.mkdir(parents=True, exist_ok=True)
391
+ output.write_text(serialized, encoding="utf-8")
392
+ logger.info("Report written to %s", output)
393
+ except OSError as exc:
394
+ msg = f"Cannot write report to {output}: {exc}"
395
+ errors.append(msg)
396
+ logger.error(msg)
397
+ else:
398
+ print(serialized)
399
+
400
+ # Clean up database handle
401
+ db.close()
402
+
403
+ success = len(errors) == 0
404
+ return SyncResult(success=success, report=report, errors=errors)
diary/sync/protocol.py ADDED
@@ -0,0 +1,176 @@
1
+ """JSON protocol schema for AI agent communication during diary sync.
2
+
3
+ This module defines the data structures and serialization helpers
4
+ that format sync results into JSON consumed by the ``/diary-sync``
5
+ AI agent command.
6
+ """
7
+
8
+ import dataclasses
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, get_type_hints
12
+
13
+
14
+ # ── Data classes ────────────────────────────────────────────────────────────
15
+
16
+
17
+ @dataclass
18
+ class ChangeEntry:
19
+ """A single code change detected during the sync scan."""
20
+
21
+ file_path: str
22
+ change_type: str # e.g. "modified", "added", "deleted"
23
+ affected_docs: list[str] = field(default_factory=list)
24
+ confidence: float = 1.0
25
+
26
+
27
+ @dataclass
28
+ class ConflictEntry:
29
+ """A documentation block that was edited on both sides since last sync."""
30
+
31
+ file_path: str
32
+ block_id: str
33
+ old_hash: str
34
+ current_hash: str
35
+ diff_preview: str
36
+
37
+
38
+ @dataclass
39
+ class SyncStats:
40
+ """Aggregate counters for a single sync run."""
41
+
42
+ files_scanned: int = 0
43
+ docs_updated: int = 0
44
+ conflicts: int = 0
45
+ duration_ms: int = 0
46
+
47
+
48
+ @dataclass
49
+ class SyncReport:
50
+ """Top-level report produced after a sync scan completes."""
51
+
52
+ branch: str
53
+ timestamp: str # ISO-8601
54
+ changes: list[ChangeEntry] = field(default_factory=list)
55
+ conflicts: list[ConflictEntry] = field(default_factory=list)
56
+ stats: SyncStats = field(default_factory=SyncStats)
57
+
58
+
59
+ # ── Serialization ───────────────────────────────────────────────────────────
60
+
61
+
62
+ def _from_dict(cls: type[Any], data: dict[str, Any]) -> Any:
63
+ """Recursively build a dataclass (and nested dataclasses) from *data*."""
64
+ if not dataclasses.is_dataclass(cls):
65
+ return data
66
+
67
+ field_types = {f.name: f.type for f in dataclasses.fields(cls)}
68
+ kwargs: dict[str, Any] = {}
69
+
70
+ for fname, ftype in field_types.items():
71
+ if fname not in data:
72
+ continue
73
+ raw = data[fname]
74
+
75
+ # list[X] – try to unwrap the origin
76
+ origin = getattr(ftype, "__origin__", None)
77
+ if origin is list:
78
+ args = getattr(ftype, "__args__", (Any,))
79
+ elem_type = args[0] if args else Any
80
+ if dataclasses.is_dataclass(elem_type):
81
+ kwargs[fname] = [_from_dict(elem_type, item) for item in raw]
82
+ elif isinstance(raw, list):
83
+ kwargs[fname] = raw
84
+ else:
85
+ kwargs[fname] = raw
86
+ elif dataclasses.is_dataclass(ftype):
87
+ kwargs[fname] = _from_dict(ftype, raw)
88
+ else:
89
+ kwargs[fname] = raw
90
+
91
+ return cls(**kwargs)
92
+
93
+
94
+ def serialize_report(report: SyncReport) -> str:
95
+ """Serialize *report* to a pretty-printed JSON string."""
96
+ return json.dumps(dataclasses.asdict(report), indent=2)
97
+
98
+
99
+ def parse_report(json_str: str) -> SyncReport:
100
+ """Deserialize a JSON string back into a ``SyncReport`` instance."""
101
+ raw: dict[str, Any] = json.loads(json_str)
102
+ return _from_dict(SyncReport, raw)
103
+
104
+
105
+ # ── AI prompt generation ────────────────────────────────────────────────────
106
+
107
+
108
+ def get_ai_update_prompt(report: SyncReport) -> str:
109
+ """Generate a natural-language instruction string for an AI agent.
110
+
111
+ The prompt describes every change and conflict recorded in *report* so
112
+ that an AI agent can understand which documentation blocks need updating
113
+ and which ones require manual conflict resolution.
114
+ """
115
+ lines: list[str] = []
116
+ lines.append("## DIARY Sync Update Instructions")
117
+ lines.append("")
118
+ lines.append(f"**Branch:** {report.branch}")
119
+ lines.append(f"**Timestamp:** {report.timestamp}")
120
+ lines.append(f"**Files scanned:** {report.stats.files_scanned}")
121
+ lines.append(f"**Docs updated:** {report.stats.docs_updated}")
122
+ lines.append(f"**Duration:** {report.stats.duration_ms} ms")
123
+ lines.append("")
124
+
125
+ if report.changes:
126
+ lines.append("### Documentation Updates Required")
127
+ lines.append("")
128
+ lines.append(
129
+ "The following code changes need corresponding documentation updates:"
130
+ )
131
+ lines.append("")
132
+ for ch in report.changes:
133
+ lines.append(f"- **{ch.file_path}** ({ch.change_type})")
134
+ if ch.affected_docs:
135
+ lines.append(f" - Affected docs: {', '.join(ch.affected_docs)}")
136
+ lines.append(f" - Confidence: {ch.confidence:.0%}")
137
+ lines.append("")
138
+ else:
139
+ lines.append("*No documentation updates required.*")
140
+ lines.append("")
141
+
142
+ if report.conflicts:
143
+ lines.append("### Conflicts Detected (Manual Resolution Required)")
144
+ lines.append("")
145
+ lines.append(
146
+ "The following documentation blocks have been edited on both "
147
+ "sides since the last sync and need manual review:"
148
+ )
149
+ lines.append("")
150
+ for cf in report.conflicts:
151
+ lines.append(f"- **{cf.file_path}** — block `{cf.block_id}`")
152
+ lines.append(f" - Old hash: `{cf.old_hash}`")
153
+ lines.append(f" - Current hash: `{cf.current_hash}`")
154
+ lines.append(f" - Diff preview: {cf.diff_preview}")
155
+ lines.append("")
156
+ else:
157
+ lines.append("*No conflicts detected.*")
158
+ lines.append("")
159
+
160
+ lines.append("### Action Items")
161
+ lines.append("")
162
+ if report.changes:
163
+ lines.append(
164
+ "1. For each change listed above, update the corresponding "
165
+ "documentation block to reflect the new code."
166
+ )
167
+ if report.conflicts:
168
+ lines.append(
169
+ "2. For each conflict, manually review the diff preview and "
170
+ "decide which version to keep (or merge both)."
171
+ )
172
+ if not report.changes and not report.conflicts:
173
+ lines.append("No action required — everything is up to date.")
174
+ lines.append("")
175
+
176
+ return "\n".join(lines)
diary/templates.py ADDED
@@ -0,0 +1,102 @@
1
+ from datetime import date
2
+ from pathlib import Path
3
+
4
+
5
+ def aimb_wrap_body(content: str, filename: str) -> str:
6
+ """Wrap body content (after frontmatter) in an AI-managed block.
7
+
8
+ Args:
9
+ content: Full file content including frontmatter.
10
+ filename: Filename (e.g. ``index.md``) — the stem becomes the AIMB id.
11
+
12
+ Returns:
13
+ Content with an ``<!-- ai-managed … -->`` wrapper around everything
14
+ after the first YAML frontmatter block.
15
+ """
16
+ today = date.today().isoformat()
17
+ aimb_id = Path(filename).stem
18
+ opening = f'<!-- ai-managed id="{aimb_id}" hash="init" updated="{today}" -->'
19
+ closing = "<!-- /ai-managed -->"
20
+
21
+ if content.startswith("---"):
22
+ parts = content.split("---", 2)
23
+ if len(parts) == 3:
24
+ _, front, body = parts
25
+ body = body.strip("\n")
26
+ return f"---{front}---\n\n{opening}\n{body}\n\n{closing}\n"
27
+ return f"{opening}\n{content}\n\n{closing}\n"
28
+
29
+
30
+ MKDOCS_TEMPLATE = """\
31
+ site_name: {project_name}
32
+ site_description: Technical documentation and knowledge base for {project_name}
33
+
34
+ nav:
35
+ - Pendahuluan:
36
+ - Beranda: index.md
37
+ - Tujuan: objectives.md
38
+ - Panduan Umum:
39
+ - Panduan Menulis Docs: structure/index.md
40
+ - Technical Documentation:
41
+ - Overview: techdocs/index.md
42
+ - Arsitektur: techdocs/architecture.md
43
+ - API Contracts: techdocs/api-contracts.md
44
+ - Deployment Flow: techdocs/deployment.md
45
+ - ADR Log: techdocs/adr.md
46
+ - Development Guide: techdocs/development-guide.md
47
+ - Product Documentation:
48
+ - Overview: product/index.md
49
+ - User Guide: product/user-guide.md
50
+ - Modules: product/modules.md
51
+ - Ops & Governance:
52
+ - Overview: ops-governance/index.md
53
+ - Runbooks: ops-governance/runbooks.md
54
+ - Security: ops-governance/security.md
55
+ - Knowledge Base:
56
+ - Overview: knowledge-base/index.md
57
+ - FAQ: knowledge-base/faq.md
58
+ - Troubleshooting: knowledge-base/troubleshooting.md
59
+
60
+ plugins:
61
+ - techdocs-core
62
+
63
+ markdown_extensions:
64
+ - admonition
65
+ - pymdownx.details
66
+ - pymdownx.superfences:
67
+ custom_fences:
68
+ - name: mermaid
69
+ class: mermaid
70
+ format: !!python/name:pymdownx.superfences.fence_code_format
71
+ - pymdownx.tabbed:
72
+ alternate_style: true
73
+ - pymdownx.highlight:
74
+ anchor_linenums: true
75
+ - pymdownx.inlinehilite
76
+ - pymdownx.snippets
77
+ - tables
78
+ - attr_list
79
+ - md_in_html
80
+ - toc:
81
+ permalink: true
82
+ """
83
+
84
+
85
+ CATALOG_TEMPLATE = """\
86
+ apiVersion: backstage.io/v1alpha1
87
+ kind: Component
88
+ metadata:
89
+ name: {project_name}
90
+ title: {project_title}
91
+ description: Documentation site for {project_name}
92
+ annotations:
93
+ backstage.io/techdocs-ref: dir:.
94
+ gitlab.com/project-id: '0'
95
+ gitlab.com/instance: gitlab.example.com
96
+ gitlab.com/project-slug: group/{project_name}
97
+ tags: []
98
+ spec:
99
+ type: documentation
100
+ lifecycle: production
101
+ owner: unknown
102
+ """