@workit-dev/core 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +22 -43
- package/package.json +56 -6
- package/schemas/config.json +74 -0
- package/schemas/vcs.json +79 -0
- package/schemas/workspaces.json +1029 -0
- package/schemas/youtrack.json +84 -0
- package/scripts/analyze-release-scope.ts +215 -0
- package/scripts/bundled-deps.json +131 -0
- package/scripts/fixtures/sample-plan.md +15 -0
- package/scripts/fixtures/sample-sdd/progress.md +2 -0
- package/scripts/install-codex-plugin.sh +28 -0
- package/scripts/install-cursor-plugin.sh +146 -0
- package/scripts/install-opencode-plugin.sh +79 -0
- package/scripts/install-pi-package.sh +56 -0
- package/scripts/lib/config-dir.sh +28 -0
- package/scripts/publish-changed-packages.ts +395 -0
- package/scripts/rewrite-workspace-deps.ts +53 -0
- package/scripts/run-cursor-mcp.sh +6 -0
- package/scripts/sync-release-manifests.ts +82 -0
- package/scripts/sync-runtime.sh +142 -0
- package/scripts/validate-cursor-marketplace.ts +171 -0
- package/scripts/verify-bundle-sources.ts +134 -0
- package/scripts/write-schemas.ts +16 -0
- package/skills/workit-architecture/SKILL.md +72 -0
- package/skills/workit-architecture/agents/openai.yaml +3 -0
- package/skills/workit-architecture/references/instruction-files.md +52 -0
- package/skills/workit-architecture/references/vocabulary.md +53 -0
- package/skills/workit-bdd/SKILL.md +53 -0
- package/skills/workit-continue/SKILL.md +54 -0
- package/skills/workit-debug/SKILL.md +45 -0
- package/skills/workit-deslop/SKILL.md +58 -0
- package/skills/workit-fanout/SKILL.md +71 -0
- package/skills/workit-fanout/references/brief.md +141 -0
- package/skills/workit-fanout/references/orchestration.md +169 -0
- package/skills/workit-implement/SKILL.md +71 -0
- package/skills/workit-retro/SKILL.md +72 -0
- package/skills/workit-retro/agents/openai.yaml +3 -0
- package/skills/workit-retro/references/sources.md +35 -0
- package/skills/workit-retro/references/steering.md +39 -0
- package/skills/workit-review/SKILL.md +66 -0
- package/skills/workit-review/references/impact.md +24 -0
- package/skills/workit-shape/SKILL.md +71 -0
- package/skills/workit-shape/references/diagrams.md +17 -0
- package/skills/workit-shape/references/knowledge.md +61 -0
- package/skills/workit-shape/references/mockups.md +15 -0
- package/skills/workit-shape/references/slicing.md +44 -0
- package/skills/workit-ship/SKILL.md +71 -0
- package/skills/workit-ship/references/changelog.md +44 -0
- package/skills/workit-ship/references/pr-body.md +32 -0
- package/skills/workit-ship/references/release.md +39 -0
- package/skills/workit-test-audit/SKILL.md +54 -0
- package/skills/workit-verify-app/SKILL.md +68 -0
- package/skills/workit-verify-app/references/template.md +51 -0
- package/src/autonomy.ts +910 -0
- package/src/check-config.ts +613 -0
- package/src/checks.ts +534 -0
- package/src/ci-waiter.ts +159 -0
- package/src/core/boundary.ts +55 -0
- package/src/core/branch-policy.ts +112 -0
- package/src/core/branch.ts +1192 -0
- package/src/core/changelog-gate.ts +349 -0
- package/src/core/changelog-merge.ts +214 -0
- package/src/core/changelog.ts +392 -0
- package/src/core/cleanup.ts +638 -0
- package/src/core/cli-locate.ts +173 -0
- package/src/core/commit-flavors.ts +65 -0
- package/src/core/config-schemas.ts +653 -0
- package/src/core/config.ts +351 -0
- package/src/core/context-read.ts +144 -0
- package/src/core/docs-gate.ts +69 -0
- package/src/core/finding-evidence.ts +89 -0
- package/src/core/finding-queue.ts +36 -0
- package/src/core/git.ts +73 -0
- package/src/core/gitignore.ts +52 -0
- package/src/core/heavy-eta.ts +170 -0
- package/src/core/heavy-lease.ts +290 -0
- package/src/core/heavy-rules.ts +373 -0
- package/src/core/heavy-scope.ts +131 -0
- package/src/core/heavy-words.ts +83 -0
- package/src/core/heavy.ts +535 -0
- package/src/core/hygiene.ts +149 -0
- package/src/core/init.ts +218 -0
- package/src/core/live-session.ts +333 -0
- package/src/core/logger.ts +310 -0
- package/src/core/methods.ts +185 -0
- package/src/core/model-fallback.ts +208 -0
- package/src/core/node-resolve.mjs +249 -0
- package/src/core/operation-input.ts +632 -0
- package/src/core/package-root.ts +28 -0
- package/src/core/policy/attributes.ts +97 -0
- package/src/core/policy/bookkeeping.ts +119 -0
- package/src/core/policy/carry.ts +122 -0
- package/src/core/policy/check-evidence.ts +115 -0
- package/src/core/policy/current-judgment.ts +20 -0
- package/src/core/policy/derive.ts +387 -0
- package/src/core/policy/judgment.ts +246 -0
- package/src/core/policy/lane.ts +562 -0
- package/src/core/policy/pr-lane.ts +32 -0
- package/src/core/policy/substance.ts +365 -0
- package/src/core/policy-resolver.ts +94 -0
- package/src/core/pr-create.ts +448 -0
- package/src/core/pr-template.ts +162 -0
- package/src/core/release-notes.ts +197 -0
- package/src/core/release-tracks.ts +779 -0
- package/src/core/remotes.ts +436 -0
- package/src/core/repo-context.ts +542 -0
- package/src/core/repo-paths.ts +29 -0
- package/src/core/resources.ts +1299 -0
- package/src/core/role-models.ts +603 -0
- package/src/core/route-intent.ts +305 -0
- package/src/core/rules.ts +65 -0
- package/src/core/runtime-identity.ts +63 -0
- package/src/core/safe-write.ts +87 -0
- package/src/core/scope-glob.ts +56 -0
- package/src/core/scripts.ts +6 -0
- package/src/core/skill-manifests.ts +173 -0
- package/src/core/store-lock.ts +374 -0
- package/src/core/support-matrix.ts +24 -0
- package/src/core/task-context.ts +247 -0
- package/src/core/task-contract.ts +1279 -0
- package/src/core/task-delivery.ts +109 -0
- package/src/core/task-engine.ts +2248 -0
- package/src/core/task-evaluation.ts +1147 -0
- package/src/core/task-store.ts +2649 -0
- package/src/core/task-view.ts +180 -0
- package/src/core/templates.ts +22 -0
- package/src/core/tracker-issues.ts +174 -0
- package/src/core/vcs-config.ts +540 -0
- package/src/core/workers.ts +62 -0
- package/src/core/workit-paths.mjs +242 -0
- package/src/core/workspace-branch-policy.ts +123 -0
- package/src/core/workspaces.ts +1745 -0
- package/src/core/youtrack-backend.ts +288 -0
- package/src/core/youtrack-tools.ts +260 -0
- package/src/core/youtrack-writes.ts +73 -0
- package/src/core/youtrack-ytrack.ts +217 -0
- package/src/core/youtrack.ts +454 -0
- package/src/core/ytrack-skill.ts +297 -0
- package/src/core.ts +171 -0
- package/src/deslop/lex.ts +271 -0
- package/src/deslop/scan.ts +232 -0
- package/src/duration.ts +27 -0
- package/src/fanout-brief.ts +486 -0
- package/src/fanout-check.ts +455 -0
- package/src/fanout-landed.ts +370 -0
- package/src/fanout-stack.ts +108 -0
- package/src/fanout-status.ts +362 -0
- package/src/fanout-worktree.ts +893 -0
- package/src/fanout.ts +1473 -0
- package/src/forge/exec.ts +385 -0
- package/src/forge/github.ts +831 -0
- package/src/forge/gitlab.ts +906 -0
- package/src/forge/issue-forge.ts +209 -0
- package/src/forge/issue-link.ts +468 -0
- package/src/forge/issue-ops.ts +314 -0
- package/src/forge/merge-gate-base-red.ts +72 -0
- package/src/forge/pr-body.ts +100 -0
- package/src/forge/pr-edit.ts +292 -0
- package/src/forge/pr-ops.ts +1109 -0
- package/src/forge/pr-status.ts +10 -0
- package/src/forge/redact.ts +195 -0
- package/src/forge/report.ts +994 -0
- package/src/forge/reruns.ts +132 -0
- package/src/forge/resolve.ts +509 -0
- package/src/forge/types.ts +242 -0
- package/src/forge/verify.ts +398 -0
- package/src/git/default-base.ts +130 -0
- package/src/git/env-redirect.ts +28 -0
- package/src/git/facts.ts +122 -0
- package/src/git/ops.ts +1823 -0
- package/src/git/refs-read.ts +332 -0
- package/src/git/release.ts +1020 -0
- package/src/git/rev.ts +1175 -0
- package/src/git/session-hook.ts +597 -0
- package/src/git/stage.ts +63 -0
- package/src/hooks/agent-model.ts +149 -0
- package/src/hooks/budget-signal.ts +284 -0
- package/src/hooks/context.ts +461 -0
- package/src/hooks/cwd-normalize.ts +79 -0
- package/src/hooks/deadline.ts +228 -0
- package/src/hooks/delivery-verbs.ts +182 -0
- package/src/hooks/descriptor.ts +124 -0
- package/src/hooks/handle.ts +433 -0
- package/src/hooks/heavy-gate.ts +99 -0
- package/src/hooks/hook-runs.ts +130 -0
- package/src/hooks/hosts/claude-code.ts +380 -0
- package/src/hooks/hosts/codex.ts +372 -0
- package/src/hooks/hosts/cursor.ts +419 -0
- package/src/hooks/hosts/fields.ts +98 -0
- package/src/hooks/hosts/opencode.ts +79 -0
- package/src/hooks/hosts/pi.ts +78 -0
- package/src/hooks/index.ts +60 -0
- package/src/hooks/model-limit.ts +378 -0
- package/src/hooks/model-switch.ts +35 -0
- package/src/hooks/policy.ts +29 -0
- package/src/hooks/process.ts +48 -0
- package/src/hooks/protocol.ts +135 -0
- package/src/hooks/raw-git.ts +2289 -0
- package/src/hooks/repeat-failure.ts +115 -0
- package/src/hooks/run.ts +54 -0
- package/src/hooks/session-marker.ts +151 -0
- package/src/hooks/shell-words.ts +532 -0
- package/src/hooks/skill-nudge.ts +295 -0
- package/src/hooks/spawn-gate.ts +72 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +225 -0
- package/src/hooks/steering.ts +74 -0
- package/src/hooks/stop.ts +408 -0
- package/src/hooks/worktree-confine.ts +415 -0
- package/src/hooks/write-gate.ts +1174 -0
- package/src/host-session.ts +459 -0
- package/src/knowledge.ts +368 -0
- package/src/ledger.ts +2913 -0
- package/src/stack.ts +2570 -0
- package/src/store/event-log.ts +217 -0
- package/src/store/notes.ts +29 -0
- package/src/store/patch.ts +135 -0
- package/src/store/paths.ts +368 -0
- package/src/store/reduce.ts +203 -0
- package/src/test-audit-mocks.ts +309 -0
- package/src/verdict-cover.ts +151 -0
- package/src/worktree-deps.ts +142 -0
- package/templates/headers.md +3 -0
- package/templates/hygiene/.editorconfig +8 -0
- package/templates/hygiene/.gitattributes +3 -0
- package/templates/hygiene/CHANGELOG.md +14 -0
- package/templates/hygiene/CONTRIBUTING.md +3 -0
- package/templates/hygiene/LICENSE +21 -0
- package/templates/hygiene/README.md +3 -0
- package/templates/issue-update.md +5 -0
- package/templates/plan-template.md +26 -0
- package/templates/spec-template.md +64 -0
- package/templates/workit-contract.md +13 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// The read side of a task operation: the context guards, the task view and the
|
|
2
|
+
// compact per-turn context. Hooks import this, not task-engine.ts, so a hook
|
|
3
|
+
// bundle that only reads task context carries none of the recording engine.
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import {
|
|
6
|
+
failure,
|
|
7
|
+
success,
|
|
8
|
+
type Candidate,
|
|
9
|
+
type Capability,
|
|
10
|
+
type Caller,
|
|
11
|
+
type Constraint,
|
|
12
|
+
type Entry,
|
|
13
|
+
type Result,
|
|
14
|
+
type TaskRecord,
|
|
15
|
+
type TaskView,
|
|
16
|
+
type Utc,
|
|
17
|
+
type Worker,
|
|
18
|
+
} from "./task-contract";
|
|
19
|
+
import { checkoutRootOf } from "../store/paths";
|
|
20
|
+
import { sameDirectoryIdentity, type TaskStore } from "./task-store";
|
|
21
|
+
import { compactTaskContext, type InheritedDecisions } from "./task-context";
|
|
22
|
+
import type { NativeWorkerVerifier } from "./workers";
|
|
23
|
+
import {
|
|
24
|
+
captureCandidate,
|
|
25
|
+
evaluateEvidence,
|
|
26
|
+
evaluateRequirements,
|
|
27
|
+
signalFreshness,
|
|
28
|
+
treeFreshness,
|
|
29
|
+
type CandidateEnvironment,
|
|
30
|
+
type Freshness,
|
|
31
|
+
} from "./task-evaluation";
|
|
32
|
+
|
|
33
|
+
export type OperationContext = {
|
|
34
|
+
root: string;
|
|
35
|
+
caller: Caller;
|
|
36
|
+
/** Whether the host supplied a trusted per-session caller identity. */
|
|
37
|
+
callerAttested?: boolean;
|
|
38
|
+
provenanceKind?: "host_observed" | "agent_reported";
|
|
39
|
+
capabilities: Capability[];
|
|
40
|
+
constraints: Constraint[];
|
|
41
|
+
now: Utc | (() => Utc);
|
|
42
|
+
nativeWorker?: NativeWorkerVerifier;
|
|
43
|
+
workerId?: string | null;
|
|
44
|
+
/** A child session with no worker record: it may only read the bounded task summary. */
|
|
45
|
+
readOnlyChild?: boolean;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export const environment = (): CandidateEnvironment => [];
|
|
49
|
+
|
|
50
|
+
export const sameSession = (value: unknown, context: OperationContext): boolean =>
|
|
51
|
+
typeof value === "object" &&
|
|
52
|
+
value !== null &&
|
|
53
|
+
(value as any).kind === "host" &&
|
|
54
|
+
(value as any).host === context.caller.host &&
|
|
55
|
+
(value as any).handle === context.caller.actor;
|
|
56
|
+
|
|
57
|
+
export function contextRootError(store: TaskStore, context: OperationContext): Result<null> {
|
|
58
|
+
try {
|
|
59
|
+
// The same checkout: any directory inside it resolves to its top level.
|
|
60
|
+
realpathSync(context.root);
|
|
61
|
+
const root = checkoutRootOf(context.root);
|
|
62
|
+
if (root !== store.root && !sameDirectoryIdentity(root, store.root))
|
|
63
|
+
return failure("invalid_input", "operation context root does not match the task store root");
|
|
64
|
+
} catch {
|
|
65
|
+
return failure("invalid_input", "operation context root cannot be resolved");
|
|
66
|
+
}
|
|
67
|
+
return success(null, null, null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function helperEntry(
|
|
71
|
+
task: TaskRecord,
|
|
72
|
+
context: OperationContext,
|
|
73
|
+
requireSession = false,
|
|
74
|
+
requireActive = false,
|
|
75
|
+
): Result<Entry<Worker>> {
|
|
76
|
+
const workerId = context.workerId ?? null;
|
|
77
|
+
if (workerId === null) return failure("permission_denied", "operation is lead-only");
|
|
78
|
+
const worker = task.workers.find((entry) => entry.id === workerId);
|
|
79
|
+
if (!worker) return failure("permission_denied", "worker is not assigned to this task");
|
|
80
|
+
if (worker.data.session && !sameSession(worker.data.session, context))
|
|
81
|
+
return failure("permission_denied", "worker session does not match the caller");
|
|
82
|
+
if (requireSession && (!worker.data.session || !sameSession(worker.data.session, context)))
|
|
83
|
+
return failure("permission_denied", "worker session has not been observed");
|
|
84
|
+
if (requireActive && worker.data.state !== "running")
|
|
85
|
+
return failure("permission_denied", "worker is not active");
|
|
86
|
+
return success(null, null, worker);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function helperTaskGuard(
|
|
90
|
+
task: TaskRecord,
|
|
91
|
+
context: OperationContext,
|
|
92
|
+
allowRead = false,
|
|
93
|
+
): Result<null> {
|
|
94
|
+
if ((context.workerId ?? null) === null) return success(null, null, null);
|
|
95
|
+
const worker = helperEntry(task, context);
|
|
96
|
+
if (!worker.ok) return worker;
|
|
97
|
+
return allowRead
|
|
98
|
+
? success(null, null, null)
|
|
99
|
+
: failure("permission_denied", "helpers cannot control task lifecycle or scope");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function taskView(
|
|
103
|
+
store: TaskStore,
|
|
104
|
+
context: OperationContext,
|
|
105
|
+
task: TaskRecord,
|
|
106
|
+
capture = true,
|
|
107
|
+
freshness: Freshness = treeFreshness(store.root),
|
|
108
|
+
): Result<TaskView> {
|
|
109
|
+
const workspace = store.readWorkspace();
|
|
110
|
+
if (!workspace.ok) return workspace;
|
|
111
|
+
if (!workspace.data) return failure("not_found", "workspace not found");
|
|
112
|
+
const historical = task.status === "closed" ? task.candidates.at(-1) : undefined;
|
|
113
|
+
const current = historical
|
|
114
|
+
? success(null, null, historical)
|
|
115
|
+
: capture
|
|
116
|
+
? captureCandidate(store.root, task.intent.data.scope, environment())
|
|
117
|
+
: success<Candidate | null>(null, null, task.candidates.at(-1) ?? null);
|
|
118
|
+
if (!current.ok) return current;
|
|
119
|
+
const evaluationWorkspace = workspace.data;
|
|
120
|
+
const tree = freshness;
|
|
121
|
+
const requirements = evaluateRequirements(
|
|
122
|
+
task,
|
|
123
|
+
evaluationWorkspace,
|
|
124
|
+
context.capabilities,
|
|
125
|
+
current.data,
|
|
126
|
+
store.root,
|
|
127
|
+
context.caller,
|
|
128
|
+
tree,
|
|
129
|
+
);
|
|
130
|
+
return success(task.revision, workspace.data.revision, {
|
|
131
|
+
task,
|
|
132
|
+
workspace: evaluationWorkspace,
|
|
133
|
+
capabilities: context.capabilities,
|
|
134
|
+
requirements,
|
|
135
|
+
evidence: evaluateEvidence(task, current.data, tree),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Compact task context for per-turn host injection. Unlike `inspect`
|
|
141
|
+
* view:"full", it never captures a candidate: evidence and requirements are
|
|
142
|
+
* judged against the last recorded candidate, and live staleness is
|
|
143
|
+
* detected by evidence, close, resume, and full inspection, which capture.
|
|
144
|
+
*/
|
|
145
|
+
export function compactContext(
|
|
146
|
+
store: TaskStore,
|
|
147
|
+
context: OperationContext,
|
|
148
|
+
taskId: string | null,
|
|
149
|
+
freshness?: Freshness,
|
|
150
|
+
/** The base branch's task (TaskStore.originTaskEntry), whose decisions are inherited. */
|
|
151
|
+
origin: { origin: string; taskId: string } | null = null,
|
|
152
|
+
): Result<string> {
|
|
153
|
+
const root = contextRootError(store, context);
|
|
154
|
+
if (!root.ok) return root;
|
|
155
|
+
const base = origin && store.readStoreTask(origin.taskId);
|
|
156
|
+
const inherited: InheritedDecisions | null =
|
|
157
|
+
base?.ok && base.data
|
|
158
|
+
? {
|
|
159
|
+
origin: origin!.origin,
|
|
160
|
+
taskId: base.data.id,
|
|
161
|
+
// A revoked decision no longer applies to anything cut from the base.
|
|
162
|
+
decisions: base.data.decisions.filter((entry) => entry.data.revoked === null),
|
|
163
|
+
}
|
|
164
|
+
: null;
|
|
165
|
+
// No task of its own yet: only what the branch inherits.
|
|
166
|
+
if (taskId === null)
|
|
167
|
+
return success(
|
|
168
|
+
null,
|
|
169
|
+
null,
|
|
170
|
+
inherited?.decisions.length ? compactTaskContext(null, inherited) : "",
|
|
171
|
+
);
|
|
172
|
+
const task = store.readTask(taskId);
|
|
173
|
+
if (!task.ok) return task;
|
|
174
|
+
const helper = helperTaskGuard(task.data, context, true);
|
|
175
|
+
if (!helper.ok) return helper;
|
|
176
|
+
// Per-turn path: the cheap worktree signal, never the object-writing tree hash.
|
|
177
|
+
const view = taskView(store, context, task.data, false, freshness ?? signalFreshness(store.root));
|
|
178
|
+
if (!view.ok) return view;
|
|
179
|
+
return success(view.revision, view.workspaceRevision, compactTaskContext(view.data, inherited));
|
|
180
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { configDir } from "./config";
|
|
4
|
+
import { assetRoot } from "./package-root";
|
|
5
|
+
|
|
6
|
+
export type TemplateName = "issue-update" | "headers";
|
|
7
|
+
|
|
8
|
+
const repoRoot = assetRoot();
|
|
9
|
+
|
|
10
|
+
export const templatePath = (name: TemplateName): string =>
|
|
11
|
+
path.join(configDir(), "templates", `${name}.md`);
|
|
12
|
+
|
|
13
|
+
export const readTemplate = (
|
|
14
|
+
name: TemplateName,
|
|
15
|
+
): { source: "config" | "repo"; content: string } => {
|
|
16
|
+
const cfg = templatePath(name);
|
|
17
|
+
if (existsSync(cfg)) return { source: "config", content: readFileSync(cfg, "utf8") };
|
|
18
|
+
return {
|
|
19
|
+
source: "repo",
|
|
20
|
+
content: readFileSync(path.join(repoRoot, "templates", `${name}.md`), "utf8"),
|
|
21
|
+
};
|
|
22
|
+
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { hostingCliAvailable, parseGhIssue, pushRemote } from "./pr-create";
|
|
3
|
+
import { hostingApiHostMatches, vcsConfig } from "./vcs-config";
|
|
4
|
+
|
|
5
|
+
// Read-only issue fetches for context.read (GitHub + GitLab), mirroring the
|
|
6
|
+
// YouTrack triple shape. Authenticated gh/glab serves the read in the target checkout.
|
|
7
|
+
|
|
8
|
+
export type TrackerIssueBody = {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
body: string | null;
|
|
12
|
+
state: string | null;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type TrackerIssueFailure = {
|
|
16
|
+
error: string;
|
|
17
|
+
kind: "creds" | "request" | "input";
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Full repo path from an origin URL (scp-style, https, or bare); subgroups kept. */
|
|
21
|
+
export const repoPathFromRemote = (remote: string): string | null => {
|
|
22
|
+
let rest = (remote || "").trim().replace(/\/+$/, "");
|
|
23
|
+
if (!rest) return null;
|
|
24
|
+
if (rest.endsWith(".git")) rest = rest.slice(0, -4);
|
|
25
|
+
const scp = /^[^@/]+@[^:]+:(.+)$/.exec(rest);
|
|
26
|
+
if (scp) return scp[1].replace(/^\//, "") || null;
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(rest.includes("://") ? rest : `https://${rest}`);
|
|
29
|
+
return url.pathname.replace(/^\//, "") || null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type TrackerCli = (
|
|
36
|
+
args: string[],
|
|
37
|
+
root: string,
|
|
38
|
+
) => Promise<{ status: number; stdout: string; stderr: string }> | null;
|
|
39
|
+
|
|
40
|
+
export type TrackerDeps = {
|
|
41
|
+
remote?: (root: string) => string | null;
|
|
42
|
+
cli?: TrackerCli;
|
|
43
|
+
host?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/** Native CLI runner (gh/glab) when installed; null when absent. */
|
|
47
|
+
const defaultCli = (bin: string, available: boolean): TrackerCli | null => {
|
|
48
|
+
if (!available) return null;
|
|
49
|
+
return (args, root) => {
|
|
50
|
+
const out = spawnSync(bin, args, {
|
|
51
|
+
cwd: root,
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
env: { ...process.env, PATH: process.env.PATH ?? "" },
|
|
54
|
+
});
|
|
55
|
+
return Promise.resolve({
|
|
56
|
+
status: out.status ?? 1,
|
|
57
|
+
stdout: out.stdout ?? "",
|
|
58
|
+
stderr: out.error?.message ?? out.stderr ?? "",
|
|
59
|
+
});
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const tryCliTriple = async (
|
|
64
|
+
cli: TrackerCli | null | undefined,
|
|
65
|
+
name: string,
|
|
66
|
+
args: string[],
|
|
67
|
+
root: string,
|
|
68
|
+
id: string,
|
|
69
|
+
idKeys: string[],
|
|
70
|
+
bodyKeys: string[],
|
|
71
|
+
): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
|
|
72
|
+
if (!cli) return { error: `${name} CLI is unavailable`, kind: "creds" };
|
|
73
|
+
const run = cli(args, root);
|
|
74
|
+
if (!run) return { error: `${name} CLI is unavailable`, kind: "creds" };
|
|
75
|
+
const out = await run;
|
|
76
|
+
if (out.status !== 0)
|
|
77
|
+
return { error: `${name} CLI is not authenticated or the issue is unavailable`, kind: "creds" };
|
|
78
|
+
try {
|
|
79
|
+
const triple = parseTriple(
|
|
80
|
+
JSON.parse(out.stdout) as Record<string, unknown>,
|
|
81
|
+
id,
|
|
82
|
+
idKeys,
|
|
83
|
+
bodyKeys,
|
|
84
|
+
);
|
|
85
|
+
return triple
|
|
86
|
+
? { data: triple }
|
|
87
|
+
: { error: `unexpected ${name} issue response`, kind: "request" };
|
|
88
|
+
} catch {
|
|
89
|
+
return { error: `invalid ${name} issue response`, kind: "request" };
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const parseTriple = (
|
|
94
|
+
parsed: Record<string, unknown>,
|
|
95
|
+
id: string,
|
|
96
|
+
idKeys: string[],
|
|
97
|
+
bodyKeys: string[],
|
|
98
|
+
): TrackerIssueBody | null => {
|
|
99
|
+
const title = parsed.title;
|
|
100
|
+
if (typeof title !== "string") return null;
|
|
101
|
+
const pick = (keys: string[]): string | null => {
|
|
102
|
+
for (const key of keys) {
|
|
103
|
+
const value = parsed[key];
|
|
104
|
+
if (typeof value === "string") return value;
|
|
105
|
+
if (typeof value === "number") return String(value);
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
};
|
|
109
|
+
const foundId = pick(idKeys) ?? id;
|
|
110
|
+
const state = typeof parsed.state === "string" ? parsed.state : null;
|
|
111
|
+
return { id: foundId, title, body: pick(bodyKeys), state };
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** Fetch a GitHub issue triple for context.read through the authenticated CLI. */
|
|
115
|
+
export const fetchGitHubIssueBody = async (
|
|
116
|
+
ref: string,
|
|
117
|
+
root: string,
|
|
118
|
+
deps: TrackerDeps = {},
|
|
119
|
+
): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
|
|
120
|
+
const id = parseGhIssue(ref);
|
|
121
|
+
if (!/^\d+$/.test(id)) return { error: `invalid GitHub issue ref "${ref}"`, kind: "input" };
|
|
122
|
+
const remote = (deps.remote ?? pushRemote)(root);
|
|
123
|
+
const repo = repoPathFromRemote(remote ?? "");
|
|
124
|
+
if (!repo) return { error: "no origin remote to resolve the repository", kind: "input" };
|
|
125
|
+
const cfg = vcsConfig("load", root);
|
|
126
|
+
const host = (
|
|
127
|
+
deps.host ??
|
|
128
|
+
(cfg.ok && cfg.provider === "github" ? cfg.github?.host : null) ??
|
|
129
|
+
"github.com"
|
|
130
|
+
).toLowerCase();
|
|
131
|
+
if (!remote || !hostingApiHostMatches(remote, host))
|
|
132
|
+
return { error: "hosting API host does not match the push destination", kind: "input" };
|
|
133
|
+
const repoArg = host === "github.com" ? repo : `${host}/${repo}`;
|
|
134
|
+
return tryCliTriple(
|
|
135
|
+
deps.cli ?? defaultCli("gh", hostingCliAvailable("github")),
|
|
136
|
+
"gh",
|
|
137
|
+
["issue", "view", id, "--repo", repoArg, "--json", "number,title,body,state"],
|
|
138
|
+
root,
|
|
139
|
+
id,
|
|
140
|
+
["number"],
|
|
141
|
+
["body"],
|
|
142
|
+
);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/** Fetch a GitLab issue triple for context.read through the authenticated CLI. */
|
|
146
|
+
export const fetchGitLabIssueBody = async (
|
|
147
|
+
ref: string,
|
|
148
|
+
root: string,
|
|
149
|
+
deps: TrackerDeps = {},
|
|
150
|
+
): Promise<{ data: TrackerIssueBody } | TrackerIssueFailure> => {
|
|
151
|
+
const id = parseGhIssue(ref);
|
|
152
|
+
if (!/^\d+$/.test(id)) return { error: `invalid GitLab issue ref "${ref}"`, kind: "input" };
|
|
153
|
+
const remote = (deps.remote ?? pushRemote)(root);
|
|
154
|
+
const repo = repoPathFromRemote(remote ?? "");
|
|
155
|
+
if (!repo) return { error: "no origin remote to resolve the project", kind: "input" };
|
|
156
|
+
const cfg = vcsConfig("load", root);
|
|
157
|
+
const host = (
|
|
158
|
+
deps.host ??
|
|
159
|
+
(cfg.ok && cfg.provider === "gitlab" ? cfg.gitlab?.host : null) ??
|
|
160
|
+
"gitlab.com"
|
|
161
|
+
).toLowerCase();
|
|
162
|
+
if (!remote || !hostingApiHostMatches(remote, host))
|
|
163
|
+
return { error: "hosting API host does not match the push destination", kind: "input" };
|
|
164
|
+
const repoArg = host === "gitlab.com" ? repo : `https://${host}/${repo}`;
|
|
165
|
+
return tryCliTriple(
|
|
166
|
+
deps.cli ?? defaultCli("glab", hostingCliAvailable("gitlab")),
|
|
167
|
+
"glab",
|
|
168
|
+
["issue", "view", id, "-R", repoArg, "-F", "json"],
|
|
169
|
+
root,
|
|
170
|
+
id,
|
|
171
|
+
["iid"],
|
|
172
|
+
["description"],
|
|
173
|
+
);
|
|
174
|
+
};
|