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.
- codealmanac/__init__.py +13 -0
- codealmanac/app.py +175 -0
- codealmanac/cli/__init__.py +1 -0
- codealmanac/cli/dispatch/__init__.py +0 -0
- codealmanac/cli/dispatch/admin.py +124 -0
- codealmanac/cli/dispatch/config.py +50 -0
- codealmanac/cli/dispatch/root.py +328 -0
- codealmanac/cli/main.py +28 -0
- codealmanac/cli/parser/__init__.py +0 -0
- codealmanac/cli/parser/admin.py +81 -0
- codealmanac/cli/parser/lifecycle.py +57 -0
- codealmanac/cli/parser/root.py +19 -0
- codealmanac/cli/parser/wiki.py +87 -0
- codealmanac/cli/render/__init__.py +0 -0
- codealmanac/cli/render/admin.py +191 -0
- codealmanac/cli/render/root.py +290 -0
- codealmanac/core/__init__.py +1 -0
- codealmanac/core/errors.py +45 -0
- codealmanac/core/models.py +14 -0
- codealmanac/core/paths.py +25 -0
- codealmanac/core/slug.py +7 -0
- codealmanac/core/text.py +5 -0
- codealmanac/database/__init__.py +15 -0
- codealmanac/database/sqlite.py +54 -0
- codealmanac/integrations/__init__.py +1 -0
- codealmanac/integrations/automation/__init__.py +3 -0
- codealmanac/integrations/automation/scheduler/__init__.py +5 -0
- codealmanac/integrations/automation/scheduler/launchd.py +163 -0
- codealmanac/integrations/command.py +56 -0
- codealmanac/integrations/harnesses/__init__.py +7 -0
- codealmanac/integrations/harnesses/claude/__init__.py +1 -0
- codealmanac/integrations/harnesses/claude/adapter.py +217 -0
- codealmanac/integrations/harnesses/codex/__init__.py +3 -0
- codealmanac/integrations/harnesses/codex/adapter.py +221 -0
- codealmanac/integrations/harnesses/git_status.py +49 -0
- codealmanac/integrations/sources/__init__.py +29 -0
- codealmanac/integrations/sources/filesystem/__init__.py +5 -0
- codealmanac/integrations/sources/filesystem/adapter.py +685 -0
- codealmanac/integrations/sources/filesystem/selection.py +209 -0
- codealmanac/integrations/sources/git/__init__.py +3 -0
- codealmanac/integrations/sources/git/adapter.py +132 -0
- codealmanac/integrations/sources/github/__init__.py +3 -0
- codealmanac/integrations/sources/github/adapter.py +413 -0
- codealmanac/integrations/sources/runtime.py +22 -0
- codealmanac/integrations/sources/transcripts/__init__.py +33 -0
- codealmanac/integrations/sources/transcripts/claude.py +61 -0
- codealmanac/integrations/sources/transcripts/codex.py +69 -0
- codealmanac/integrations/sources/transcripts/jsonl.py +84 -0
- codealmanac/integrations/sources/transcripts/runtime.py +387 -0
- codealmanac/integrations/sources/web/__init__.py +3 -0
- codealmanac/integrations/sources/web/adapter.py +303 -0
- codealmanac/integrations/updates/__init__.py +7 -0
- codealmanac/integrations/updates/package.py +85 -0
- codealmanac/integrations/workspaces/__init__.py +1 -0
- codealmanac/integrations/workspaces/git/__init__.py +3 -0
- codealmanac/integrations/workspaces/git/probe.py +128 -0
- codealmanac/manual/README.md +24 -0
- codealmanac/manual/__init__.py +19 -0
- codealmanac/manual/build.md +20 -0
- codealmanac/manual/evidence.md +23 -0
- codealmanac/manual/garden.md +20 -0
- codealmanac/manual/ingest.md +17 -0
- codealmanac/manual/library.py +84 -0
- codealmanac/manual/models.py +83 -0
- codealmanac/manual/pages.md +28 -0
- codealmanac/manual/requests.py +6 -0
- codealmanac/manual/sources.md +18 -0
- codealmanac/manual/style.md +19 -0
- codealmanac/prompts/__init__.py +5 -0
- codealmanac/prompts/base/notability.md +14 -0
- codealmanac/prompts/base/purpose.md +23 -0
- codealmanac/prompts/base/syntax.md +19 -0
- codealmanac/prompts/models.py +9 -0
- codealmanac/prompts/operations/garden.md +26 -0
- codealmanac/prompts/operations/ingest.md +18 -0
- codealmanac/prompts/renderer.py +24 -0
- codealmanac/prompts/requests.py +22 -0
- codealmanac/server/__init__.py +1 -0
- codealmanac/server/app.py +202 -0
- codealmanac/server/assets/__init__.py +1 -0
- codealmanac/server/assets/app.css +865 -0
- codealmanac/server/assets/app.js +3 -0
- codealmanac/server/assets/index.html +80 -0
- codealmanac/server/assets/viewer/api.js +30 -0
- codealmanac/server/assets/viewer/components.js +197 -0
- codealmanac/server/assets/viewer/main.js +126 -0
- codealmanac/server/assets/viewer/renderers.js +122 -0
- codealmanac/server/assets/viewer/routes.js +36 -0
- codealmanac/services/__init__.py +1 -0
- codealmanac/services/automation/__init__.py +3 -0
- codealmanac/services/automation/models.py +83 -0
- codealmanac/services/automation/ports.py +14 -0
- codealmanac/services/automation/requests.py +40 -0
- codealmanac/services/automation/service.py +294 -0
- codealmanac/services/config/__init__.py +17 -0
- codealmanac/services/config/models.py +61 -0
- codealmanac/services/config/requests.py +21 -0
- codealmanac/services/config/service.py +55 -0
- codealmanac/services/config/store.py +26 -0
- codealmanac/services/diagnostics/__init__.py +1 -0
- codealmanac/services/diagnostics/models.py +22 -0
- codealmanac/services/diagnostics/requests.py +8 -0
- codealmanac/services/diagnostics/service.py +283 -0
- codealmanac/services/harnesses/__init__.py +1 -0
- codealmanac/services/harnesses/models.py +104 -0
- codealmanac/services/harnesses/ports.py +18 -0
- codealmanac/services/harnesses/requests.py +19 -0
- codealmanac/services/harnesses/service.py +38 -0
- codealmanac/services/health/__init__.py +1 -0
- codealmanac/services/health/requests.py +8 -0
- codealmanac/services/health/service.py +20 -0
- codealmanac/services/index/__init__.py +1 -0
- codealmanac/services/index/models.py +135 -0
- codealmanac/services/index/requests.py +26 -0
- codealmanac/services/index/service.py +86 -0
- codealmanac/services/index/store.py +411 -0
- codealmanac/services/index/views.py +524 -0
- codealmanac/services/pages/__init__.py +1 -0
- codealmanac/services/pages/requests.py +17 -0
- codealmanac/services/pages/service.py +26 -0
- codealmanac/services/runs/__init__.py +1 -0
- codealmanac/services/runs/models.py +91 -0
- codealmanac/services/runs/requests.py +76 -0
- codealmanac/services/runs/service.py +86 -0
- codealmanac/services/runs/store.py +256 -0
- codealmanac/services/search/__init__.py +1 -0
- codealmanac/services/search/requests.py +23 -0
- codealmanac/services/search/service.py +31 -0
- codealmanac/services/sources/__init__.py +1 -0
- codealmanac/services/sources/models.py +126 -0
- codealmanac/services/sources/ports.py +30 -0
- codealmanac/services/sources/requests.py +76 -0
- codealmanac/services/sources/service.py +351 -0
- codealmanac/services/tagging/__init__.py +1 -0
- codealmanac/services/tagging/models.py +9 -0
- codealmanac/services/tagging/requests.py +35 -0
- codealmanac/services/tagging/service.py +43 -0
- codealmanac/services/topics/__init__.py +1 -0
- codealmanac/services/topics/models.py +36 -0
- codealmanac/services/topics/requests.py +115 -0
- codealmanac/services/topics/service.py +297 -0
- codealmanac/services/updates/__init__.py +4 -0
- codealmanac/services/updates/models.py +83 -0
- codealmanac/services/updates/ports.py +17 -0
- codealmanac/services/updates/requests.py +10 -0
- codealmanac/services/updates/service.py +113 -0
- codealmanac/services/viewer/__init__.py +1 -0
- codealmanac/services/viewer/models.py +80 -0
- codealmanac/services/viewer/renderer.py +89 -0
- codealmanac/services/viewer/requests.py +86 -0
- codealmanac/services/viewer/service.py +211 -0
- codealmanac/services/wiki/__init__.py +1 -0
- codealmanac/services/wiki/documents.py +83 -0
- codealmanac/services/wiki/frontmatter.py +94 -0
- codealmanac/services/wiki/frontmatter_rewrite.py +142 -0
- codealmanac/services/wiki/models.py +69 -0
- codealmanac/services/wiki/paths.py +42 -0
- codealmanac/services/wiki/service.py +57 -0
- codealmanac/services/wiki/templates.py +73 -0
- codealmanac/services/wiki/topics.py +266 -0
- codealmanac/services/wiki/wikilinks.py +58 -0
- codealmanac/services/workspaces/__init__.py +1 -0
- codealmanac/services/workspaces/models.py +124 -0
- codealmanac/services/workspaces/ports.py +9 -0
- codealmanac/services/workspaces/requests.py +82 -0
- codealmanac/services/workspaces/roots.py +74 -0
- codealmanac/services/workspaces/service.py +303 -0
- codealmanac/services/workspaces/store.py +127 -0
- codealmanac/workflows/__init__.py +1 -0
- codealmanac/workflows/build/__init__.py +1 -0
- codealmanac/workflows/build/models.py +8 -0
- codealmanac/workflows/build/service.py +45 -0
- codealmanac/workflows/garden/__init__.py +3 -0
- codealmanac/workflows/garden/models.py +30 -0
- codealmanac/workflows/garden/requests.py +22 -0
- codealmanac/workflows/garden/service.py +239 -0
- codealmanac/workflows/ingest/__init__.py +1 -0
- codealmanac/workflows/ingest/models.py +26 -0
- codealmanac/workflows/ingest/requests.py +39 -0
- codealmanac/workflows/ingest/service.py +302 -0
- codealmanac/workflows/lifecycle.py +197 -0
- codealmanac/workflows/sync/__init__.py +3 -0
- codealmanac/workflows/sync/models.py +157 -0
- codealmanac/workflows/sync/requests.py +63 -0
- codealmanac/workflows/sync/service.py +651 -0
- codealmanac/workflows/sync/store.py +51 -0
- codealmanac-0.1.0.dev0.dist-info/METADATA +248 -0
- codealmanac-0.1.0.dev0.dist-info/RECORD +192 -0
- codealmanac-0.1.0.dev0.dist-info/WHEEL +5 -0
- codealmanac-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- codealmanac-0.1.0.dev0.dist-info/licenses/LICENSE.md +201 -0
- 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,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
|