codealmanac 0.1.0.dev0__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.
Files changed (192) hide show
  1. codealmanac/__init__.py +13 -0
  2. codealmanac/app.py +175 -0
  3. codealmanac/cli/__init__.py +1 -0
  4. codealmanac/cli/dispatch/__init__.py +0 -0
  5. codealmanac/cli/dispatch/admin.py +124 -0
  6. codealmanac/cli/dispatch/config.py +50 -0
  7. codealmanac/cli/dispatch/root.py +328 -0
  8. codealmanac/cli/main.py +28 -0
  9. codealmanac/cli/parser/__init__.py +0 -0
  10. codealmanac/cli/parser/admin.py +81 -0
  11. codealmanac/cli/parser/lifecycle.py +57 -0
  12. codealmanac/cli/parser/root.py +19 -0
  13. codealmanac/cli/parser/wiki.py +87 -0
  14. codealmanac/cli/render/__init__.py +0 -0
  15. codealmanac/cli/render/admin.py +191 -0
  16. codealmanac/cli/render/root.py +290 -0
  17. codealmanac/core/__init__.py +1 -0
  18. codealmanac/core/errors.py +45 -0
  19. codealmanac/core/models.py +14 -0
  20. codealmanac/core/paths.py +25 -0
  21. codealmanac/core/slug.py +7 -0
  22. codealmanac/core/text.py +5 -0
  23. codealmanac/database/__init__.py +15 -0
  24. codealmanac/database/sqlite.py +54 -0
  25. codealmanac/integrations/__init__.py +1 -0
  26. codealmanac/integrations/automation/__init__.py +3 -0
  27. codealmanac/integrations/automation/scheduler/__init__.py +5 -0
  28. codealmanac/integrations/automation/scheduler/launchd.py +163 -0
  29. codealmanac/integrations/command.py +56 -0
  30. codealmanac/integrations/harnesses/__init__.py +7 -0
  31. codealmanac/integrations/harnesses/claude/__init__.py +1 -0
  32. codealmanac/integrations/harnesses/claude/adapter.py +217 -0
  33. codealmanac/integrations/harnesses/codex/__init__.py +3 -0
  34. codealmanac/integrations/harnesses/codex/adapter.py +221 -0
  35. codealmanac/integrations/harnesses/git_status.py +49 -0
  36. codealmanac/integrations/sources/__init__.py +29 -0
  37. codealmanac/integrations/sources/filesystem/__init__.py +5 -0
  38. codealmanac/integrations/sources/filesystem/adapter.py +685 -0
  39. codealmanac/integrations/sources/filesystem/selection.py +209 -0
  40. codealmanac/integrations/sources/git/__init__.py +3 -0
  41. codealmanac/integrations/sources/git/adapter.py +132 -0
  42. codealmanac/integrations/sources/github/__init__.py +3 -0
  43. codealmanac/integrations/sources/github/adapter.py +413 -0
  44. codealmanac/integrations/sources/runtime.py +22 -0
  45. codealmanac/integrations/sources/transcripts/__init__.py +33 -0
  46. codealmanac/integrations/sources/transcripts/claude.py +61 -0
  47. codealmanac/integrations/sources/transcripts/codex.py +69 -0
  48. codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
  49. codealmanac/integrations/sources/transcripts/runtime.py +387 -0
  50. codealmanac/integrations/sources/web/__init__.py +3 -0
  51. codealmanac/integrations/sources/web/adapter.py +303 -0
  52. codealmanac/integrations/updates/__init__.py +7 -0
  53. codealmanac/integrations/updates/package.py +85 -0
  54. codealmanac/integrations/workspaces/__init__.py +1 -0
  55. codealmanac/integrations/workspaces/git/__init__.py +3 -0
  56. codealmanac/integrations/workspaces/git/probe.py +128 -0
  57. codealmanac/manual/README.md +24 -0
  58. codealmanac/manual/__init__.py +19 -0
  59. codealmanac/manual/build.md +20 -0
  60. codealmanac/manual/evidence.md +23 -0
  61. codealmanac/manual/garden.md +20 -0
  62. codealmanac/manual/ingest.md +17 -0
  63. codealmanac/manual/library.py +84 -0
  64. codealmanac/manual/models.py +83 -0
  65. codealmanac/manual/pages.md +28 -0
  66. codealmanac/manual/requests.py +6 -0
  67. codealmanac/manual/sources.md +18 -0
  68. codealmanac/manual/style.md +19 -0
  69. codealmanac/prompts/__init__.py +5 -0
  70. codealmanac/prompts/base/notability.md +14 -0
  71. codealmanac/prompts/base/purpose.md +23 -0
  72. codealmanac/prompts/base/syntax.md +19 -0
  73. codealmanac/prompts/models.py +9 -0
  74. codealmanac/prompts/operations/garden.md +26 -0
  75. codealmanac/prompts/operations/ingest.md +18 -0
  76. codealmanac/prompts/renderer.py +24 -0
  77. codealmanac/prompts/requests.py +22 -0
  78. codealmanac/server/__init__.py +1 -0
  79. codealmanac/server/app.py +202 -0
  80. codealmanac/server/assets/__init__.py +1 -0
  81. codealmanac/server/assets/app.css +865 -0
  82. codealmanac/server/assets/app.js +3 -0
  83. codealmanac/server/assets/index.html +80 -0
  84. codealmanac/server/assets/viewer/api.js +30 -0
  85. codealmanac/server/assets/viewer/components.js +197 -0
  86. codealmanac/server/assets/viewer/main.js +126 -0
  87. codealmanac/server/assets/viewer/renderers.js +122 -0
  88. codealmanac/server/assets/viewer/routes.js +36 -0
  89. codealmanac/services/__init__.py +1 -0
  90. codealmanac/services/automation/__init__.py +3 -0
  91. codealmanac/services/automation/models.py +83 -0
  92. codealmanac/services/automation/ports.py +14 -0
  93. codealmanac/services/automation/requests.py +40 -0
  94. codealmanac/services/automation/service.py +294 -0
  95. codealmanac/services/config/__init__.py +17 -0
  96. codealmanac/services/config/models.py +61 -0
  97. codealmanac/services/config/requests.py +21 -0
  98. codealmanac/services/config/service.py +55 -0
  99. codealmanac/services/config/store.py +26 -0
  100. codealmanac/services/diagnostics/__init__.py +1 -0
  101. codealmanac/services/diagnostics/models.py +22 -0
  102. codealmanac/services/diagnostics/requests.py +8 -0
  103. codealmanac/services/diagnostics/service.py +283 -0
  104. codealmanac/services/harnesses/__init__.py +1 -0
  105. codealmanac/services/harnesses/models.py +104 -0
  106. codealmanac/services/harnesses/ports.py +18 -0
  107. codealmanac/services/harnesses/requests.py +19 -0
  108. codealmanac/services/harnesses/service.py +38 -0
  109. codealmanac/services/health/__init__.py +1 -0
  110. codealmanac/services/health/requests.py +8 -0
  111. codealmanac/services/health/service.py +20 -0
  112. codealmanac/services/index/__init__.py +1 -0
  113. codealmanac/services/index/models.py +135 -0
  114. codealmanac/services/index/requests.py +26 -0
  115. codealmanac/services/index/service.py +86 -0
  116. codealmanac/services/index/store.py +411 -0
  117. codealmanac/services/index/views.py +524 -0
  118. codealmanac/services/pages/__init__.py +1 -0
  119. codealmanac/services/pages/requests.py +17 -0
  120. codealmanac/services/pages/service.py +26 -0
  121. codealmanac/services/runs/__init__.py +1 -0
  122. codealmanac/services/runs/models.py +91 -0
  123. codealmanac/services/runs/requests.py +76 -0
  124. codealmanac/services/runs/service.py +86 -0
  125. codealmanac/services/runs/store.py +256 -0
  126. codealmanac/services/search/__init__.py +1 -0
  127. codealmanac/services/search/requests.py +23 -0
  128. codealmanac/services/search/service.py +31 -0
  129. codealmanac/services/sources/__init__.py +1 -0
  130. codealmanac/services/sources/models.py +126 -0
  131. codealmanac/services/sources/ports.py +30 -0
  132. codealmanac/services/sources/requests.py +76 -0
  133. codealmanac/services/sources/service.py +351 -0
  134. codealmanac/services/tagging/__init__.py +1 -0
  135. codealmanac/services/tagging/models.py +9 -0
  136. codealmanac/services/tagging/requests.py +35 -0
  137. codealmanac/services/tagging/service.py +43 -0
  138. codealmanac/services/topics/__init__.py +1 -0
  139. codealmanac/services/topics/models.py +36 -0
  140. codealmanac/services/topics/requests.py +115 -0
  141. codealmanac/services/topics/service.py +297 -0
  142. codealmanac/services/updates/__init__.py +4 -0
  143. codealmanac/services/updates/models.py +83 -0
  144. codealmanac/services/updates/ports.py +17 -0
  145. codealmanac/services/updates/requests.py +10 -0
  146. codealmanac/services/updates/service.py +113 -0
  147. codealmanac/services/viewer/__init__.py +1 -0
  148. codealmanac/services/viewer/models.py +80 -0
  149. codealmanac/services/viewer/renderer.py +89 -0
  150. codealmanac/services/viewer/requests.py +86 -0
  151. codealmanac/services/viewer/service.py +211 -0
  152. codealmanac/services/wiki/__init__.py +1 -0
  153. codealmanac/services/wiki/documents.py +83 -0
  154. codealmanac/services/wiki/frontmatter.py +94 -0
  155. codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
  156. codealmanac/services/wiki/models.py +69 -0
  157. codealmanac/services/wiki/paths.py +42 -0
  158. codealmanac/services/wiki/service.py +57 -0
  159. codealmanac/services/wiki/templates.py +73 -0
  160. codealmanac/services/wiki/topics.py +266 -0
  161. codealmanac/services/wiki/wikilinks.py +58 -0
  162. codealmanac/services/workspaces/__init__.py +1 -0
  163. codealmanac/services/workspaces/models.py +124 -0
  164. codealmanac/services/workspaces/ports.py +9 -0
  165. codealmanac/services/workspaces/requests.py +82 -0
  166. codealmanac/services/workspaces/roots.py +74 -0
  167. codealmanac/services/workspaces/service.py +303 -0
  168. codealmanac/services/workspaces/store.py +127 -0
  169. codealmanac/workflows/__init__.py +1 -0
  170. codealmanac/workflows/build/__init__.py +1 -0
  171. codealmanac/workflows/build/models.py +8 -0
  172. codealmanac/workflows/build/service.py +45 -0
  173. codealmanac/workflows/garden/__init__.py +3 -0
  174. codealmanac/workflows/garden/models.py +30 -0
  175. codealmanac/workflows/garden/requests.py +22 -0
  176. codealmanac/workflows/garden/service.py +239 -0
  177. codealmanac/workflows/ingest/__init__.py +1 -0
  178. codealmanac/workflows/ingest/models.py +26 -0
  179. codealmanac/workflows/ingest/requests.py +39 -0
  180. codealmanac/workflows/ingest/service.py +302 -0
  181. codealmanac/workflows/lifecycle.py +197 -0
  182. codealmanac/workflows/sync/__init__.py +3 -0
  183. codealmanac/workflows/sync/models.py +157 -0
  184. codealmanac/workflows/sync/requests.py +63 -0
  185. codealmanac/workflows/sync/service.py +651 -0
  186. codealmanac/workflows/sync/store.py +51 -0
  187. codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
  188. codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
  189. codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
  190. codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  191. codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
  192. codealmanac-0.1.0.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,76 @@
1
+ from pathlib import Path
2
+
3
+ from pydantic import Field, field_validator
4
+
5
+ from codealmanac.core.models import CodeAlmanacModel
6
+ from codealmanac.services.sources.models import SourceRef, TranscriptApp
7
+ from codealmanac.services.workspaces.roots import (
8
+ DEFAULT_ALMANAC_ROOT,
9
+ normalized_almanac_roots,
10
+ )
11
+
12
+
13
+ class ResolveSourcesRequest(CodeAlmanacModel):
14
+ cwd: Path
15
+ inputs: tuple[str, ...]
16
+
17
+ @field_validator("inputs")
18
+ @classmethod
19
+ def require_inputs(cls, value: tuple[str, ...]) -> tuple[str, ...]:
20
+ if len(value) == 0:
21
+ raise ValueError("at least one source input is required")
22
+ return value
23
+
24
+
25
+ class DiscoverTranscriptsRequest(CodeAlmanacModel):
26
+ home: Path
27
+ apps: tuple[TranscriptApp, ...]
28
+ almanac_roots: tuple[Path, ...] = (DEFAULT_ALMANAC_ROOT,)
29
+
30
+ @field_validator("apps")
31
+ @classmethod
32
+ def require_apps(
33
+ cls,
34
+ value: tuple[TranscriptApp, ...],
35
+ ) -> tuple[TranscriptApp, ...]:
36
+ if len(value) == 0:
37
+ raise ValueError("at least one transcript app is required")
38
+ return value
39
+
40
+ @field_validator("almanac_roots")
41
+ @classmethod
42
+ def validate_almanac_roots(cls, value: tuple[Path, ...]) -> tuple[Path, ...]:
43
+ return normalized_almanac_roots(value)
44
+
45
+
46
+ class SourceRuntimeContext(CodeAlmanacModel):
47
+ ignored_directories: tuple[Path, ...] = ()
48
+
49
+ @field_validator("ignored_directories")
50
+ @classmethod
51
+ def validate_ignored_directories(
52
+ cls,
53
+ value: tuple[Path, ...],
54
+ ) -> tuple[Path, ...]:
55
+ directories: list[Path] = []
56
+ for directory in value:
57
+ normalized = normalize_ignored_directory(directory)
58
+ if normalized not in directories:
59
+ directories.append(normalized)
60
+ return tuple(directories)
61
+
62
+
63
+ class InspectSourceRuntimeRequest(CodeAlmanacModel):
64
+ cwd: Path
65
+ ref: SourceRef
66
+ context: SourceRuntimeContext = Field(default_factory=SourceRuntimeContext)
67
+
68
+
69
+ def normalize_ignored_directory(path: Path) -> Path:
70
+ if path.is_absolute():
71
+ raise ValueError("source runtime ignored directories must be repo-relative")
72
+ if len(path.parts) == 0:
73
+ raise ValueError("source runtime ignored directories must name a directory")
74
+ if any(part in {"..", "~"} for part in path.parts):
75
+ raise ValueError("source runtime ignored directories must stay inside the repo")
76
+ return Path(*path.parts)
@@ -0,0 +1,351 @@
1
+ from collections.abc import Sequence
2
+ from hashlib import sha256
3
+ from pathlib import Path
4
+ from urllib.parse import urlsplit
5
+
6
+ from pydantic import AnyHttpUrl, TypeAdapter, ValidationError
7
+
8
+ from codealmanac.core.errors import ValidationFailed
9
+ from codealmanac.core.paths import normalize_path
10
+ from codealmanac.services.sources.models import (
11
+ SourceAddress,
12
+ SourceBrief,
13
+ SourceKind,
14
+ SourceProvenanceKind,
15
+ SourceRef,
16
+ SourceRuntime,
17
+ SourceRuntimeStatus,
18
+ TranscriptCandidate,
19
+ )
20
+ from codealmanac.services.sources.ports import (
21
+ SourceRuntimeAdapter,
22
+ TranscriptDiscoveryAdapter,
23
+ )
24
+ from codealmanac.services.sources.requests import (
25
+ DiscoverTranscriptsRequest,
26
+ InspectSourceRuntimeRequest,
27
+ ResolveSourcesRequest,
28
+ )
29
+
30
+ PULL_REQUEST_PROMPT_HINT = (
31
+ "Inspect the pull request, diff, commits, reviews, and linked issues before "
32
+ "deciding whether durable wiki knowledge changed."
33
+ )
34
+ ISSUE_PROMPT_HINT = (
35
+ "Inspect the issue, linked pull requests, decisions, labels, and comments "
36
+ "before deciding whether durable wiki knowledge changed."
37
+ )
38
+ GIT_RANGE_PROMPT_HINT = (
39
+ "Inspect the commit range and changed files before deciding whether durable "
40
+ "wiki knowledge changed."
41
+ )
42
+ GIT_DIFF_PROMPT_HINT = (
43
+ "Inspect the diff and current files before deciding whether durable wiki "
44
+ "knowledge changed."
45
+ )
46
+ WEB_PROMPT_HINT = (
47
+ "Inspect the web page as source material before deciding whether durable "
48
+ "wiki knowledge changed."
49
+ )
50
+ DIRECTORY_PROMPT_HINT = (
51
+ "Inspect the directory as bounded local source material before deciding "
52
+ "whether durable wiki knowledge changed."
53
+ )
54
+ FILE_PROMPT_HINT = (
55
+ "Inspect the file as bounded local source material before deciding whether "
56
+ "durable wiki knowledge changed."
57
+ )
58
+ MISSING_PATH_PROMPT_HINT = (
59
+ "Resolve the missing local path before attempting to use it as source "
60
+ "material."
61
+ )
62
+
63
+ HTTP_URL_ADAPTER = TypeAdapter(AnyHttpUrl)
64
+
65
+
66
+ class SourcesService:
67
+ def __init__(
68
+ self,
69
+ transcript_discovery_adapters: Sequence[TranscriptDiscoveryAdapter] = (),
70
+ runtime_adapters: Sequence[SourceRuntimeAdapter] = (),
71
+ ):
72
+ self.transcript_discovery_adapters = tuple(transcript_discovery_adapters)
73
+ self.runtime_adapters = tuple(runtime_adapters)
74
+
75
+ def resolve(self, request: ResolveSourcesRequest) -> tuple[SourceBrief, ...]:
76
+ return tuple(
77
+ resolve_address(SourceAddress(raw=raw), request.cwd)
78
+ for raw in request.inputs
79
+ )
80
+
81
+ def discover_transcripts(
82
+ self,
83
+ request: DiscoverTranscriptsRequest,
84
+ ) -> tuple[TranscriptCandidate, ...]:
85
+ selected = set(request.apps)
86
+ candidates: list[TranscriptCandidate] = []
87
+ for adapter in self.transcript_discovery_adapters:
88
+ if adapter.app in selected:
89
+ candidates.extend(adapter.discover(request))
90
+ return tuple(sorted(candidates, key=transcript_sort_key))
91
+
92
+ def inspect_runtime(
93
+ self,
94
+ request: InspectSourceRuntimeRequest,
95
+ ) -> SourceRuntime:
96
+ for adapter in self.runtime_adapters:
97
+ if adapter.supports(request.ref):
98
+ return adapter.inspect(request)
99
+ return SourceRuntime(
100
+ ref=request.ref,
101
+ status=SourceRuntimeStatus.SKIPPED,
102
+ title=f"No runtime adapter for {request.ref.identity}",
103
+ )
104
+
105
+
106
+ def transcript_sort_key(candidate: TranscriptCandidate) -> tuple[str, str, str]:
107
+ return (
108
+ candidate.app.value,
109
+ str(candidate.transcript_path),
110
+ candidate.session_id,
111
+ )
112
+
113
+
114
+ def resolve_address(address: SourceAddress, cwd: Path) -> SourceBrief:
115
+ raw = address.raw
116
+ if raw.startswith("github:"):
117
+ return resolve_github_shorthand(raw)
118
+ if raw.startswith("git:range:"):
119
+ return resolve_git_range(raw)
120
+ if raw == "git:diff" or raw.startswith("git:diff:"):
121
+ return resolve_git_diff(raw)
122
+ if raw.startswith("transcript:"):
123
+ return resolve_transcript(raw)
124
+ parsed = urlsplit(raw)
125
+ if parsed.scheme in {"http", "https"}:
126
+ return resolve_url(raw)
127
+ return resolve_path(raw, cwd)
128
+
129
+
130
+ def resolve_github_shorthand(raw: str) -> SourceBrief:
131
+ parts = raw.split(":")
132
+ if len(parts) != 3:
133
+ raise ValidationFailed(f"invalid GitHub source address: {raw}")
134
+ _, source_type, number_text = parts
135
+ number = parse_positive_int(number_text, raw)
136
+ if source_type == "pr":
137
+ ref = SourceRef(
138
+ raw=raw,
139
+ kind=SourceKind.GITHUB_PULL_REQUEST,
140
+ identity=f"github.pull_request:{number}",
141
+ number=number,
142
+ )
143
+ return SourceBrief(
144
+ ref=ref,
145
+ title=f"GitHub pull request #{number}",
146
+ provenance_kind=SourceProvenanceKind.PR,
147
+ prompt_hint=PULL_REQUEST_PROMPT_HINT,
148
+ )
149
+ if source_type == "issue":
150
+ ref = SourceRef(
151
+ raw=raw,
152
+ kind=SourceKind.GITHUB_ISSUE,
153
+ identity=f"github.issue:{number}",
154
+ number=number,
155
+ )
156
+ return SourceBrief(
157
+ ref=ref,
158
+ title=f"GitHub issue #{number}",
159
+ provenance_kind=SourceProvenanceKind.ISSUE,
160
+ prompt_hint=ISSUE_PROMPT_HINT,
161
+ )
162
+ raise ValidationFailed(f"unsupported GitHub source address: {raw}")
163
+
164
+
165
+ def resolve_git_range(raw: str) -> SourceBrief:
166
+ revision_range = raw.removeprefix("git:range:").strip()
167
+ if not revision_range:
168
+ raise ValidationFailed("git range source requires a revision range")
169
+ ref = SourceRef(
170
+ raw=raw,
171
+ kind=SourceKind.GIT_RANGE,
172
+ identity=f"git.range:{revision_range}",
173
+ revision_range=revision_range,
174
+ )
175
+ return SourceBrief(
176
+ ref=ref,
177
+ title=f"Git range {revision_range}",
178
+ provenance_kind=SourceProvenanceKind.GIT,
179
+ prompt_hint=GIT_RANGE_PROMPT_HINT,
180
+ )
181
+
182
+
183
+ def resolve_git_diff(raw: str) -> SourceBrief:
184
+ target = raw.removeprefix("git:diff").removeprefix(":").strip() or "working-tree"
185
+ ref = SourceRef(
186
+ raw=raw,
187
+ kind=SourceKind.GIT_DIFF,
188
+ identity=f"git.diff:{target}",
189
+ revision_range=target,
190
+ )
191
+ return SourceBrief(
192
+ ref=ref,
193
+ title=f"Git diff {target}",
194
+ provenance_kind=SourceProvenanceKind.GIT,
195
+ prompt_hint=GIT_DIFF_PROMPT_HINT,
196
+ )
197
+
198
+
199
+ def resolve_transcript(raw: str) -> SourceBrief:
200
+ transcript = raw.removeprefix("transcript:").strip()
201
+ if not transcript:
202
+ raise ValidationFailed("transcript source requires an identifier or path")
203
+ ref = SourceRef(
204
+ raw=raw,
205
+ kind=SourceKind.TRANSCRIPT,
206
+ identity=f"transcript:{transcript}",
207
+ transcript=transcript,
208
+ )
209
+ return SourceBrief(
210
+ ref=ref,
211
+ title=f"Transcript {transcript}",
212
+ provenance_kind=SourceProvenanceKind.TRANSCRIPT,
213
+ prompt_hint=(
214
+ "Inspect the transcript structurally and preserve only reusable "
215
+ "project knowledge."
216
+ ),
217
+ )
218
+
219
+
220
+ def resolve_url(raw: str) -> SourceBrief:
221
+ url = normalize_http_url(raw)
222
+ github = parse_github_url(raw, url)
223
+ if github is not None:
224
+ return github
225
+ ref = SourceRef(
226
+ raw=raw,
227
+ kind=SourceKind.WEB_URL,
228
+ identity=url,
229
+ url=url,
230
+ )
231
+ return SourceBrief(
232
+ ref=ref,
233
+ title=url,
234
+ provenance_kind=SourceProvenanceKind.URL,
235
+ prompt_hint=WEB_PROMPT_HINT,
236
+ )
237
+
238
+
239
+ def normalize_http_url(raw: str) -> str:
240
+ parsed = urlsplit(raw)
241
+ if parsed.scheme not in {"http", "https"} or not parsed.netloc:
242
+ raise ValidationFailed(f"invalid URL source address: {raw}")
243
+ try:
244
+ return str(HTTP_URL_ADAPTER.validate_python(raw))
245
+ except ValidationError as error:
246
+ raise ValidationFailed(f"invalid URL source address: {raw}") from error
247
+
248
+
249
+ def parse_github_url(raw: str, url: str) -> SourceBrief | None:
250
+ parsed = urlsplit(url)
251
+ if parsed.netloc.casefold() != "github.com":
252
+ return None
253
+ parts = [part for part in parsed.path.split("/") if part]
254
+ if len(parts) < 4:
255
+ return None
256
+ owner, repo, source_type, number_text = parts[:4]
257
+ if source_type not in {"pull", "issues"}:
258
+ return None
259
+ number = parse_positive_int(number_text, raw)
260
+ repository = f"{owner}/{repo}"
261
+ if source_type == "pull":
262
+ url = f"https://github.com/{repository}/pull/{number}"
263
+ ref = SourceRef(
264
+ raw=raw,
265
+ kind=SourceKind.GITHUB_PULL_REQUEST,
266
+ identity=f"github.pull_request:{repository}#{number}",
267
+ url=url,
268
+ repository=repository,
269
+ number=number,
270
+ )
271
+ return SourceBrief(
272
+ ref=ref,
273
+ title=f"{repository} pull request #{number}",
274
+ provenance_kind=SourceProvenanceKind.PR,
275
+ prompt_hint=PULL_REQUEST_PROMPT_HINT,
276
+ )
277
+ url = f"https://github.com/{repository}/issues/{number}"
278
+ ref = SourceRef(
279
+ raw=raw,
280
+ kind=SourceKind.GITHUB_ISSUE,
281
+ identity=f"github.issue:{repository}#{number}",
282
+ url=url,
283
+ repository=repository,
284
+ number=number,
285
+ )
286
+ return SourceBrief(
287
+ ref=ref,
288
+ title=f"{repository} issue #{number}",
289
+ provenance_kind=SourceProvenanceKind.ISSUE,
290
+ prompt_hint=ISSUE_PROMPT_HINT,
291
+ )
292
+
293
+
294
+ def resolve_path(raw: str, cwd: Path) -> SourceBrief:
295
+ path = resolve_user_path(raw, cwd)
296
+ if path.is_dir():
297
+ kind = SourceKind.PATH_DIRECTORY
298
+ provenance_kind = SourceProvenanceKind.DIRECTORY
299
+ title = f"Directory {path}"
300
+ prompt_hint = DIRECTORY_PROMPT_HINT
301
+ fingerprint = None
302
+ elif path.is_file():
303
+ kind = SourceKind.PATH_FILE
304
+ provenance_kind = SourceProvenanceKind.FILE
305
+ title = f"File {path}"
306
+ prompt_hint = FILE_PROMPT_HINT
307
+ fingerprint = file_fingerprint(path)
308
+ else:
309
+ kind = SourceKind.PATH_UNKNOWN
310
+ provenance_kind = SourceProvenanceKind.MISSING_PATH
311
+ title = f"Missing path {path}"
312
+ prompt_hint = MISSING_PATH_PROMPT_HINT
313
+ fingerprint = None
314
+ ref = SourceRef(
315
+ raw=raw,
316
+ kind=kind,
317
+ identity=f"{kind.value}:{path}",
318
+ path=path,
319
+ exists=path.exists(),
320
+ fingerprint=fingerprint,
321
+ )
322
+ return SourceBrief(
323
+ ref=ref,
324
+ title=title,
325
+ provenance_kind=provenance_kind,
326
+ prompt_hint=prompt_hint,
327
+ )
328
+
329
+
330
+ def resolve_user_path(raw: str, cwd: Path) -> Path:
331
+ path = Path(raw).expanduser()
332
+ if not path.is_absolute():
333
+ path = cwd / path
334
+ return normalize_path(path)
335
+
336
+
337
+ def file_fingerprint(path: Path) -> str | None:
338
+ try:
339
+ return sha256(path.read_bytes()).hexdigest()
340
+ except OSError:
341
+ return None
342
+
343
+
344
+ def parse_positive_int(value: str, raw: str) -> int:
345
+ try:
346
+ parsed = int(value)
347
+ except ValueError as error:
348
+ raise ValidationFailed(f"source number must be positive: {raw}") from error
349
+ if parsed < 1:
350
+ raise ValidationFailed(f"source number must be positive: {raw}")
351
+ return parsed
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,9 @@
1
+ from codealmanac.core.models import CodeAlmanacModel
2
+
3
+
4
+ class TaggingResult(CodeAlmanacModel):
5
+ slug: str
6
+ requested_topics: tuple[str, ...]
7
+ topics_before: tuple[str, ...]
8
+ topics_after: tuple[str, ...]
9
+ changed_topics: tuple[str, ...]
@@ -0,0 +1,35 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ from pydantic import field_validator
5
+
6
+ from codealmanac.core.models import CodeAlmanacModel
7
+ from codealmanac.core.slug import to_kebab_case
8
+ from codealmanac.core.text import required_text
9
+
10
+
11
+ class TagPageRequest(CodeAlmanacModel):
12
+ cwd: Path
13
+ slug: str
14
+ topics: tuple[str, ...]
15
+ wiki: str | None = None
16
+
17
+ @field_validator("slug")
18
+ @classmethod
19
+ def require_slug(cls, value: str) -> str:
20
+ return required_text(value, "page")
21
+
22
+ @field_validator("topics", mode="before")
23
+ @classmethod
24
+ def canonical_topics(cls, value: Any) -> tuple[str, ...]:
25
+ if not isinstance(value, list | tuple):
26
+ raise ValueError("topics must not be empty")
27
+ topics = tuple(dict.fromkeys(to_kebab_case(str(item)) for item in value))
28
+ topics = tuple(topic for topic in topics if topic)
29
+ if not topics:
30
+ raise ValueError("topics must not be empty")
31
+ return topics
32
+
33
+
34
+ class UntagPageRequest(TagPageRequest):
35
+ pass
@@ -0,0 +1,43 @@
1
+ from codealmanac.services.pages.requests import ShowPageRequest
2
+ from codealmanac.services.pages.service import PagesService
3
+ from codealmanac.services.tagging.models import TaggingResult
4
+ from codealmanac.services.tagging.requests import TagPageRequest, UntagPageRequest
5
+ from codealmanac.services.wiki.frontmatter_rewrite import rewrite_page_topics
6
+
7
+
8
+ class TaggingService:
9
+ def __init__(self, pages: PagesService):
10
+ self.pages = pages
11
+
12
+ def tag(self, request: TagPageRequest) -> TaggingResult:
13
+ page = self.pages.show(
14
+ ShowPageRequest(cwd=request.cwd, wiki=request.wiki, slug=request.slug)
15
+ )
16
+ before = page.topics
17
+ after = tuple(dict.fromkeys((*before, *request.topics)))
18
+ rewrite_page_topics(page.file_path, after)
19
+ changed = tuple(topic for topic in after if topic not in before)
20
+ return TaggingResult(
21
+ slug=page.slug,
22
+ requested_topics=request.topics,
23
+ topics_before=before,
24
+ topics_after=after,
25
+ changed_topics=changed,
26
+ )
27
+
28
+ def untag(self, request: UntagPageRequest) -> TaggingResult:
29
+ page = self.pages.show(
30
+ ShowPageRequest(cwd=request.cwd, wiki=request.wiki, slug=request.slug)
31
+ )
32
+ before = page.topics
33
+ remove = set(request.topics)
34
+ after = tuple(topic for topic in before if topic not in remove)
35
+ rewrite_page_topics(page.file_path, after)
36
+ changed = tuple(topic for topic in before if topic not in after)
37
+ return TaggingResult(
38
+ slug=page.slug,
39
+ requested_topics=request.topics,
40
+ topics_before=before,
41
+ topics_after=after,
42
+ changed_topics=changed,
43
+ )
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,36 @@
1
+ from enum import StrEnum
2
+
3
+ from codealmanac.core.models import CodeAlmanacModel
4
+
5
+
6
+ class TopicMutationAction(StrEnum):
7
+ CREATED = "created"
8
+ UPDATED = "updated"
9
+ DESCRIBED = "described"
10
+ LINKED = "linked"
11
+ ALREADY_LINKED = "already_linked"
12
+ UNLINKED = "unlinked"
13
+ NO_EDGE = "no_edge"
14
+ RENAMED = "renamed"
15
+ UNCHANGED = "unchanged"
16
+ DELETED = "deleted"
17
+
18
+
19
+ class TopicMutationResult(CodeAlmanacModel):
20
+ action: TopicMutationAction
21
+ slug: str
22
+ parents: tuple[str, ...] = ()
23
+ description: str | None = None
24
+
25
+
26
+ class TopicEdgeMutationResult(CodeAlmanacModel):
27
+ action: TopicMutationAction
28
+ child: str
29
+ parent: str
30
+
31
+
32
+ class TopicRewriteMutationResult(CodeAlmanacModel):
33
+ action: TopicMutationAction
34
+ slug: str
35
+ new_slug: str | None = None
36
+ pages_updated: int = 0
@@ -0,0 +1,115 @@
1
+ from pathlib import Path
2
+ from typing import Any
3
+
4
+ from pydantic import field_validator
5
+
6
+ from codealmanac.core.models import CodeAlmanacModel
7
+ from codealmanac.core.slug import to_kebab_case
8
+ from codealmanac.core.text import required_text
9
+
10
+
11
+ class ListTopicsRequest(CodeAlmanacModel):
12
+ cwd: Path
13
+ wiki: str | None = None
14
+
15
+
16
+ class ShowTopicRequest(CodeAlmanacModel):
17
+ cwd: Path
18
+ slug: str
19
+ wiki: str | None = None
20
+ include_descendants: bool = False
21
+
22
+ @field_validator("slug")
23
+ @classmethod
24
+ def require_slug(cls, value: str) -> str:
25
+ return required_text(value, "topic")
26
+
27
+
28
+ class CreateTopicRequest(CodeAlmanacModel):
29
+ cwd: Path
30
+ name: str
31
+ parents: tuple[str, ...] = ()
32
+ wiki: str | None = None
33
+
34
+ @field_validator("name")
35
+ @classmethod
36
+ def require_name(cls, value: str) -> str:
37
+ return required_text(value, "topic")
38
+
39
+ @field_validator("parents", mode="before")
40
+ @classmethod
41
+ def canonical_parents(cls, value: Any) -> tuple[str, ...]:
42
+ if value is None:
43
+ return ()
44
+ if not isinstance(value, list | tuple):
45
+ raise ValueError("parents must be a list")
46
+ parents = tuple(dict.fromkeys(to_kebab_case(str(item)) for item in value))
47
+ return tuple(parent for parent in parents if parent)
48
+
49
+
50
+ class DescribeTopicRequest(CodeAlmanacModel):
51
+ cwd: Path
52
+ slug: str
53
+ description: str
54
+ wiki: str | None = None
55
+
56
+ @field_validator("slug")
57
+ @classmethod
58
+ def require_slug(cls, value: str) -> str:
59
+ slug = to_kebab_case(required_text(value, "topic"))
60
+ if not slug:
61
+ raise ValueError("topic must contain slug-able characters")
62
+ return slug
63
+
64
+ @field_validator("description")
65
+ @classmethod
66
+ def normalize_description(cls, value: str) -> str:
67
+ return value.strip()
68
+
69
+
70
+ class LinkTopicRequest(CodeAlmanacModel):
71
+ cwd: Path
72
+ child: str
73
+ parent: str
74
+ wiki: str | None = None
75
+
76
+ @field_validator("child", "parent")
77
+ @classmethod
78
+ def canonical_topic(cls, value: str) -> str:
79
+ slug = to_kebab_case(required_text(value, "topic"))
80
+ if not slug:
81
+ raise ValueError("topic must contain slug-able characters")
82
+ return slug
83
+
84
+
85
+ class UnlinkTopicRequest(LinkTopicRequest):
86
+ pass
87
+
88
+
89
+ class RenameTopicRequest(CodeAlmanacModel):
90
+ cwd: Path
91
+ old_slug: str
92
+ new_slug: str
93
+ wiki: str | None = None
94
+
95
+ @field_validator("old_slug", "new_slug")
96
+ @classmethod
97
+ def canonical_topic(cls, value: str) -> str:
98
+ slug = to_kebab_case(required_text(value, "topic"))
99
+ if not slug:
100
+ raise ValueError("topic must contain slug-able characters")
101
+ return slug
102
+
103
+
104
+ class DeleteTopicRequest(CodeAlmanacModel):
105
+ cwd: Path
106
+ slug: str
107
+ wiki: str | None = None
108
+
109
+ @field_validator("slug")
110
+ @classmethod
111
+ def canonical_topic(cls, value: str) -> str:
112
+ slug = to_kebab_case(required_text(value, "topic"))
113
+ if not slug:
114
+ raise ValueError("topic must contain slug-able characters")
115
+ return slug