@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,217 @@
|
|
|
1
|
+
// The `ytrack` executable (BrainerVirus/youtrack-cli, #263): found on PATH,
|
|
2
|
+
// run without a shell, never handed a token (it keeps its own in the OS
|
|
3
|
+
// keyring). Workit reaches YouTrack only through it.
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { findExecutable } from "../forge/exec";
|
|
6
|
+
|
|
7
|
+
export type YtrackResult = {
|
|
8
|
+
status: number | null;
|
|
9
|
+
stdout: string;
|
|
10
|
+
stderr: string;
|
|
11
|
+
missing?: boolean;
|
|
12
|
+
};
|
|
13
|
+
export type YtrackRun = (args: string[], input?: string) => YtrackResult;
|
|
14
|
+
|
|
15
|
+
const TIMEOUT_MS = 60_000;
|
|
16
|
+
|
|
17
|
+
/** ytrack's exit codes: 0 ok, 1 failure, 2 cancelled, 4 authentication required. */
|
|
18
|
+
export const YTRACK_EXIT = { ok: 0, failed: 1, cancelled: 2, auth: 4 } as const;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* ytrack on `env`'s PATH, unless `WORKIT_NO_YTRACK=1`. The test preload
|
|
22
|
+
* (test/shared/state-isolation.ts) sets it for the whole run: a developer's
|
|
23
|
+
* real ytrack is logged in to a real instance (maybe the work one), so a test
|
|
24
|
+
* reaches ytrack only through a stub PATH in an env it builds itself.
|
|
25
|
+
*/
|
|
26
|
+
export const findYtrack = (env: NodeJS.ProcessEnv): string | null =>
|
|
27
|
+
env.WORKIT_NO_YTRACK === "1" ? null : findExecutable("ytrack", env);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Run ytrack from `env`'s PATH. `host` (youtrack.json's baseUrl, when set)
|
|
31
|
+
* goes in YTRACK_HOST so ytrack talks to the instance Workit is configured
|
|
32
|
+
* for; Workit adds no token to argv or env.
|
|
33
|
+
*/
|
|
34
|
+
export function ytrackRunner(
|
|
35
|
+
env: NodeJS.ProcessEnv,
|
|
36
|
+
host?: string | null,
|
|
37
|
+
timeoutMs: number = TIMEOUT_MS,
|
|
38
|
+
): YtrackRun {
|
|
39
|
+
return (args, input) => {
|
|
40
|
+
const exe = findYtrack(env);
|
|
41
|
+
if (!exe) return { status: null, stdout: "", stderr: "", missing: true };
|
|
42
|
+
const result = spawnSync(exe, args, {
|
|
43
|
+
encoding: "utf8",
|
|
44
|
+
env: { ...env, NO_COLOR: "1", ...(host ? { YTRACK_HOST: host } : {}) },
|
|
45
|
+
timeout: timeoutMs,
|
|
46
|
+
killSignal: "SIGKILL",
|
|
47
|
+
windowsHide: true,
|
|
48
|
+
...(input === undefined
|
|
49
|
+
? { stdio: ["ignore", "pipe", "pipe"] as const }
|
|
50
|
+
: { input, stdio: ["pipe", "pipe", "pipe"] as const }),
|
|
51
|
+
});
|
|
52
|
+
const error: NodeJS.ErrnoException | undefined = result.error;
|
|
53
|
+
if (error?.code === "ENOENT") return { status: null, stdout: "", stderr: "", missing: true };
|
|
54
|
+
return {
|
|
55
|
+
status: result.status,
|
|
56
|
+
stdout: result.stdout ?? "",
|
|
57
|
+
stderr: result.stderr || (error ? error.message : ""),
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The last lines of ytrack's stderr, for an error message. */
|
|
63
|
+
export const ytrackDetail = (out: YtrackResult): string =>
|
|
64
|
+
out.stderr.trim().split("\n").slice(-3).join(" ").slice(0, 300);
|
|
65
|
+
|
|
66
|
+
export type YtrackHostStatus = {
|
|
67
|
+
host: string;
|
|
68
|
+
active: boolean;
|
|
69
|
+
login: string | null;
|
|
70
|
+
state: string;
|
|
71
|
+
tokenSource: string | null;
|
|
72
|
+
error: string | null;
|
|
73
|
+
};
|
|
74
|
+
export type YtrackStatus =
|
|
75
|
+
| { installed: false }
|
|
76
|
+
| {
|
|
77
|
+
installed: true;
|
|
78
|
+
version: string | null;
|
|
79
|
+
/** ok: the active host accepted the token; logged_out: exit 4 or no host. */
|
|
80
|
+
auth: YtrackAuth;
|
|
81
|
+
hosts: YtrackHostStatus[];
|
|
82
|
+
error: string | null;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const STATUS_FIELDS = "host,active,login,state,tokenSource,error";
|
|
86
|
+
|
|
87
|
+
/** ytrack's version and auth state, for `workit doctor`. Never prints a token. */
|
|
88
|
+
export function ytrackStatus(run: YtrackRun): YtrackStatus {
|
|
89
|
+
const version = run(["version"]);
|
|
90
|
+
if (version.missing) return { installed: false };
|
|
91
|
+
const match = /ytrack version (\S+)/u.exec(version.stdout);
|
|
92
|
+
const status = run(["auth", "status", "--json", STATUS_FIELDS]);
|
|
93
|
+
const base = { installed: true as const, version: match ? match[1] : null };
|
|
94
|
+
if (status.status === YTRACK_EXIT.auth)
|
|
95
|
+
return { ...base, auth: "logged_out", hosts: [], error: ytrackDetail(status) || null };
|
|
96
|
+
let rows: unknown;
|
|
97
|
+
try {
|
|
98
|
+
rows = JSON.parse(status.stdout);
|
|
99
|
+
} catch {
|
|
100
|
+
return { ...base, auth: "error", hosts: [], error: ytrackDetail(status) || "no JSON" };
|
|
101
|
+
}
|
|
102
|
+
const hosts = (Array.isArray(rows) ? rows : [rows])
|
|
103
|
+
.filter((row): row is Record<string, unknown> => typeof row === "object" && row !== null)
|
|
104
|
+
.map((row) => ({
|
|
105
|
+
host: String(row.host ?? ""),
|
|
106
|
+
active: row.active === true,
|
|
107
|
+
login: typeof row.login === "string" && row.login ? row.login : null,
|
|
108
|
+
state: String(row.state ?? "error"),
|
|
109
|
+
tokenSource: typeof row.tokenSource === "string" && row.tokenSource ? row.tokenSource : null,
|
|
110
|
+
error: typeof row.error === "string" && row.error ? row.error : null,
|
|
111
|
+
}));
|
|
112
|
+
const active = hosts.find((row) => row.active) ?? hosts[0];
|
|
113
|
+
return { ...base, auth: rowAuth(active), hosts, error: null };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
type YtrackAuth = "ok" | "logged_out" | "rejected" | "error";
|
|
117
|
+
|
|
118
|
+
const rowAuth = (row: YtrackHostStatus | undefined): YtrackAuth =>
|
|
119
|
+
!row
|
|
120
|
+
? "logged_out"
|
|
121
|
+
: row.state === "ok"
|
|
122
|
+
? "ok"
|
|
123
|
+
: row.state === "rejected"
|
|
124
|
+
? "rejected"
|
|
125
|
+
: row.state === "no_token"
|
|
126
|
+
? "logged_out"
|
|
127
|
+
: "error";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A YouTrack URL or host as ytrack names it (`ytrack auth login --host`):
|
|
131
|
+
* the URL's host, lower case, without scheme, path or trailing slash.
|
|
132
|
+
*/
|
|
133
|
+
export const ytrackHostName = (urlOrHost: string): string =>
|
|
134
|
+
urlOrHost
|
|
135
|
+
.trim()
|
|
136
|
+
.replace(/^[a-z][a-z0-9+.-]*:\/\//iu, "")
|
|
137
|
+
.split("/")[0]
|
|
138
|
+
.toLowerCase();
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The auth state ytrack reports for `host` (a youtrack.json or workspace
|
|
142
|
+
* baseUrl); with no host, the active one's. A host ytrack has no login for is
|
|
143
|
+
* logged_out.
|
|
144
|
+
*/
|
|
145
|
+
export function ytrackHostAuth(
|
|
146
|
+
status: Extract<YtrackStatus, { installed: true }>,
|
|
147
|
+
host: string | null,
|
|
148
|
+
): { auth: YtrackAuth; login: string | null; error: string | null } {
|
|
149
|
+
if (!host) {
|
|
150
|
+
const active = status.hosts.find((row) => row.active) ?? status.hosts[0];
|
|
151
|
+
return {
|
|
152
|
+
auth: status.auth,
|
|
153
|
+
login: active?.login ?? null,
|
|
154
|
+
error: active?.error ?? status.error,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
const name = ytrackHostName(host);
|
|
158
|
+
const row = status.hosts.find((candidate) => ytrackHostName(candidate.host) === name);
|
|
159
|
+
return {
|
|
160
|
+
auth: status.auth === "error" && !row ? "error" : rowAuth(row),
|
|
161
|
+
login: row?.login ?? null,
|
|
162
|
+
error: row?.error ?? status.error,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Where ytrack comes from: Workit reaches YouTrack only through it. */
|
|
167
|
+
export const YTRACK_INSTALL =
|
|
168
|
+
"install ytrack (https://github.com/BrainerVirus/youtrack-cli: install.sh, or install.ps1 on Windows)";
|
|
169
|
+
|
|
170
|
+
/** `ytrack auth login`, aimed at `host` (a URL or host name) when one is known. */
|
|
171
|
+
export const ytrackLogin = (host?: string | null): string =>
|
|
172
|
+
`ytrack auth login${host ? ` --host ${ytrackHostName(host)}` : ""}`;
|
|
173
|
+
|
|
174
|
+
/** The unblock for a missing ytrack: install it, then log in to `host`. */
|
|
175
|
+
export const ytrackMissingUnblock = (host?: string | null): string =>
|
|
176
|
+
`${YTRACK_INSTALL}, then ${ytrackLogin(host)}`;
|
|
177
|
+
|
|
178
|
+
export const YTRACK_MISSING = "ytrack is not on PATH: Workit reaches YouTrack only through ytrack";
|
|
179
|
+
|
|
180
|
+
export type YtrackReadiness =
|
|
181
|
+
| { ok: true; version: string | null; login: string | null }
|
|
182
|
+
| {
|
|
183
|
+
ok: false;
|
|
184
|
+
reason: "missing" | "logged_out" | "rejected" | "error";
|
|
185
|
+
error: string;
|
|
186
|
+
unblock: string;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Whether ytrack can reach `host` (youtrack.json's baseUrl; null for its
|
|
191
|
+
* active host): installed and logged in, else the reason and the one command
|
|
192
|
+
* that unblocks it. Never reads or prints a token.
|
|
193
|
+
*/
|
|
194
|
+
export function ytrackReadiness(run: YtrackRun, host: string | null): YtrackReadiness {
|
|
195
|
+
const status = ytrackStatus(run);
|
|
196
|
+
if (!status.installed)
|
|
197
|
+
return {
|
|
198
|
+
ok: false,
|
|
199
|
+
reason: "missing",
|
|
200
|
+
error: YTRACK_MISSING,
|
|
201
|
+
unblock: ytrackMissingUnblock(host),
|
|
202
|
+
};
|
|
203
|
+
const auth = ytrackHostAuth(status, host);
|
|
204
|
+
const where = host ? ytrackHostName(host) : "the active host";
|
|
205
|
+
if (auth.auth === "ok") return { ok: true, version: status.version, login: auth.login };
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
reason: auth.auth,
|
|
209
|
+
error:
|
|
210
|
+
auth.auth === "logged_out"
|
|
211
|
+
? `ytrack is not logged in to ${where}`
|
|
212
|
+
: auth.auth === "rejected"
|
|
213
|
+
? `${where} rejected ytrack's stored token`
|
|
214
|
+
: `could not read ytrack's login for ${where}${auth.error ? ` (${auth.error})` : ""}`,
|
|
215
|
+
unblock: ytrackLogin(host),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readTemplate } from "./templates";
|
|
4
|
+
import { resolveWorkspaceRoot } from "./scripts";
|
|
5
|
+
import { configDir, isConfigObject, resolveConfigDir } from "./config";
|
|
6
|
+
import { resolveInside } from "../core";
|
|
7
|
+
import {
|
|
8
|
+
YTRACK_EXIT,
|
|
9
|
+
YTRACK_MISSING,
|
|
10
|
+
ytrackDetail,
|
|
11
|
+
ytrackRunner,
|
|
12
|
+
type YtrackRun,
|
|
13
|
+
} from "./youtrack-ytrack";
|
|
14
|
+
|
|
15
|
+
const ISSUE_RE = /^[A-Z]+-\d+$/;
|
|
16
|
+
|
|
17
|
+
// Port of scripts/youtrack/config.sh chain: WORKIT_YOUTRACK_CONFIG ->
|
|
18
|
+
// configDir() (XDG_CONFIG_HOME / HOME .config + workit).
|
|
19
|
+
export const youTrackConfigPath = (): string =>
|
|
20
|
+
process.env.WORKIT_YOUTRACK_CONFIG ?? path.join(configDir(), "youtrack.json");
|
|
21
|
+
|
|
22
|
+
/** Config path for read-only context: the resolved config dir, no side effects. */
|
|
23
|
+
export const youTrackReadOnlyConfigPath = (): string =>
|
|
24
|
+
process.env.WORKIT_YOUTRACK_CONFIG ?? path.join(resolveConfigDir(), "youtrack.json");
|
|
25
|
+
|
|
26
|
+
const readYouTrackContextConfig = (): { data: Record<string, any> } | { error: string } => {
|
|
27
|
+
const cfgPath = youTrackReadOnlyConfigPath();
|
|
28
|
+
if (!fs.existsSync(cfgPath)) return { data: {} };
|
|
29
|
+
let parsed: unknown;
|
|
30
|
+
try {
|
|
31
|
+
parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return { error: "YouTrack configuration is unavailable" };
|
|
34
|
+
}
|
|
35
|
+
if (!isConfigObject(parsed)) return { error: "YouTrack configuration is unavailable" };
|
|
36
|
+
return { data: readOnlyYouTrackConfig(parsed as Record<string, any>) };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function tzParts(date: Date, tz: string): { y: string; m: string; d: string } {
|
|
40
|
+
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
41
|
+
timeZone: tz,
|
|
42
|
+
year: "numeric",
|
|
43
|
+
month: "2-digit",
|
|
44
|
+
day: "2-digit",
|
|
45
|
+
}).formatToParts(date);
|
|
46
|
+
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
47
|
+
return { y: map.year, m: map.month, d: map.day };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The process timezone (honours TZ). Used when youtrack.json has no explicit
|
|
51
|
+
// `timezone` override; there is no hard-coded default zone.
|
|
52
|
+
const processTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
53
|
+
|
|
54
|
+
export type WorkTimezone = {
|
|
55
|
+
timezone: string;
|
|
56
|
+
source: "youtrack.json" | "process";
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** The timezone that decides a work item's calendar day: an explicit
|
|
60
|
+
* youtrack.json `timezone` wins, otherwise the process timezone (TZ). */
|
|
61
|
+
export const effectiveWorkTimezone = (
|
|
62
|
+
cfg: Record<string, any> | null | undefined,
|
|
63
|
+
): WorkTimezone => {
|
|
64
|
+
const configured = cfg?.timezone;
|
|
65
|
+
return typeof configured === "string" && configured.trim()
|
|
66
|
+
? { timezone: configured.trim(), source: "youtrack.json" }
|
|
67
|
+
: { timezone: processTimezone(), source: "process" };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Neutral default work-item text for meeting time; youtrack.json can set a
|
|
71
|
+
* global `meetingWorkItemText` or a per-meeting `workItemText`. */
|
|
72
|
+
export const DEFAULT_MEETING_WORK_ITEM_TEXT = "Meetings";
|
|
73
|
+
|
|
74
|
+
export const meetingWorkItemText = (
|
|
75
|
+
cfg: Record<string, any> | null | undefined,
|
|
76
|
+
item?: Record<string, any> | null,
|
|
77
|
+
): string => {
|
|
78
|
+
if (typeof item?.workItemText === "string" && item.workItemText) return item.workItemText;
|
|
79
|
+
if (typeof cfg?.meetingWorkItemText === "string" && cfg.meetingWorkItemText)
|
|
80
|
+
return cfg.meetingWorkItemText;
|
|
81
|
+
return DEFAULT_MEETING_WORK_ITEM_TEXT;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// A YouTrack work-item date is the calendar day at UTC midnight. Computing it
|
|
85
|
+
// with Date.UTC keeps it independent of the process timezone (Date.parse of a
|
|
86
|
+
// local "T00:00:00" shifted it a day back east of UTC).
|
|
87
|
+
const utcMidnight = (y: number, m: number, d: number): number | null => {
|
|
88
|
+
const ms = Date.UTC(y, m - 1, d);
|
|
89
|
+
const check = new Date(ms);
|
|
90
|
+
return check.getUTCFullYear() === y && check.getUTCMonth() === m - 1 && check.getUTCDate() === d
|
|
91
|
+
? ms
|
|
92
|
+
: null;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Port of scripts/youtrack/parse-duration.sh. */
|
|
96
|
+
export function youTrackParseDuration(
|
|
97
|
+
text: string,
|
|
98
|
+
): { data: { minutes: number; text: string } } | { error: string } {
|
|
99
|
+
const lower = String(text).toLowerCase().trim();
|
|
100
|
+
let total = 0;
|
|
101
|
+
for (const match of lower.matchAll(/(\d+)\s*h/g)) total += Number(match[1]) * 60;
|
|
102
|
+
for (const match of lower.matchAll(/(\d+)\s*m/g)) total += Number(match[1]);
|
|
103
|
+
if (total === 0 && /^\d+$/.test(lower)) total = Number(lower);
|
|
104
|
+
if (total <= 0) return { error: "could not parse duration" };
|
|
105
|
+
return { data: { minutes: total, text: String(text).trim() } };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms.
|
|
109
|
+
* The calendar day comes from youtrack.json `timezone` when set (optional
|
|
110
|
+
* override), otherwise from the process timezone. */
|
|
111
|
+
export function youTrackWorkDateMs(dateRaw: string):
|
|
112
|
+
| {
|
|
113
|
+
data: {
|
|
114
|
+
dateMs: number;
|
|
115
|
+
timezone: string;
|
|
116
|
+
timezoneSource: WorkTimezone["source"];
|
|
117
|
+
localDate: string;
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
| { error: string } {
|
|
121
|
+
const cfgPath = youTrackConfigPath();
|
|
122
|
+
let effective = effectiveWorkTimezone(null);
|
|
123
|
+
// Missing file is a legitimate unconfigured state (reader: "missing" keeps
|
|
124
|
+
// defaults); a parseable non-object is malformed and must propagate the
|
|
125
|
+
// exact-path error instead of silently defaulting the timezone.
|
|
126
|
+
if (fs.existsSync(cfgPath)) {
|
|
127
|
+
let parsed: unknown;
|
|
128
|
+
try {
|
|
129
|
+
parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
|
|
130
|
+
} catch {
|
|
131
|
+
return { error: `${cfgPath} is not valid JSON` };
|
|
132
|
+
}
|
|
133
|
+
if (!isConfigObject(parsed)) {
|
|
134
|
+
return { error: `${cfgPath} is not a JSON object` };
|
|
135
|
+
}
|
|
136
|
+
effective = effectiveWorkTimezone(parsed as Record<string, any>);
|
|
137
|
+
}
|
|
138
|
+
const tz = effective.timezone;
|
|
139
|
+
const meta = { timezone: tz, timezoneSource: effective.source };
|
|
140
|
+
const raw = dateRaw || "auto";
|
|
141
|
+
try {
|
|
142
|
+
if (raw === "auto") {
|
|
143
|
+
const { y, m, d } = tzParts(new Date(), tz);
|
|
144
|
+
const dateMs = utcMidnight(Number(y), Number(m), Number(d));
|
|
145
|
+
if (dateMs === null) return { error: "could not resolve date" };
|
|
146
|
+
return { data: { dateMs, ...meta, localDate: `${y}-${m}-${d}` } };
|
|
147
|
+
}
|
|
148
|
+
if (/^\d+$/.test(raw)) {
|
|
149
|
+
// An explicit epoch is already a work-item date (UTC midnight of the
|
|
150
|
+
// day); its label is that UTC calendar day so it round-trips exactly.
|
|
151
|
+
const dt = new Date(Number(raw));
|
|
152
|
+
if (Number.isNaN(dt.getTime())) return { error: `invalid date: ${raw}` };
|
|
153
|
+
return { data: { dateMs: Number(raw), ...meta, localDate: dt.toISOString().slice(0, 10) } };
|
|
154
|
+
}
|
|
155
|
+
const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw);
|
|
156
|
+
const dateMs = match ? utcMidnight(Number(match[1]), Number(match[2]), Number(match[3])) : null;
|
|
157
|
+
if (!match || dateMs === null) return { error: `invalid date: ${raw} (expected YYYY-MM-DD)` };
|
|
158
|
+
const localDate = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`;
|
|
159
|
+
return { data: { dateMs, ...meta, localDate } };
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { error: err instanceof Error ? err.message : "could not resolve date" };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** youtrack.json's baseUrl without trailing slashes, read-only; null when unset. */
|
|
166
|
+
export const youTrackBaseUrl = (): string | null => {
|
|
167
|
+
const read = readYouTrackContextConfig();
|
|
168
|
+
if ("error" in read) return null;
|
|
169
|
+
const base = typeof read.data.baseUrl === "string" ? read.data.baseUrl.replace(/\/+$/, "") : "";
|
|
170
|
+
return base || null;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
/** Parse bare id (NSR-40) or YouTrack URL into issue id. */
|
|
174
|
+
export function parseIssueRef(
|
|
175
|
+
input: unknown,
|
|
176
|
+
): { issueId: string; source: string } | { error: string } {
|
|
177
|
+
const trimmed = String(input ?? "").trim();
|
|
178
|
+
if (!trimmed) return { error: "empty issue reference" };
|
|
179
|
+
|
|
180
|
+
if (ISSUE_RE.test(trimmed)) {
|
|
181
|
+
return { issueId: trimmed, source: "id" };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const fromPath = trimmed.match(/\/(?:issue|issues)\/([A-Z]+-\d+)/i);
|
|
185
|
+
if (fromPath && ISSUE_RE.test(fromPath[1])) {
|
|
186
|
+
return { issueId: fromPath[1], source: "url" };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const anywhere = trimmed.match(/([A-Z]+-\d+)/);
|
|
190
|
+
if (anywhere && ISSUE_RE.test(anywhere[1])) {
|
|
191
|
+
return { issueId: anywhere[1], source: "url" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return { error: `could not parse issue id from: ${trimmed}` };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type YouTrackScripts = {
|
|
198
|
+
config(): Record<string, any>;
|
|
199
|
+
parseDuration(text: string): Record<string, any>;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const defaultScripts: YouTrackScripts = {
|
|
203
|
+
config: readYouTrackContextConfig,
|
|
204
|
+
parseDuration: (text) => youTrackParseDuration(text),
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
function resolveYouTrackFromPaths(
|
|
208
|
+
spec_path: string | undefined,
|
|
209
|
+
plan_path: string | undefined,
|
|
210
|
+
workspace_root: string,
|
|
211
|
+
): string | null {
|
|
212
|
+
const root = resolveWorkspaceRoot(workspace_root);
|
|
213
|
+
for (const rel of [spec_path, plan_path].filter(Boolean) as string[]) {
|
|
214
|
+
let full: string;
|
|
215
|
+
try {
|
|
216
|
+
full = resolveInside(root, rel);
|
|
217
|
+
} catch {
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (!fs.existsSync(full)) continue;
|
|
221
|
+
const text = fs.readFileSync(full, "utf8");
|
|
222
|
+
const m = text.match(/^\*\*YouTrack:\*\*\s*`?([A-Z]+-\d+)`?/m);
|
|
223
|
+
if (m) return m[1];
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
|
|
229
|
+
const base = (cfg.baseUrl || "").replace(/\/$/, "");
|
|
230
|
+
if (cfg.meetingIssues && typeof cfg.meetingIssues === "object") {
|
|
231
|
+
return Object.entries(cfg.meetingIssues).map(([key, item]: [string, any]) => ({
|
|
232
|
+
key,
|
|
233
|
+
issue: item.issue,
|
|
234
|
+
label: item.label ?? item.issue,
|
|
235
|
+
workItemText: meetingWorkItemText(cfg, item),
|
|
236
|
+
url: item.url ?? (base && item.issue ? `${base}/issue/${item.issue}` : null),
|
|
237
|
+
}));
|
|
238
|
+
}
|
|
239
|
+
const issue = cfg.meetingIssue;
|
|
240
|
+
return [
|
|
241
|
+
{
|
|
242
|
+
key: "general",
|
|
243
|
+
issue,
|
|
244
|
+
label: "General meetings",
|
|
245
|
+
workItemText: meetingWorkItemText(cfg),
|
|
246
|
+
url: base && issue ? `${base}/issue/${issue}` : null,
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const readOnlyYouTrackConfig = (cfg: Record<string, any>): Record<string, any> => {
|
|
252
|
+
const publicUrl = (value: unknown): string | undefined => {
|
|
253
|
+
if (typeof value !== "string") return undefined;
|
|
254
|
+
try {
|
|
255
|
+
const url = new URL(value);
|
|
256
|
+
if (url.username || url.password)
|
|
257
|
+
return `${url.protocol}//${url.host}${url.pathname}${url.search}${url.hash}`;
|
|
258
|
+
return value;
|
|
259
|
+
} catch {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
const safe: Record<string, any> = {};
|
|
264
|
+
const baseUrl = publicUrl(cfg.baseUrl);
|
|
265
|
+
if (baseUrl) safe.baseUrl = baseUrl;
|
|
266
|
+
for (const key of ["timezone", "meetingIssue", "meetingWorkItemText"])
|
|
267
|
+
if (typeof cfg[key] === "string") safe[key] = cfg[key];
|
|
268
|
+
if (
|
|
269
|
+
cfg.meetingIssues &&
|
|
270
|
+
typeof cfg.meetingIssues === "object" &&
|
|
271
|
+
!Array.isArray(cfg.meetingIssues)
|
|
272
|
+
) {
|
|
273
|
+
safe.meetingIssues = Object.fromEntries(
|
|
274
|
+
Object.entries(cfg.meetingIssues).flatMap(([key, value]) => {
|
|
275
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
276
|
+
const item = value as Record<string, any>;
|
|
277
|
+
const safeItem: Record<string, string> = {};
|
|
278
|
+
for (const field of ["issue", "label", "workItemText"])
|
|
279
|
+
if (typeof item[field] === "string") safeItem[field] = item[field];
|
|
280
|
+
const url = publicUrl(item.url);
|
|
281
|
+
if (url) safeItem.url = url;
|
|
282
|
+
return [[key, safeItem]] as const;
|
|
283
|
+
}),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return safe;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
export function context(
|
|
290
|
+
{
|
|
291
|
+
spec_path,
|
|
292
|
+
plan_path,
|
|
293
|
+
issue_id,
|
|
294
|
+
issue_url,
|
|
295
|
+
issue_ref,
|
|
296
|
+
mode,
|
|
297
|
+
workspace_root,
|
|
298
|
+
}: {
|
|
299
|
+
spec_path?: string;
|
|
300
|
+
plan_path?: string;
|
|
301
|
+
issue_id?: string;
|
|
302
|
+
issue_url?: string;
|
|
303
|
+
issue_ref?: string;
|
|
304
|
+
mode?: string;
|
|
305
|
+
workspace_root: string;
|
|
306
|
+
},
|
|
307
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
308
|
+
): Record<string, any> {
|
|
309
|
+
const cfg = scripts.config();
|
|
310
|
+
if (cfg.error) return { error: cfg.error };
|
|
311
|
+
|
|
312
|
+
const safeConfig = readOnlyYouTrackConfig(cfg.data);
|
|
313
|
+
const meetingOptions = meetingOptionsFromConfig(safeConfig);
|
|
314
|
+
|
|
315
|
+
if (mode === "meetings" && !issue_id && !issue_url && !issue_ref) {
|
|
316
|
+
return {
|
|
317
|
+
config: safeConfig,
|
|
318
|
+
workTimezone: effectiveWorkTimezone(safeConfig),
|
|
319
|
+
mode: "meetings",
|
|
320
|
+
requiresMeetingChoice: true,
|
|
321
|
+
meetingOptions,
|
|
322
|
+
issueId: null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let issue = issue_id;
|
|
327
|
+
if (!issue && (issue_url || issue_ref)) {
|
|
328
|
+
const parsed = parseIssueRef(issue_url ?? issue_ref);
|
|
329
|
+
if ("error" in parsed) return { error: parsed.error };
|
|
330
|
+
issue = parsed.issueId;
|
|
331
|
+
}
|
|
332
|
+
if (!issue && mode === "meetings") issue = meetingOptions[0]?.issue ?? safeConfig.meetingIssue;
|
|
333
|
+
if (!issue) issue = resolveYouTrackFromPaths(spec_path, plan_path, workspace_root) ?? undefined;
|
|
334
|
+
if (!issue || !ISSUE_RE.test(issue)) {
|
|
335
|
+
return {
|
|
336
|
+
error:
|
|
337
|
+
"invalid or missing issue id — pass issue_url, issue_id, or spec/plan with **YouTrack:**",
|
|
338
|
+
requiresIssueInput: true,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const base = (safeConfig.baseUrl || "").replace(/\/$/, "");
|
|
343
|
+
const issueUrl = base ? `${base}/issue/${issue}` : null;
|
|
344
|
+
|
|
345
|
+
const selectedMeeting = meetingOptions.find((m) => m.issue === issue);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
config: safeConfig,
|
|
349
|
+
workTimezone: effectiveWorkTimezone(safeConfig),
|
|
350
|
+
issueId: issue,
|
|
351
|
+
issueUrl,
|
|
352
|
+
mode: mode ?? (selectedMeeting ? "meetings" : "task"),
|
|
353
|
+
meetingOptions,
|
|
354
|
+
workItemText: selectedMeeting?.workItemText ?? null,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Fetch a YouTrack issue body for context.read (titles alone mislead), through
|
|
359
|
+
* ytrack. A missing ytrack or login degrades (offline keeps the link shape); a
|
|
360
|
+
* failed read fails closed so sessions never mistake titles for bodies. */
|
|
361
|
+
export const fetchYouTrackIssueBody = async (
|
|
362
|
+
issue: string,
|
|
363
|
+
ytrack: YtrackRun = ytrackRunner(process.env, youTrackBaseUrl()),
|
|
364
|
+
): Promise<
|
|
365
|
+
| {
|
|
366
|
+
data: {
|
|
367
|
+
idReadable: string;
|
|
368
|
+
summary: string;
|
|
369
|
+
description: string | null;
|
|
370
|
+
state: string | null;
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
| { error: string; kind: "creds" | "request" }
|
|
374
|
+
> => {
|
|
375
|
+
const viaYtrack = ytrack([
|
|
376
|
+
"issue",
|
|
377
|
+
"view",
|
|
378
|
+
issue,
|
|
379
|
+
"--json",
|
|
380
|
+
"idReadable,summary,description,state",
|
|
381
|
+
]);
|
|
382
|
+
if (viaYtrack.missing) return { error: YTRACK_MISSING, kind: "creds" };
|
|
383
|
+
if (viaYtrack.status !== YTRACK_EXIT.ok)
|
|
384
|
+
return {
|
|
385
|
+
error: `ytrack issue view failed (exit ${viaYtrack.status ?? "signal"}): ${ytrackDetail(viaYtrack)}`,
|
|
386
|
+
kind: viaYtrack.status === YTRACK_EXIT.auth ? "creds" : "request",
|
|
387
|
+
};
|
|
388
|
+
try {
|
|
389
|
+
const parsed = JSON.parse(viaYtrack.stdout) as Record<string, unknown>;
|
|
390
|
+
return {
|
|
391
|
+
data: {
|
|
392
|
+
idReadable: String(parsed.idReadable ?? issue),
|
|
393
|
+
summary: String(parsed.summary ?? ""),
|
|
394
|
+
description: typeof parsed.description === "string" ? parsed.description : null,
|
|
395
|
+
state: typeof parsed.state === "string" && parsed.state ? parsed.state : null,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
} catch {
|
|
399
|
+
return { error: "ytrack printed invalid JSON", kind: "request" };
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
export function parseDuration(
|
|
404
|
+
text: string,
|
|
405
|
+
_workspace_root: string,
|
|
406
|
+
scripts: YouTrackScripts = defaultScripts,
|
|
407
|
+
): Record<string, any> {
|
|
408
|
+
const out = scripts.parseDuration(text);
|
|
409
|
+
if (out.error) return { error: out.error };
|
|
410
|
+
return out.data;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function buildDraft({
|
|
414
|
+
issueId,
|
|
415
|
+
projectName,
|
|
416
|
+
userNotes,
|
|
417
|
+
facts,
|
|
418
|
+
includeProjectOpener,
|
|
419
|
+
includeFacts,
|
|
420
|
+
}: {
|
|
421
|
+
issueId: string;
|
|
422
|
+
projectName?: string;
|
|
423
|
+
userNotes?: string;
|
|
424
|
+
facts?: any;
|
|
425
|
+
includeProjectOpener?: boolean;
|
|
426
|
+
includeFacts?: boolean;
|
|
427
|
+
}): Record<string, any> {
|
|
428
|
+
// The wording lives in the editable issue-update template (config
|
|
429
|
+
// templates/issue-update.md overrides the bundled neutral one). Placeholders
|
|
430
|
+
// this build does not fill (e.g. a legacy {{greetingSection}}) render empty.
|
|
431
|
+
const tpl = readTemplate("issue-update").content;
|
|
432
|
+
const para = (value: string): string => (value ? `\n\n${value}` : "");
|
|
433
|
+
const sections: Record<string, string> = {
|
|
434
|
+
"{{projectSection}}": includeProjectOpener && projectName ? `Project: ${projectName}` : "",
|
|
435
|
+
"{{userNotesSection}}": (userNotes ?? "").trim(),
|
|
436
|
+
"{{progressSection}}":
|
|
437
|
+
includeFacts && facts?.progress_excerpt?.length
|
|
438
|
+
? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n")
|
|
439
|
+
: "",
|
|
440
|
+
"{{gitCommitsSection}}":
|
|
441
|
+
includeFacts && facts?.git_commits?.length
|
|
442
|
+
? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
|
|
443
|
+
: "",
|
|
444
|
+
};
|
|
445
|
+
let filled = tpl;
|
|
446
|
+
for (const [placeholder, value] of Object.entries(sections))
|
|
447
|
+
filled = filled.replaceAll(placeholder, para(value));
|
|
448
|
+
filled = filled.replace(/\{\{[A-Za-z0-9_]+\}\}/g, "");
|
|
449
|
+
const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
450
|
+
// Bare draft keeps the header's trailing blank line (matches legacy output);
|
|
451
|
+
// drafts with sections end right after the last one.
|
|
452
|
+
const markdown = Object.values(sections).some(Boolean) ? collapsed : `${collapsed}\n\n`;
|
|
453
|
+
return { issueId, markdown };
|
|
454
|
+
}
|