kataster 0.0.0-stage → 0.2.0
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.
- package/.claude/agents/acceptance-tester.md +53 -0
- package/.claude/agents/code-reviewer.md +56 -0
- package/.claude/agents/domain-architect.md +40 -0
- package/.claude/agents/lead-dev.md +50 -0
- package/.claude/agents/product-owner.md +39 -0
- package/.claude/agents/repo-analyst.md +78 -0
- package/.claude/agents/requirements-engineer.md +44 -0
- package/.claude/agents/ux-designer.md +64 -0
- package/.claude/hooks/bash-guard.ts +92 -0
- package/.claude/hooks/code-check.ts +37 -0
- package/.claude/hooks/path-guard.ts +34 -0
- package/.claude/hooks/path-rules.ts +66 -0
- package/.claude/hooks/project-check.ts +108 -0
- package/.claude/hooks/protect-files.ts +55 -0
- package/.claude/hooks/session-start.ts +213 -0
- package/.claude/hooks/stop-gate.ts +56 -0
- package/.claude/hooks/validate-docs.ts +58 -0
- package/.claude/lib/architecture.ts +736 -0
- package/.claude/lib/backlog-github.ts +454 -0
- package/.claude/lib/backlog-jira.ts +347 -0
- package/.claude/lib/backlog-sync.ts +42 -0
- package/.claude/lib/backlog.ts +396 -0
- package/.claude/lib/bazel.ts +212 -0
- package/.claude/lib/config.ts +122 -0
- package/.claude/lib/discovery.ts +821 -0
- package/.claude/lib/engineering.ts +406 -0
- package/.claude/lib/nx.ts +129 -0
- package/.claude/lib/repo.ts +1194 -0
- package/.claude/lib/toolgraph.ts +74 -0
- package/.claude/lib/turbo.ts +166 -0
- package/.claude/package-lock.json +427 -0
- package/.claude/package.json +21 -0
- package/.claude/settings.json +82 -0
- package/.claude/skills/adopt/SKILL.md +283 -0
- package/.claude/skills/adopt/scripts/boundary-check.ts +156 -0
- package/.claude/skills/adopt/scripts/package-doc.ts +71 -0
- package/.claude/skills/adopt/scripts/repo-map.ts +123 -0
- package/.claude/skills/adopt/scripts/rule-doc.ts +80 -0
- package/.claude/skills/architect/ADR-FORMAT.md +97 -0
- package/.claude/skills/architect/SKILL.md +64 -0
- package/.claude/skills/architect/TECH-STACK.md +21 -0
- package/.claude/skills/backlog/SKILL.md +198 -0
- package/.claude/skills/backlog/scripts/backlog-pull.ts +45 -0
- package/.claude/skills/backlog/scripts/backlog-push.ts +38 -0
- package/.claude/skills/backlog/scripts/backlog-status.ts +32 -0
- package/.claude/skills/backlog/scripts/jira-import.ts +40 -0
- package/.claude/skills/backlog/scripts/jira-plan.ts +26 -0
- package/.claude/skills/backlog/scripts/jira-record.ts +41 -0
- package/.claude/skills/bootstrap/SKILL.md +119 -0
- package/.claude/skills/codebase-design/DEEPENING.md +37 -0
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.claude/skills/codebase-design/SKILL.md +114 -0
- package/.claude/skills/domain-model/CONTEXT-FORMAT.md +47 -0
- package/.claude/skills/domain-model/SKILL.md +64 -0
- package/.claude/skills/domain-model/templates/data-model.md +29 -0
- package/.claude/skills/engineering-conventions/SKILL.md +31 -0
- package/.claude/skills/event-storming/SKILL.md +44 -0
- package/.claude/skills/event-storming/schema/events.schema.json +124 -0
- package/.claude/skills/event-storming/scripts/render-event-storming.ts +11 -0
- package/.claude/skills/event-storming/scripts/validate-events.ts +11 -0
- package/.claude/skills/event-storming/templates/events.example.yaml +91 -0
- package/.claude/skills/feature/SKILL.md +86 -0
- package/.claude/skills/feature/scripts/intake.ts +356 -0
- package/.claude/skills/grill-me/SKILL.md +7 -0
- package/.claude/skills/grill-with-docs/SKILL.md +7 -0
- package/.claude/skills/grilling/SKILL.md +30 -0
- package/.claude/skills/groom-backlog/SKILL.md +93 -0
- package/.claude/skills/groom-backlog/scripts/backlog-health.ts +238 -0
- package/.claude/skills/implement/SKILL.md +129 -0
- package/.claude/skills/implement/scripts/affected.ts +245 -0
- package/.claude/skills/implement/scripts/check-commands.ts +37 -0
- package/.claude/skills/implement/scripts/check-language.ts +15 -0
- package/.claude/skills/implement/scripts/check-scenarios.ts +59 -0
- package/.claude/skills/implement/scripts/next-story.ts +23 -0
- package/.claude/skills/implement/scripts/story-context.ts +135 -0
- package/.claude/skills/implement/scripts/story-status.ts +47 -0
- package/.claude/skills/implement/scripts/verify.ts +92 -0
- package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +71 -0
- package/.claude/skills/retro/SKILL.md +53 -0
- package/.claude/skills/review-stories/SKILL.md +31 -0
- package/.claude/skills/shape/SKILL.md +193 -0
- package/.claude/skills/tdd/SKILL.md +38 -0
- package/.claude/skills/tdd/mocking.md +59 -0
- package/.claude/skills/tdd/tests.md +77 -0
- package/.claude/skills/user-stories/SKILL.md +138 -0
- package/.claude/skills/user-stories/schema/story.schema.json +39 -0
- package/.claude/skills/user-stories/scripts/render-backlog.ts +4 -0
- package/.claude/skills/user-stories/scripts/validate-stories.ts +13 -0
- package/.claude/skills/user-stories/templates/story-template.md +38 -0
- package/.claude/skills/ux-design/SKILL.md +107 -0
- package/.claude/skills/writing-for-agents/SKILL-MECHANICS.md +22 -0
- package/.claude/skills/writing-for-agents/SKILL.md +81 -0
- package/.claude/tsconfig.json +15 -0
- package/.opencode/agents/acceptance-tester.md +64 -0
- package/.opencode/agents/code-reviewer.md +69 -0
- package/.opencode/agents/domain-architect.md +59 -0
- package/.opencode/agents/lead-dev.md +67 -0
- package/.opencode/agents/product-owner.md +59 -0
- package/.opencode/agents/repo-analyst.md +86 -0
- package/.opencode/agents/requirements-engineer.md +58 -0
- package/.opencode/agents/ux-designer.md +83 -0
- package/.opencode/commands/adopt.md +7 -0
- package/.opencode/commands/backlog-sync.md +7 -0
- package/.opencode/commands/bootstrap.md +7 -0
- package/.opencode/commands/event-storming.md +7 -0
- package/.opencode/commands/feature.md +7 -0
- package/.opencode/commands/grill-me.md +7 -0
- package/.opencode/commands/grill-with-docs.md +7 -0
- package/.opencode/commands/groom-backlog.md +7 -0
- package/.opencode/commands/implement.md +7 -0
- package/.opencode/commands/improve-codebase-architecture.md +7 -0
- package/.opencode/commands/retro.md +7 -0
- package/.opencode/commands/review-stories.md +7 -0
- package/.opencode/commands/shape.md +7 -0
- package/.opencode/opencode.jsonc +15 -0
- package/.opencode/plugins/kataster/index.ts +283 -0
- package/AGENTS.md +21 -0
- package/CHANGELOG.md +82 -0
- package/LICENSE +21 -0
- package/README.md +324 -2
- package/bin/kataster.mjs +7 -0
- package/extras/skills/shadcn/SKILL.md +277 -0
- package/extras/skills/shadcn/agents/openai.yml +5 -0
- package/extras/skills/shadcn/assets/shadcn-small.png +0 -0
- package/extras/skills/shadcn/assets/shadcn.png +0 -0
- package/extras/skills/shadcn/cli.md +290 -0
- package/extras/skills/shadcn/customization.md +209 -0
- package/extras/skills/shadcn/evals/evals.json +77 -0
- package/extras/skills/shadcn/mcp.md +105 -0
- package/extras/skills/shadcn/registry.md +277 -0
- package/extras/skills/shadcn/rules/base-vs-radix.md +306 -0
- package/extras/skills/shadcn/rules/chat.md +250 -0
- package/extras/skills/shadcn/rules/composition.md +213 -0
- package/extras/skills/shadcn/rules/forms.md +192 -0
- package/extras/skills/shadcn/rules/icons.md +101 -0
- package/extras/skills/shadcn/rules/styling.md +185 -0
- package/guide/README.md +9 -0
- package/guide/brownfield.md +242 -0
- package/guide/concepts.md +109 -0
- package/guide/greenfield.md +84 -0
- package/guide/install.md +144 -0
- package/guide/release.md +62 -0
- package/package.json +56 -4
- package/src/cli.mjs +302 -0
- package/src/detect.mjs +237 -0
- package/src/install.mjs +427 -0
- package/src/repo.mjs +129 -0
- package/src/util.mjs +110 -0
- package/templates/CONTEXT.md +21 -0
- package/templates/conventions.md +63 -0
- package/templates/docs/adr/README.md +7 -0
- package/templates/docs/architecture/data-model.md +31 -0
- package/templates/docs/domain/context-map.md +21 -0
- package/templates/docs/domain/events.yaml +23 -0
- package/templates/docs/product/initiatives/README.md +41 -0
- package/templates/docs/product/ux-guidelines.md +27 -0
- package/templates/docs/product/vision.md +36 -0
- package/templates/docs/reviews/.gitkeep +0 -0
- package/templates/docs/stories/OPEN_QUESTIONS.md +8 -0
- package/templates/docs/stories/README.md +26 -0
- package/templates/docs/workflow.md +135 -0
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Issues adapter. Deterministic: every operation is a `gh` CLI call, so it works from a script, from a hook
|
|
3
|
+
* (when asked to) and from the agent.
|
|
4
|
+
*
|
|
5
|
+
* The tracker is the source of truth. `pull` only overwrites a local story when the remote issue changed since the
|
|
6
|
+
* last sync; if both sides changed, the tracker wins and the local file is backed up. `push` refuses a story whose
|
|
7
|
+
* remote counterpart changed since the last sync – pull first, then decide.
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from "node:child_process";
|
|
10
|
+
import { isManagedLabel, labelPrefix, type BacklogConfig } from "./config.ts";
|
|
11
|
+
import * as d from "./discovery.ts";
|
|
12
|
+
import {
|
|
13
|
+
backupStory,
|
|
14
|
+
bumpStoryId,
|
|
15
|
+
externalOf,
|
|
16
|
+
knownContexts,
|
|
17
|
+
nextStoryId,
|
|
18
|
+
parseIssue,
|
|
19
|
+
payloadHash,
|
|
20
|
+
readProviderState,
|
|
21
|
+
remotePayload,
|
|
22
|
+
renderIssue,
|
|
23
|
+
setExternal,
|
|
24
|
+
storyHash,
|
|
25
|
+
writeStory,
|
|
26
|
+
writeSyncState,
|
|
27
|
+
type RemoteIssue,
|
|
28
|
+
} from "./backlog.ts";
|
|
29
|
+
|
|
30
|
+
type Json = Record<string, unknown>;
|
|
31
|
+
|
|
32
|
+
function gh(args: string[], input?: string): string {
|
|
33
|
+
const r = spawnSync("gh", args, {
|
|
34
|
+
cwd: d.ROOT,
|
|
35
|
+
encoding: "utf8",
|
|
36
|
+
input,
|
|
37
|
+
timeout: 120_000,
|
|
38
|
+
});
|
|
39
|
+
if (r.error) throw new Error(`gh ${args[0]}: ${r.error.message}`);
|
|
40
|
+
if (r.status !== 0) {
|
|
41
|
+
const detail = `${r.stderr ?? ""}${r.stdout ?? ""}`.trim();
|
|
42
|
+
throw new Error(`gh ${args.join(" ")} failed${detail ? `: ${detail}` : ""}`);
|
|
43
|
+
}
|
|
44
|
+
return (r.stdout ?? "").trim();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function repoArgs(cfg: BacklogConfig, repo = cfg.github.repo): string[] {
|
|
48
|
+
return repo ? ["--repo", repo] : [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function labelNames(raw: unknown): string[] {
|
|
52
|
+
if (!Array.isArray(raw)) return [];
|
|
53
|
+
return raw
|
|
54
|
+
.map((label) => (typeof label === "string" ? label : label !== null && typeof label === "object" ? (label as Json).name : undefined))
|
|
55
|
+
.filter((name): name is string => typeof name === "string" && name.length > 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function remoteIssues(cfg: BacklogConfig): RemoteIssue[] {
|
|
59
|
+
const raw = gh([
|
|
60
|
+
"issue", "list",
|
|
61
|
+
"--state", "all",
|
|
62
|
+
"--limit", "1000",
|
|
63
|
+
"--json", "number,title,body,state,labels,url",
|
|
64
|
+
...repoArgs(cfg),
|
|
65
|
+
]);
|
|
66
|
+
const parsed = JSON.parse(raw || "[]") as Json[];
|
|
67
|
+
const prefix = labelPrefix(cfg);
|
|
68
|
+
return parsed.map((issue) => ({
|
|
69
|
+
key: String(issue.number),
|
|
70
|
+
url: typeof issue.url === "string" ? issue.url : undefined,
|
|
71
|
+
title: String(issue.title ?? ""),
|
|
72
|
+
body: String(issue.body ?? ""),
|
|
73
|
+
labels: labelNames(issue.labels),
|
|
74
|
+
state: (String(issue.state).toLowerCase() === "closed" ? "closed" : "open") as "closed" | "open",
|
|
75
|
+
})).filter((issue) => issue.labels.includes(prefix) || issue.body.includes("<!-- kataster:story") || issue.body.includes("```kataster-story"));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function remoteIssue(cfg: BacklogConfig, key: string, repo?: string): RemoteIssue | null {
|
|
79
|
+
try {
|
|
80
|
+
const issue = JSON.parse(gh([
|
|
81
|
+
"issue", "view", key,
|
|
82
|
+
"--json", "number,title,body,state,labels,url",
|
|
83
|
+
...repoArgs(cfg, repo),
|
|
84
|
+
])) as Json;
|
|
85
|
+
return {
|
|
86
|
+
key: String(issue.number),
|
|
87
|
+
url: typeof issue.url === "string" ? issue.url : undefined,
|
|
88
|
+
title: String(issue.title ?? ""),
|
|
89
|
+
body: String(issue.body ?? ""),
|
|
90
|
+
labels: labelNames(issue.labels),
|
|
91
|
+
state: String(issue.state).toLowerCase() === "closed" ? "closed" : "open",
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const message = String(error);
|
|
95
|
+
// A missing issue is an orphan; an auth or network failure is not and must reach the user.
|
|
96
|
+
if (/not found|could not resolve|no issue/i.test(message)) return null;
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Read-only lookup for `/feature` intake: the issue as evidence, without touching the mirror or the sync state.
|
|
103
|
+
* `repo` overrides `backlog.github.repo` (a pasted URL may point at another repository).
|
|
104
|
+
*/
|
|
105
|
+
export function readGithubIssue(cfg: BacklogConfig, key: string, repo?: string): RemoteIssue | null {
|
|
106
|
+
return remoteIssue(cfg, key, repo);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Creates labels that do not exist yet. Existing labels keep their colour and description. */
|
|
110
|
+
function ensureLabels(cfg: BacklogConfig, labels: string[]): void {
|
|
111
|
+
if (!labels.length) return;
|
|
112
|
+
const existing = new Set(
|
|
113
|
+
(JSON.parse(gh(["label", "list", "--limit", "1000", "--json", "name", ...repoArgs(cfg)]) || "[]") as Json[])
|
|
114
|
+
.map((label) => String(label.name)),
|
|
115
|
+
);
|
|
116
|
+
const prefix = labelPrefix(cfg);
|
|
117
|
+
for (const label of labels) {
|
|
118
|
+
if (existing.has(label)) continue;
|
|
119
|
+
try {
|
|
120
|
+
gh([
|
|
121
|
+
"label", "create", label,
|
|
122
|
+
"--color", isManagedLabel(label, prefix) ? "ededed" : "c5def5",
|
|
123
|
+
"--description", isManagedLabel(label, prefix) ? "Managed by Kataster" : "Kataster story label",
|
|
124
|
+
...repoArgs(cfg),
|
|
125
|
+
]);
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!/already exists/i.test(String(error))) throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function shouldBeClosed(payloadStatus: string): boolean {
|
|
133
|
+
return payloadStatus === "done" || payloadStatus === "superseded";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function closeReason(status: string): string {
|
|
137
|
+
return status === "superseded" ? "not planned" : "completed";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function issueNumber(url: string): string {
|
|
141
|
+
const match = /\/issues\/(\d+)\s*$/.exec(url.trim());
|
|
142
|
+
if (!match) throw new Error(`could not read the issue number from '${url}'`);
|
|
143
|
+
return match[1];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function setOpenState(cfg: BacklogConfig, key: string, wantClosed: boolean, status: string): void {
|
|
147
|
+
const remote = remoteIssue(cfg, key);
|
|
148
|
+
if (!remote) return;
|
|
149
|
+
if (wantClosed && remote.state !== "closed") {
|
|
150
|
+
gh(["issue", "close", key, "--reason", closeReason(status), ...repoArgs(cfg)]);
|
|
151
|
+
} else if (!wantClosed && remote.state === "closed") {
|
|
152
|
+
gh(["issue", "reopen", key, ...repoArgs(cfg)]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface SyncReport {
|
|
157
|
+
lines: string[];
|
|
158
|
+
changed: boolean;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Push local stories to GitHub. Stories without an external ref are created; existing ones are updated. */
|
|
162
|
+
export function pushGithub(cfg: BacklogConfig, ids: string[] = []): SyncReport {
|
|
163
|
+
const wanted = new Set(ids.map((id) => id.toUpperCase()));
|
|
164
|
+
const stories = d.loadStories().parsed.filter((story) => !wanted.size || wanted.has(d.storyId(story)));
|
|
165
|
+
const state = readProviderState("github");
|
|
166
|
+
const lines: string[] = [];
|
|
167
|
+
let changed = false;
|
|
168
|
+
|
|
169
|
+
for (const story of stories) {
|
|
170
|
+
const id = d.storyId(story);
|
|
171
|
+
if (!id) {
|
|
172
|
+
lines.push(`skip ${d.rel(story.path)}: no story id`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const payload = renderIssue(story, cfg);
|
|
176
|
+
const ext = externalOf(story);
|
|
177
|
+
if (ext && ext.provider !== "github") {
|
|
178
|
+
lines.push(`skip ${id}: linked to ${ext.provider} (${ext.key}) – change backlog.provider or remove the external ref`);
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!ext) {
|
|
183
|
+
ensureLabels(cfg, payload.labels);
|
|
184
|
+
const out = gh([
|
|
185
|
+
"issue", "create",
|
|
186
|
+
"--title", payload.title,
|
|
187
|
+
"--body-file", "-",
|
|
188
|
+
...payload.labels.flatMap((label) => ["--label", label]),
|
|
189
|
+
...repoArgs(cfg),
|
|
190
|
+
], payload.body);
|
|
191
|
+
const url = out.split("\n").map((line) => line.trim()).find((line) => /\/issues\/\d+$/.test(line)) ?? out.trim();
|
|
192
|
+
const key = issueNumber(url);
|
|
193
|
+
setExternal(story, { provider: "github", key, url });
|
|
194
|
+
if (shouldBeClosed(payload.status)) setOpenState(cfg, key, true, payload.status);
|
|
195
|
+
state.stories[id] = { key, url, localHash: storyHash(story), remoteHash: payloadHash(payload) };
|
|
196
|
+
lines.push(`created ${id} → #${key}`);
|
|
197
|
+
changed = true;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const entry = state.stories[id];
|
|
202
|
+
const remote = remoteIssue(cfg, ext.key);
|
|
203
|
+
if (!remote) {
|
|
204
|
+
lines.push(`orphan ${id}: GitHub issue #${ext.key} does not exist any more – nothing pushed`);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const sentHash = payloadHash(payload);
|
|
208
|
+
const currentRemoteHash = payloadHash(remotePayload(remote, cfg));
|
|
209
|
+
const localChanged = entry ? storyHash(story) !== entry.localHash : true;
|
|
210
|
+
const remoteChanged = entry ? currentRemoteHash !== entry.remoteHash : false;
|
|
211
|
+
|
|
212
|
+
if (remoteChanged && !localChanged) {
|
|
213
|
+
lines.push(`remote ${id}: GitHub changed since the last sync – pull first, then push`);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (localChanged && remoteChanged) {
|
|
217
|
+
lines.push(`conflict ${id}: local and GitHub changed since the last sync – pull first, then push`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!localChanged && !remoteChanged) {
|
|
221
|
+
lines.push(`unchanged ${id} (#${ext.key})`);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
ensureLabels(cfg, payload.labels);
|
|
226
|
+
const add = payload.labels.filter((label) => !remote.labels.includes(label));
|
|
227
|
+
const remove = remote.labels.filter((label) => !payload.labels.includes(label));
|
|
228
|
+
|
|
229
|
+
gh([
|
|
230
|
+
"issue", "edit", ext.key,
|
|
231
|
+
"--title", payload.title,
|
|
232
|
+
"--body-file", "-",
|
|
233
|
+
...add.flatMap((label) => ["--add-label", label]),
|
|
234
|
+
...remove.flatMap((label) => ["--remove-label", label]),
|
|
235
|
+
...repoArgs(cfg),
|
|
236
|
+
], payload.body);
|
|
237
|
+
setOpenState(cfg, ext.key, shouldBeClosed(payload.status), payload.status);
|
|
238
|
+
state.stories[id] = {
|
|
239
|
+
key: ext.key,
|
|
240
|
+
...(ext.url ? { url: ext.url } : {}),
|
|
241
|
+
localHash: storyHash(story),
|
|
242
|
+
remoteHash: sentHash,
|
|
243
|
+
};
|
|
244
|
+
lines.push(`updated ${id} → #${ext.key}`);
|
|
245
|
+
changed = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
state.provider = "github";
|
|
249
|
+
if (changed) writeSyncState(state);
|
|
250
|
+
if (changed) d.writeBacklogMd();
|
|
251
|
+
if (!lines.length) lines.push("nothing to push – no stories in scope");
|
|
252
|
+
return { lines, changed };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Pull GitHub issues into the local mirror. The tracker wins; local unpushed changes are reported. */
|
|
256
|
+
export function pullGithub(cfg: BacklogConfig): SyncReport {
|
|
257
|
+
const issues = remoteIssues(cfg);
|
|
258
|
+
const stories = d.loadStories().parsed;
|
|
259
|
+
const byId = new Map(stories.map((story) => [d.storyId(story), story]));
|
|
260
|
+
const byKey = new Map(
|
|
261
|
+
stories.flatMap((story) => {
|
|
262
|
+
const ext = externalOf(story);
|
|
263
|
+
return ext && ext.provider === "github" ? [[ext.key, story] as const] : [];
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
const state = readProviderState("github");
|
|
267
|
+
const contexts = knownContexts();
|
|
268
|
+
const lines: string[] = [];
|
|
269
|
+
let changed = false;
|
|
270
|
+
let next = nextStoryId();
|
|
271
|
+
|
|
272
|
+
for (const issue of issues) {
|
|
273
|
+
const existingByKey = byKey.get(issue.key);
|
|
274
|
+
const fallbackId = existingByKey ? d.storyId(existingByKey) : "";
|
|
275
|
+
const parsed = parseIssue(issue, cfg, { contexts, fallbackId });
|
|
276
|
+
// The story id is stable; an issue matched by its external key keeps the id it already has.
|
|
277
|
+
let id = existingByKey ? d.storyId(existingByKey) : parsed.id;
|
|
278
|
+
if (existingByKey && parsed.id && parsed.id !== id) {
|
|
279
|
+
lines.push(`warning ${id}: the issue title claims ${parsed.id}; the stable id stays ${id}`);
|
|
280
|
+
}
|
|
281
|
+
if (!id) {
|
|
282
|
+
id = next;
|
|
283
|
+
next = bumpStoryId(next);
|
|
284
|
+
}
|
|
285
|
+
const existing = byId.get(id) ?? existingByKey;
|
|
286
|
+
const existingExt = existing ? externalOf(existing) : null;
|
|
287
|
+
if (existingExt && (existingExt.provider !== "github" || existingExt.key !== issue.key)) {
|
|
288
|
+
lines.push(`conflict ${id}: local story is linked to ${existingExt.provider} ${existingExt.key}, but #${issue.key} claims the same id – skipped`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const remoteHash = payloadHash(remotePayload(issue, cfg));
|
|
293
|
+
const entry = state.stories[id];
|
|
294
|
+
const localHash = existing ? storyHash(existing) : "";
|
|
295
|
+
const remoteUnchanged = Boolean(entry && entry.remoteHash === remoteHash);
|
|
296
|
+
const localChanged = Boolean(existing && entry && localHash !== entry.localHash);
|
|
297
|
+
|
|
298
|
+
// Fresh clone: the mirror is committed, the sync state is not. If the local story already renders to the same
|
|
299
|
+
// issue, only the state is missing – no conflict, no backup, no rewrite.
|
|
300
|
+
if (existing && !entry && payloadHash(renderIssue(existing, cfg)) === remoteHash) {
|
|
301
|
+
state.stories[id] = {
|
|
302
|
+
key: issue.key,
|
|
303
|
+
...(issue.url ? { url: issue.url } : {}),
|
|
304
|
+
localHash,
|
|
305
|
+
remoteHash,
|
|
306
|
+
};
|
|
307
|
+
lines.push(`synced ${id} ← #${issue.key} (state rebuilt)`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// No remote change: a local story that is ahead stays as it is.
|
|
312
|
+
if (existing && remoteUnchanged && localChanged) {
|
|
313
|
+
lines.push(`local ${id}: local changes are not pushed yet – GitHub did not change, left alone`);
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (existing && entry && localChanged && !remoteUnchanged) {
|
|
318
|
+
const backup = backupStory(existing);
|
|
319
|
+
lines.push(`conflict ${id}: local and GitHub changed – the tracker wins, local copy saved to ${backup}`);
|
|
320
|
+
} else if (existing && !entry) {
|
|
321
|
+
const backup = backupStory(existing);
|
|
322
|
+
lines.push(`conflict ${id}: no sync state yet – the tracker wins, local copy saved to ${backup}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const path = writeStory(id, parsed.meta, parsed.body, existing);
|
|
326
|
+
state.stories[id] = {
|
|
327
|
+
key: issue.key,
|
|
328
|
+
...(issue.url ? { url: issue.url } : {}),
|
|
329
|
+
localHash: storyHash({ ...parsed, path } as d.Story),
|
|
330
|
+
remoteHash,
|
|
331
|
+
};
|
|
332
|
+
lines.push(`${existing ? "updated " : "imported"} ${id} ← #${issue.key} (${d.rel(path)})`);
|
|
333
|
+
changed = true;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const remoteKeys = new Set(issues.map((issue) => issue.key));
|
|
337
|
+
for (const story of stories) {
|
|
338
|
+
const ext = externalOf(story);
|
|
339
|
+
if (!ext) continue;
|
|
340
|
+
if (ext.provider !== "github") continue;
|
|
341
|
+
if (!remoteKeys.has(ext.key)) lines.push(`orphan ${d.storyId(story)}: linked to #${ext.key}, which is no longer in the issue list`);
|
|
342
|
+
}
|
|
343
|
+
for (const story of stories) {
|
|
344
|
+
if (!externalOf(story)) lines.push(`local ${d.storyId(story)}: not linked to GitHub yet – push to create the issue`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
state.provider = "github";
|
|
348
|
+
if (changed) writeSyncState(state);
|
|
349
|
+
if (changed) d.writeBacklogMd();
|
|
350
|
+
if (!lines.length) lines.push("nothing to pull – no Kataster issues found");
|
|
351
|
+
return { lines, changed };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Compares the local mirror with GitHub without writing anything. */
|
|
355
|
+
export function statusGithub(cfg: BacklogConfig): { lines: string[]; drift: boolean } {
|
|
356
|
+
const issues = remoteIssues(cfg);
|
|
357
|
+
const remoteByKey = new Map(issues.map((issue) => [issue.key, issue]));
|
|
358
|
+
const state = readProviderState("github");
|
|
359
|
+
const lines: string[] = [];
|
|
360
|
+
let drift = false;
|
|
361
|
+
|
|
362
|
+
for (const story of d.loadStories().parsed) {
|
|
363
|
+
const id = d.storyId(story);
|
|
364
|
+
const ext = externalOf(story);
|
|
365
|
+
if (!ext || ext.provider !== "github") {
|
|
366
|
+
const committed = ["ready", "in-progress", "done"].includes(String(story.meta.status));
|
|
367
|
+
lines.push(`${committed ? "missing" : "local "} ${id}: ${committed ? "must be linked to GitHub" : "not linked to GitHub"}`);
|
|
368
|
+
if (committed) drift = true;
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
const remote = remoteByKey.get(ext.key);
|
|
372
|
+
if (!remote) {
|
|
373
|
+
lines.push(`orphan ${id}: #${ext.key} is not in the issue list`);
|
|
374
|
+
drift = true;
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const entry = state.stories[id];
|
|
378
|
+
if (!entry) {
|
|
379
|
+
lines.push(`unknown ${id}: #${ext.key} has no sync state yet – pull or push once`);
|
|
380
|
+
drift = true;
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
const localChanged = storyHash(story) !== entry.localHash;
|
|
384
|
+
const remoteChanged = payloadHash(remotePayload(remote, cfg)) !== entry.remoteHash;
|
|
385
|
+
if (localChanged && remoteChanged) {
|
|
386
|
+
lines.push(`conflict ${id}: local and GitHub changed since the last sync`);
|
|
387
|
+
drift = true;
|
|
388
|
+
} else if (localChanged) {
|
|
389
|
+
lines.push(`local ${id}: local changes are not pushed`);
|
|
390
|
+
drift = true;
|
|
391
|
+
} else if (remoteChanged) {
|
|
392
|
+
lines.push(`remote ${id}: GitHub changed – pull to update the mirror`);
|
|
393
|
+
drift = true;
|
|
394
|
+
} else {
|
|
395
|
+
lines.push(`synced ${id} ↔ #${ext.key}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const localKeys = new Set(
|
|
400
|
+
d.loadStories().parsed.flatMap((story) => {
|
|
401
|
+
const ext = externalOf(story);
|
|
402
|
+
return ext && ext.provider === "github" ? [ext.key] : [];
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
for (const issue of issues) {
|
|
406
|
+
if (!localKeys.has(issue.key)) {
|
|
407
|
+
lines.push(`remote #${issue.key}: Kataster issue without a local story`);
|
|
408
|
+
drift = true;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
return { lines, drift };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Sets the status of a linked GitHub issue (title/body/labels + open/closed) after a local status change. */
|
|
415
|
+
export function githubSetStatus(story: d.Story, status: string, cfg: BacklogConfig): string {
|
|
416
|
+
const ext = externalOf(story);
|
|
417
|
+
if (!ext) return "GitHub: story is not linked yet – run /backlog-sync push.";
|
|
418
|
+
if (ext.provider !== "github") return `GitHub: story is linked to ${ext.provider} (${ext.key}), not to GitHub – nothing changed.`;
|
|
419
|
+
const remote = remoteIssue(cfg, ext.key);
|
|
420
|
+
if (!remote) return `GitHub: #${ext.key} does not exist any more – nothing changed.`;
|
|
421
|
+
|
|
422
|
+
const entry = readProviderState("github").stories[d.storyId(story)];
|
|
423
|
+
if (entry && payloadHash(remotePayload(remote, cfg)) !== entry.remoteHash) {
|
|
424
|
+
return `GitHub: #${ext.key} changed since the last sync – pull first, then set the status again.`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const updated = { ...story, meta: { ...story.meta, status } };
|
|
428
|
+
const payload = renderIssue(updated, cfg);
|
|
429
|
+
ensureLabels(cfg, payload.labels);
|
|
430
|
+
const add = payload.labels.filter((label) => !remote.labels.includes(label));
|
|
431
|
+
const remove = remote.labels.filter((label) => !payload.labels.includes(label));
|
|
432
|
+
|
|
433
|
+
gh([
|
|
434
|
+
"issue", "edit", ext.key,
|
|
435
|
+
"--title", payload.title,
|
|
436
|
+
"--body-file", "-",
|
|
437
|
+
...add.flatMap((label) => ["--add-label", label]),
|
|
438
|
+
...remove.flatMap((label) => ["--remove-label", label]),
|
|
439
|
+
...repoArgs(cfg),
|
|
440
|
+
], payload.body);
|
|
441
|
+
setOpenState(cfg, ext.key, shouldBeClosed(status), status);
|
|
442
|
+
|
|
443
|
+
const state = readProviderState("github");
|
|
444
|
+
state.provider = "github";
|
|
445
|
+
state.stories[d.storyId(story)] = {
|
|
446
|
+
key: ext.key,
|
|
447
|
+
...(ext.url ? { url: ext.url } : {}),
|
|
448
|
+
localHash: storyHash(story),
|
|
449
|
+
remoteHash: payloadHash(payload),
|
|
450
|
+
};
|
|
451
|
+
writeSyncState(state);
|
|
452
|
+
return `GitHub: #${ext.key} → ${status}.`;
|
|
453
|
+
}
|
|
454
|
+
|