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,413 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
6
|
+
|
|
7
|
+
from codealmanac.core.errors import ExecutionFailed
|
|
8
|
+
from codealmanac.integrations.command import CommandRunner, SubprocessCommandRunner
|
|
9
|
+
from codealmanac.integrations.sources.runtime import (
|
|
10
|
+
bounded_text,
|
|
11
|
+
source_runtime_section,
|
|
12
|
+
surface_process_error,
|
|
13
|
+
)
|
|
14
|
+
from codealmanac.services.sources.models import (
|
|
15
|
+
SourceKind,
|
|
16
|
+
SourceRef,
|
|
17
|
+
SourceRuntime,
|
|
18
|
+
SourceRuntimeStatus,
|
|
19
|
+
)
|
|
20
|
+
from codealmanac.services.sources.requests import InspectSourceRuntimeRequest
|
|
21
|
+
|
|
22
|
+
GITHUB_RUNTIME_TIMEOUT_SECONDS = 30
|
|
23
|
+
DEFAULT_MAX_CHARS = 60_000
|
|
24
|
+
PULL_REQUEST_FIELDS = ",".join(
|
|
25
|
+
(
|
|
26
|
+
"title",
|
|
27
|
+
"state",
|
|
28
|
+
"author",
|
|
29
|
+
"body",
|
|
30
|
+
"url",
|
|
31
|
+
"createdAt",
|
|
32
|
+
"updatedAt",
|
|
33
|
+
"mergedAt",
|
|
34
|
+
"baseRefName",
|
|
35
|
+
"headRefName",
|
|
36
|
+
"commits",
|
|
37
|
+
"files",
|
|
38
|
+
"comments",
|
|
39
|
+
"reviews",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
ISSUE_FIELDS = ",".join(
|
|
43
|
+
(
|
|
44
|
+
"title",
|
|
45
|
+
"state",
|
|
46
|
+
"author",
|
|
47
|
+
"body",
|
|
48
|
+
"url",
|
|
49
|
+
"createdAt",
|
|
50
|
+
"updatedAt",
|
|
51
|
+
"closedAt",
|
|
52
|
+
"labels",
|
|
53
|
+
"assignees",
|
|
54
|
+
"comments",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class GitHubCliModel(BaseModel):
|
|
60
|
+
model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class GitHubActor(GitHubCliModel):
|
|
64
|
+
login: str
|
|
65
|
+
name: str | None = None
|
|
66
|
+
is_bot: bool | None = Field(default=None, alias="is_bot")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class GitHubLabel(GitHubCliModel):
|
|
70
|
+
name: str
|
|
71
|
+
description: str | None = None
|
|
72
|
+
color: str | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class GitHubComment(GitHubCliModel):
|
|
76
|
+
author: GitHubActor | None = None
|
|
77
|
+
body: str | None = None
|
|
78
|
+
created_at: str | None = Field(default=None, alias="createdAt")
|
|
79
|
+
url: str | None = None
|
|
80
|
+
author_association: str | None = Field(default=None, alias="authorAssociation")
|
|
81
|
+
is_minimized: bool | None = Field(default=None, alias="isMinimized")
|
|
82
|
+
minimized_reason: str | None = Field(default=None, alias="minimizedReason")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GitHubReview(GitHubCliModel):
|
|
86
|
+
author: GitHubActor | None = None
|
|
87
|
+
body: str | None = None
|
|
88
|
+
state: str | None = None
|
|
89
|
+
submitted_at: str | None = Field(default=None, alias="submittedAt")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class GitHubCommitAuthor(GitHubCliModel):
|
|
93
|
+
name: str | None = None
|
|
94
|
+
email: str | None = None
|
|
95
|
+
login: str | None = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class GitHubCommit(GitHubCliModel):
|
|
99
|
+
oid: str
|
|
100
|
+
message_headline: str | None = Field(default=None, alias="messageHeadline")
|
|
101
|
+
message_body: str | None = Field(default=None, alias="messageBody")
|
|
102
|
+
authored_date: str | None = Field(default=None, alias="authoredDate")
|
|
103
|
+
committed_date: str | None = Field(default=None, alias="committedDate")
|
|
104
|
+
authors: tuple[GitHubCommitAuthor, ...] = ()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class GitHubFile(GitHubCliModel):
|
|
108
|
+
path: str
|
|
109
|
+
additions: int = 0
|
|
110
|
+
deletions: int = 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class GitHubPullRequestPayload(GitHubCliModel):
|
|
114
|
+
title: str
|
|
115
|
+
state: str
|
|
116
|
+
author: GitHubActor | None = None
|
|
117
|
+
body: str | None = None
|
|
118
|
+
url: str
|
|
119
|
+
created_at: str | None = Field(default=None, alias="createdAt")
|
|
120
|
+
updated_at: str | None = Field(default=None, alias="updatedAt")
|
|
121
|
+
merged_at: str | None = Field(default=None, alias="mergedAt")
|
|
122
|
+
base_ref_name: str | None = Field(default=None, alias="baseRefName")
|
|
123
|
+
head_ref_name: str | None = Field(default=None, alias="headRefName")
|
|
124
|
+
commits: tuple[GitHubCommit, ...] = ()
|
|
125
|
+
files: tuple[GitHubFile, ...] = ()
|
|
126
|
+
comments: tuple[GitHubComment, ...] = ()
|
|
127
|
+
reviews: tuple[GitHubReview, ...] = ()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class GitHubIssuePayload(GitHubCliModel):
|
|
131
|
+
title: str
|
|
132
|
+
state: str
|
|
133
|
+
author: GitHubActor | None = None
|
|
134
|
+
body: str | None = None
|
|
135
|
+
url: str
|
|
136
|
+
created_at: str | None = Field(default=None, alias="createdAt")
|
|
137
|
+
updated_at: str | None = Field(default=None, alias="updatedAt")
|
|
138
|
+
closed_at: str | None = Field(default=None, alias="closedAt")
|
|
139
|
+
labels: tuple[GitHubLabel, ...] = ()
|
|
140
|
+
assignees: tuple[GitHubActor, ...] = ()
|
|
141
|
+
comments: tuple[GitHubComment, ...] = ()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class GitHubSourceRuntimeAdapter:
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
runner: CommandRunner | None = None,
|
|
148
|
+
max_chars: int = DEFAULT_MAX_CHARS,
|
|
149
|
+
timeout_seconds: int = GITHUB_RUNTIME_TIMEOUT_SECONDS,
|
|
150
|
+
):
|
|
151
|
+
self.runner = runner or SubprocessCommandRunner()
|
|
152
|
+
self.max_chars = max_chars
|
|
153
|
+
self.timeout_seconds = timeout_seconds
|
|
154
|
+
|
|
155
|
+
def supports(self, ref: SourceRef) -> bool:
|
|
156
|
+
return ref.kind in {
|
|
157
|
+
SourceKind.GITHUB_PULL_REQUEST,
|
|
158
|
+
SourceKind.GITHUB_ISSUE,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
def inspect(self, request: InspectSourceRuntimeRequest) -> SourceRuntime:
|
|
162
|
+
if request.ref.kind == SourceKind.GITHUB_PULL_REQUEST:
|
|
163
|
+
return self._inspect_pull_request(request.cwd, request.ref)
|
|
164
|
+
if request.ref.kind == SourceKind.GITHUB_ISSUE:
|
|
165
|
+
return self._inspect_issue(request.cwd, request.ref)
|
|
166
|
+
return SourceRuntime(
|
|
167
|
+
ref=request.ref,
|
|
168
|
+
status=SourceRuntimeStatus.SKIPPED,
|
|
169
|
+
title=f"Unsupported GitHub source {request.ref.identity}",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _inspect_pull_request(self, cwd: Path, ref: SourceRef) -> SourceRuntime:
|
|
173
|
+
try:
|
|
174
|
+
target_args = github_target_args(ref)
|
|
175
|
+
payload = self._gh_json(
|
|
176
|
+
cwd,
|
|
177
|
+
("pr", "view", *target_args, "--json", PULL_REQUEST_FIELDS),
|
|
178
|
+
GitHubPullRequestPayload,
|
|
179
|
+
)
|
|
180
|
+
diff = self._gh_text(
|
|
181
|
+
cwd,
|
|
182
|
+
("pr", "diff", *target_args, "--patch", "--color", "never"),
|
|
183
|
+
)
|
|
184
|
+
except (ExecutionFailed, ValidationError, json.JSONDecodeError) as error:
|
|
185
|
+
return unavailable_runtime(ref, "GitHub pull request unavailable", error)
|
|
186
|
+
|
|
187
|
+
content, truncated = bounded_text(
|
|
188
|
+
"\n\n".join(
|
|
189
|
+
(
|
|
190
|
+
source_runtime_section(
|
|
191
|
+
"metadata",
|
|
192
|
+
render_pull_request_metadata(payload),
|
|
193
|
+
),
|
|
194
|
+
source_runtime_section("body", payload.body or ""),
|
|
195
|
+
source_runtime_section("files", render_files(payload.files)),
|
|
196
|
+
source_runtime_section("commits", render_commits(payload.commits)),
|
|
197
|
+
source_runtime_section(
|
|
198
|
+
"comments",
|
|
199
|
+
render_comments(payload.comments),
|
|
200
|
+
),
|
|
201
|
+
source_runtime_section("reviews", render_reviews(payload.reviews)),
|
|
202
|
+
source_runtime_section("diff", diff),
|
|
203
|
+
)
|
|
204
|
+
),
|
|
205
|
+
self.max_chars,
|
|
206
|
+
)
|
|
207
|
+
return SourceRuntime(
|
|
208
|
+
ref=ref,
|
|
209
|
+
status=SourceRuntimeStatus.AVAILABLE,
|
|
210
|
+
title=f"GitHub PR {payload.url}: {payload.title}",
|
|
211
|
+
content=content,
|
|
212
|
+
truncated=truncated,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _inspect_issue(self, cwd: Path, ref: SourceRef) -> SourceRuntime:
|
|
216
|
+
try:
|
|
217
|
+
target_args = github_target_args(ref)
|
|
218
|
+
payload = self._gh_json(
|
|
219
|
+
cwd,
|
|
220
|
+
("issue", "view", *target_args, "--json", ISSUE_FIELDS),
|
|
221
|
+
GitHubIssuePayload,
|
|
222
|
+
)
|
|
223
|
+
except (ExecutionFailed, ValidationError, json.JSONDecodeError) as error:
|
|
224
|
+
return unavailable_runtime(ref, "GitHub issue unavailable", error)
|
|
225
|
+
|
|
226
|
+
content, truncated = bounded_text(
|
|
227
|
+
"\n\n".join(
|
|
228
|
+
(
|
|
229
|
+
source_runtime_section("metadata", render_issue_metadata(payload)),
|
|
230
|
+
source_runtime_section("body", payload.body or ""),
|
|
231
|
+
source_runtime_section("labels", render_labels(payload.labels)),
|
|
232
|
+
source_runtime_section(
|
|
233
|
+
"assignees",
|
|
234
|
+
render_actors(payload.assignees),
|
|
235
|
+
),
|
|
236
|
+
source_runtime_section(
|
|
237
|
+
"comments",
|
|
238
|
+
render_comments(payload.comments),
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
),
|
|
242
|
+
self.max_chars,
|
|
243
|
+
)
|
|
244
|
+
return SourceRuntime(
|
|
245
|
+
ref=ref,
|
|
246
|
+
status=SourceRuntimeStatus.AVAILABLE,
|
|
247
|
+
title=f"GitHub issue {payload.url}: {payload.title}",
|
|
248
|
+
content=content,
|
|
249
|
+
truncated=truncated,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _gh_json(
|
|
253
|
+
self,
|
|
254
|
+
cwd: Path,
|
|
255
|
+
args: tuple[str, ...],
|
|
256
|
+
model: type[GitHubPullRequestPayload] | type[GitHubIssuePayload],
|
|
257
|
+
) -> GitHubPullRequestPayload | GitHubIssuePayload:
|
|
258
|
+
return model.model_validate_json(self._gh_text(cwd, args))
|
|
259
|
+
|
|
260
|
+
def _gh_text(self, cwd: Path, args: tuple[str, ...]) -> str:
|
|
261
|
+
try:
|
|
262
|
+
result = self.runner.run("gh", args, cwd, self.timeout_seconds)
|
|
263
|
+
except FileNotFoundError as error:
|
|
264
|
+
raise ExecutionFailed("gh not found on PATH") from error
|
|
265
|
+
except subprocess.TimeoutExpired as error:
|
|
266
|
+
raise ExecutionFailed(f"gh {' '.join(args)} timed out") from error
|
|
267
|
+
if result.returncode != 0:
|
|
268
|
+
raise ExecutionFailed(
|
|
269
|
+
f"gh {' '.join(args)} failed: {surface_process_error(result)}"
|
|
270
|
+
)
|
|
271
|
+
return result.stdout.strip()
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def github_target_args(ref: SourceRef) -> tuple[str, ...]:
|
|
275
|
+
if ref.url is not None:
|
|
276
|
+
return (ref.url,)
|
|
277
|
+
if ref.number is None:
|
|
278
|
+
raise ExecutionFailed(f"GitHub source missing number: {ref.identity}")
|
|
279
|
+
if ref.repository is None:
|
|
280
|
+
return (str(ref.number),)
|
|
281
|
+
return (str(ref.number), "--repo", ref.repository)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def unavailable_runtime(
|
|
285
|
+
ref: SourceRef,
|
|
286
|
+
title: str,
|
|
287
|
+
error: Exception,
|
|
288
|
+
) -> SourceRuntime:
|
|
289
|
+
return SourceRuntime(
|
|
290
|
+
ref=ref,
|
|
291
|
+
status=SourceRuntimeStatus.UNAVAILABLE,
|
|
292
|
+
title=title,
|
|
293
|
+
diagnostics=(first_error_line(error),),
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def first_error_line(error: Exception) -> str:
|
|
298
|
+
lines = [line.strip() for line in str(error).splitlines() if line.strip()]
|
|
299
|
+
if not lines:
|
|
300
|
+
return error.__class__.__name__
|
|
301
|
+
return lines[0]
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def render_pull_request_metadata(payload: GitHubPullRequestPayload) -> str:
|
|
305
|
+
lines = [
|
|
306
|
+
f"title: {payload.title}",
|
|
307
|
+
f"state: {payload.state}",
|
|
308
|
+
f"url: {payload.url}",
|
|
309
|
+
f"author: {render_actor(payload.author)}",
|
|
310
|
+
f"base: {payload.base_ref_name or '(unknown)'}",
|
|
311
|
+
f"head: {payload.head_ref_name or '(unknown)'}",
|
|
312
|
+
f"created_at: {payload.created_at or '(unknown)'}",
|
|
313
|
+
f"updated_at: {payload.updated_at or '(unknown)'}",
|
|
314
|
+
]
|
|
315
|
+
if payload.merged_at is not None:
|
|
316
|
+
lines.append(f"merged_at: {payload.merged_at}")
|
|
317
|
+
return "\n".join(lines)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def render_issue_metadata(payload: GitHubIssuePayload) -> str:
|
|
321
|
+
lines = [
|
|
322
|
+
f"title: {payload.title}",
|
|
323
|
+
f"state: {payload.state}",
|
|
324
|
+
f"url: {payload.url}",
|
|
325
|
+
f"author: {render_actor(payload.author)}",
|
|
326
|
+
f"created_at: {payload.created_at or '(unknown)'}",
|
|
327
|
+
f"updated_at: {payload.updated_at or '(unknown)'}",
|
|
328
|
+
]
|
|
329
|
+
if payload.closed_at is not None:
|
|
330
|
+
lines.append(f"closed_at: {payload.closed_at}")
|
|
331
|
+
return "\n".join(lines)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def render_files(files: tuple[GitHubFile, ...]) -> str:
|
|
335
|
+
if len(files) == 0:
|
|
336
|
+
return ""
|
|
337
|
+
return "\n".join(
|
|
338
|
+
f"- {file.path} (+{file.additions}/-{file.deletions})" for file in files
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def render_commits(commits: tuple[GitHubCommit, ...]) -> str:
|
|
343
|
+
if len(commits) == 0:
|
|
344
|
+
return ""
|
|
345
|
+
blocks: list[str] = []
|
|
346
|
+
for commit in commits:
|
|
347
|
+
header = commit.oid
|
|
348
|
+
if commit.message_headline:
|
|
349
|
+
header = f"{header} {commit.message_headline}"
|
|
350
|
+
body = (commit.message_body or "").strip()
|
|
351
|
+
if body:
|
|
352
|
+
blocks.append(f"- {header}\n{body}")
|
|
353
|
+
else:
|
|
354
|
+
blocks.append(f"- {header}")
|
|
355
|
+
return "\n".join(blocks)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def render_comments(comments: tuple[GitHubComment, ...]) -> str:
|
|
359
|
+
if len(comments) == 0:
|
|
360
|
+
return ""
|
|
361
|
+
blocks: list[str] = []
|
|
362
|
+
for comment in comments:
|
|
363
|
+
header = f"### {render_actor(comment.author)}"
|
|
364
|
+
if comment.created_at is not None:
|
|
365
|
+
header = f"{header} at {comment.created_at}"
|
|
366
|
+
flags = render_comment_flags(comment)
|
|
367
|
+
if flags:
|
|
368
|
+
header = f"{header} ({flags})"
|
|
369
|
+
blocks.append(f"{header}\n{(comment.body or '').strip()}")
|
|
370
|
+
return "\n\n".join(blocks)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def render_comment_flags(comment: GitHubComment) -> str:
|
|
374
|
+
flags: list[str] = []
|
|
375
|
+
if comment.author_association:
|
|
376
|
+
flags.append(f"association={comment.author_association}")
|
|
377
|
+
if comment.is_minimized is True:
|
|
378
|
+
reason = comment.minimized_reason or "unknown"
|
|
379
|
+
flags.append(f"minimized={reason}")
|
|
380
|
+
return ", ".join(flags)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def render_reviews(reviews: tuple[GitHubReview, ...]) -> str:
|
|
384
|
+
if len(reviews) == 0:
|
|
385
|
+
return ""
|
|
386
|
+
blocks: list[str] = []
|
|
387
|
+
for review in reviews:
|
|
388
|
+
state = review.state or "UNKNOWN"
|
|
389
|
+
header = f"### {state} by {render_actor(review.author)}"
|
|
390
|
+
if review.submitted_at is not None:
|
|
391
|
+
header = f"{header} at {review.submitted_at}"
|
|
392
|
+
blocks.append(f"{header}\n{(review.body or '').strip()}")
|
|
393
|
+
return "\n\n".join(blocks)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def render_labels(labels: tuple[GitHubLabel, ...]) -> str:
|
|
397
|
+
if len(labels) == 0:
|
|
398
|
+
return ""
|
|
399
|
+
return "\n".join(f"- {label.name}" for label in labels)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def render_actors(actors: tuple[GitHubActor, ...]) -> str:
|
|
403
|
+
if len(actors) == 0:
|
|
404
|
+
return ""
|
|
405
|
+
return "\n".join(f"- {render_actor(actor)}" for actor in actors)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def render_actor(actor: GitHubActor | None) -> str:
|
|
409
|
+
if actor is None:
|
|
410
|
+
return "unknown"
|
|
411
|
+
if actor.name:
|
|
412
|
+
return f"{actor.login} ({actor.name})"
|
|
413
|
+
return actor.login
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from codealmanac.integrations.command import CommandResult, first_line
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def source_runtime_section(name: str, body: str) -> str:
|
|
5
|
+
if body.strip() == "":
|
|
6
|
+
return f"## {name}\n\n(no output)"
|
|
7
|
+
return f"## {name}\n\n{body.strip()}"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def bounded_text(value: str, max_chars: int) -> tuple[str, bool]:
|
|
11
|
+
if len(value) <= max_chars:
|
|
12
|
+
return value, False
|
|
13
|
+
return value[:max_chars].rstrip() + "\n\n[truncated]", True
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def surface_process_error(result: CommandResult) -> str:
|
|
17
|
+
message = first_line(result.stderr, result.stdout)
|
|
18
|
+
if message == "":
|
|
19
|
+
return f"exit {result.returncode}"
|
|
20
|
+
if len(message) > 500:
|
|
21
|
+
return f"{message[:500]}..."
|
|
22
|
+
return message
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from codealmanac.integrations.sources.transcripts.claude import (
|
|
2
|
+
ClaudeTranscriptDiscoveryAdapter,
|
|
3
|
+
)
|
|
4
|
+
from codealmanac.integrations.sources.transcripts.codex import (
|
|
5
|
+
CodexTranscriptDiscoveryAdapter,
|
|
6
|
+
)
|
|
7
|
+
from codealmanac.integrations.sources.transcripts.runtime import (
|
|
8
|
+
TranscriptSourceRuntimeAdapter,
|
|
9
|
+
)
|
|
10
|
+
from codealmanac.services.sources.ports import (
|
|
11
|
+
SourceRuntimeAdapter,
|
|
12
|
+
TranscriptDiscoveryAdapter,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def default_transcript_discovery_adapters() -> tuple[TranscriptDiscoveryAdapter, ...]:
|
|
17
|
+
return (
|
|
18
|
+
ClaudeTranscriptDiscoveryAdapter(),
|
|
19
|
+
CodexTranscriptDiscoveryAdapter(),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def default_transcript_runtime_adapters() -> tuple[SourceRuntimeAdapter, ...]:
|
|
24
|
+
return (TranscriptSourceRuntimeAdapter(),)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"ClaudeTranscriptDiscoveryAdapter",
|
|
29
|
+
"CodexTranscriptDiscoveryAdapter",
|
|
30
|
+
"TranscriptSourceRuntimeAdapter",
|
|
31
|
+
"default_transcript_discovery_adapters",
|
|
32
|
+
"default_transcript_runtime_adapters",
|
|
33
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
4
|
+
from codealmanac.integrations.sources.transcripts.jsonl import (
|
|
5
|
+
candidate_from_meta,
|
|
6
|
+
collect_jsonl,
|
|
7
|
+
parse_json_object,
|
|
8
|
+
read_first_lines,
|
|
9
|
+
string_field,
|
|
10
|
+
)
|
|
11
|
+
from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate
|
|
12
|
+
from codealmanac.services.sources.requests import DiscoverTranscriptsRequest
|
|
13
|
+
|
|
14
|
+
CLAUDE_PROJECTS_DIR = ".claude/projects"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClaudeTranscriptDiscoveryAdapter:
|
|
18
|
+
app = TranscriptApp.CLAUDE
|
|
19
|
+
|
|
20
|
+
def __init__(self, projects_dir: Path | None = None):
|
|
21
|
+
self.projects_dir = projects_dir
|
|
22
|
+
|
|
23
|
+
def discover(
|
|
24
|
+
self,
|
|
25
|
+
request: DiscoverTranscriptsRequest,
|
|
26
|
+
) -> tuple[TranscriptCandidate, ...]:
|
|
27
|
+
root = self.projects_dir or request.home / CLAUDE_PROJECTS_DIR
|
|
28
|
+
candidates: list[TranscriptCandidate] = []
|
|
29
|
+
for path in collect_jsonl(root):
|
|
30
|
+
if "subagents" in path.parts:
|
|
31
|
+
continue
|
|
32
|
+
meta = read_claude_meta(path)
|
|
33
|
+
if meta is None:
|
|
34
|
+
continue
|
|
35
|
+
candidate = candidate_from_meta(
|
|
36
|
+
TranscriptApp.CLAUDE,
|
|
37
|
+
path,
|
|
38
|
+
meta.session_id,
|
|
39
|
+
meta.cwd,
|
|
40
|
+
request.almanac_roots,
|
|
41
|
+
)
|
|
42
|
+
if candidate is not None:
|
|
43
|
+
candidates.append(candidate)
|
|
44
|
+
return tuple(candidates)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ClaudeTranscriptMeta(CodeAlmanacModel):
|
|
48
|
+
session_id: str
|
|
49
|
+
cwd: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_claude_meta(path: Path) -> ClaudeTranscriptMeta | None:
|
|
53
|
+
for line in read_first_lines(path, 20):
|
|
54
|
+
parsed = parse_json_object(line)
|
|
55
|
+
if parsed is None:
|
|
56
|
+
continue
|
|
57
|
+
session_id = string_field(parsed, "sessionId")
|
|
58
|
+
cwd = string_field(parsed, "cwd")
|
|
59
|
+
if session_id is not None and cwd is not None:
|
|
60
|
+
return ClaudeTranscriptMeta(session_id=session_id, cwd=cwd)
|
|
61
|
+
return None
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from codealmanac.core.models import CodeAlmanacModel
|
|
4
|
+
from codealmanac.integrations.sources.transcripts.jsonl import (
|
|
5
|
+
candidate_from_meta,
|
|
6
|
+
collect_jsonl,
|
|
7
|
+
object_field,
|
|
8
|
+
parse_json_object,
|
|
9
|
+
read_first_lines,
|
|
10
|
+
string_field,
|
|
11
|
+
)
|
|
12
|
+
from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate
|
|
13
|
+
from codealmanac.services.sources.requests import DiscoverTranscriptsRequest
|
|
14
|
+
|
|
15
|
+
CODEX_SESSIONS_DIR = ".codex/sessions"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CodexTranscriptDiscoveryAdapter:
|
|
19
|
+
app = TranscriptApp.CODEX
|
|
20
|
+
|
|
21
|
+
def __init__(self, sessions_dir: Path | None = None):
|
|
22
|
+
self.sessions_dir = sessions_dir
|
|
23
|
+
|
|
24
|
+
def discover(
|
|
25
|
+
self,
|
|
26
|
+
request: DiscoverTranscriptsRequest,
|
|
27
|
+
) -> tuple[TranscriptCandidate, ...]:
|
|
28
|
+
root = self.sessions_dir or request.home / CODEX_SESSIONS_DIR
|
|
29
|
+
candidates: list[TranscriptCandidate] = []
|
|
30
|
+
for path in collect_jsonl(root):
|
|
31
|
+
meta = read_codex_meta(path)
|
|
32
|
+
if meta is None or meta.thread_source == "subagent":
|
|
33
|
+
continue
|
|
34
|
+
candidate = candidate_from_meta(
|
|
35
|
+
TranscriptApp.CODEX,
|
|
36
|
+
path,
|
|
37
|
+
meta.session_id,
|
|
38
|
+
meta.cwd,
|
|
39
|
+
request.almanac_roots,
|
|
40
|
+
)
|
|
41
|
+
if candidate is not None:
|
|
42
|
+
candidates.append(candidate)
|
|
43
|
+
return tuple(candidates)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CodexTranscriptMeta(CodeAlmanacModel):
|
|
47
|
+
session_id: str
|
|
48
|
+
cwd: str
|
|
49
|
+
thread_source: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_codex_meta(path: Path) -> CodexTranscriptMeta | None:
|
|
53
|
+
for line in read_first_lines(path, 20):
|
|
54
|
+
parsed = parse_json_object(line)
|
|
55
|
+
if parsed is None:
|
|
56
|
+
continue
|
|
57
|
+
payload = object_field(parsed, "payload")
|
|
58
|
+
if payload is None:
|
|
59
|
+
continue
|
|
60
|
+
session_id = string_field(payload, "id")
|
|
61
|
+
cwd = string_field(payload, "cwd")
|
|
62
|
+
if session_id is None or cwd is None:
|
|
63
|
+
continue
|
|
64
|
+
return CodexTranscriptMeta(
|
|
65
|
+
session_id=session_id,
|
|
66
|
+
cwd=cwd,
|
|
67
|
+
thread_source=string_field(payload, "thread_source"),
|
|
68
|
+
)
|
|
69
|
+
return None
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from datetime import UTC, datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from codealmanac.core.paths import normalize_path
|
|
6
|
+
from codealmanac.services.sources.models import TranscriptApp, TranscriptCandidate
|
|
7
|
+
from codealmanac.services.workspaces.roots import nearest_almanac_root
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def collect_jsonl(root: Path) -> tuple[Path, ...]:
|
|
11
|
+
if not root.is_dir():
|
|
12
|
+
return ()
|
|
13
|
+
return tuple(sorted(path for path in root.rglob("*.jsonl") if path.is_file()))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_first_lines(path: Path, max_lines: int) -> tuple[str, ...]:
|
|
17
|
+
try:
|
|
18
|
+
with path.open("r", encoding="utf-8") as file:
|
|
19
|
+
lines = []
|
|
20
|
+
for _ in range(max_lines):
|
|
21
|
+
line = file.readline()
|
|
22
|
+
if line == "":
|
|
23
|
+
break
|
|
24
|
+
lines.append(line.rstrip("\r\n"))
|
|
25
|
+
return tuple(lines)
|
|
26
|
+
except OSError:
|
|
27
|
+
return ()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_json_object(line: str) -> dict[str, object] | None:
|
|
31
|
+
if not line.strip():
|
|
32
|
+
return None
|
|
33
|
+
try:
|
|
34
|
+
parsed = json.loads(line)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return None
|
|
37
|
+
if isinstance(parsed, dict):
|
|
38
|
+
return parsed
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def object_field(
|
|
43
|
+
value: dict[str, object],
|
|
44
|
+
key: str,
|
|
45
|
+
) -> dict[str, object] | None:
|
|
46
|
+
field = value.get(key)
|
|
47
|
+
if isinstance(field, dict):
|
|
48
|
+
return field
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def string_field(value: dict[str, object], key: str) -> str | None:
|
|
53
|
+
field = value.get(key)
|
|
54
|
+
if isinstance(field, str) and field:
|
|
55
|
+
return field
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def candidate_from_meta(
|
|
60
|
+
app: TranscriptApp,
|
|
61
|
+
transcript_path: Path,
|
|
62
|
+
session_id: str,
|
|
63
|
+
cwd: str,
|
|
64
|
+
almanac_roots: tuple[Path, ...],
|
|
65
|
+
) -> TranscriptCandidate | None:
|
|
66
|
+
match = nearest_almanac_root(Path(cwd), almanac_roots)
|
|
67
|
+
if match is None:
|
|
68
|
+
return None
|
|
69
|
+
try:
|
|
70
|
+
stat = transcript_path.stat()
|
|
71
|
+
except OSError:
|
|
72
|
+
return None
|
|
73
|
+
if not transcript_path.is_file():
|
|
74
|
+
return None
|
|
75
|
+
return TranscriptCandidate(
|
|
76
|
+
app=app,
|
|
77
|
+
session_id=session_id,
|
|
78
|
+
transcript_path=normalize_path(transcript_path),
|
|
79
|
+
cwd=normalize_path(Path(cwd)),
|
|
80
|
+
repo_root=match.repo_root,
|
|
81
|
+
almanac_path=match.almanac_path,
|
|
82
|
+
modified_at=datetime.fromtimestamp(stat.st_mtime, UTC),
|
|
83
|
+
size_bytes=stat.st_size,
|
|
84
|
+
)
|