@brainervirus/workit-core 2.1.2 → 2.1.3
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/package.json +1 -1
- package/src/core/config-conversion.ts +1 -4
- package/src/core/config.ts +2 -5
- package/src/core/external-action-effects.ts +18 -0
- package/src/core/init.ts +21 -34
- package/src/core/setup.ts +0 -5
- package/src/core/templates.ts +1 -1
- package/src/core/youtrack-tools.ts +22 -4
- package/src/core/youtrack.ts +106 -188
- package/templates/issue-update.md +1 -2
- package/templates/greeting.md +0 -1
package/package.json
CHANGED
|
@@ -65,9 +65,6 @@ export function previewConversion(input: ConversionInput): ConversionPreview {
|
|
|
65
65
|
if (config.locale !== undefined) {
|
|
66
66
|
mappings.push({ key: "locale", from: config.locale, to: config.locale });
|
|
67
67
|
}
|
|
68
|
-
if (config.timezone !== undefined) {
|
|
69
|
-
mappings.push({ key: "timezone", from: config.timezone, to: config.timezone });
|
|
70
|
-
}
|
|
71
68
|
if (config.branchPolicy !== undefined && isConfigObject(config.branchPolicy)) {
|
|
72
69
|
const bp = config.branchPolicy as ToolkitConfig["branchPolicy"] & {
|
|
73
70
|
allowed?: string[];
|
|
@@ -142,7 +139,7 @@ export function applyConversionConfig(
|
|
|
142
139
|
const next: Record<string, unknown> = { ...raw };
|
|
143
140
|
|
|
144
141
|
for (const mapping of preview.mappings) {
|
|
145
|
-
if (mapping.key === "locale"
|
|
142
|
+
if (mapping.key === "locale") {
|
|
146
143
|
next[mapping.key] = mapping.to;
|
|
147
144
|
}
|
|
148
145
|
if (mapping.key === "branchPolicy" && !resolutions["branchPolicy.allowed"]) {
|
package/src/core/config.ts
CHANGED
|
@@ -26,7 +26,6 @@ export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom";
|
|
|
26
26
|
export type ToolkitConfig = {
|
|
27
27
|
locale: string;
|
|
28
28
|
localeOptions: string[];
|
|
29
|
-
timezone: string;
|
|
30
29
|
branchPolicy: { preset: BranchPreset; allowed: string[]; protected: string[] };
|
|
31
30
|
commitPolicy: { preset: CommitFlavorPreset; pattern?: string };
|
|
32
31
|
};
|
|
@@ -131,7 +130,6 @@ export const LOCALE_RE = /^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$/;
|
|
|
131
130
|
const DEFAULTS: ToolkitConfig = {
|
|
132
131
|
locale: "en",
|
|
133
132
|
localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"],
|
|
134
|
-
timezone: "America/Santiago",
|
|
135
133
|
branchPolicy: {
|
|
136
134
|
preset: "gitflow",
|
|
137
135
|
allowed: [...PRESETS.gitflow.allowed],
|
|
@@ -177,6 +175,8 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult<Toolk
|
|
|
177
175
|
if (!isConfigObject(parsed)) {
|
|
178
176
|
return { status: "malformed", path: file, error: `${file} is not a JSON object` };
|
|
179
177
|
}
|
|
178
|
+
// Only known keys are read: legacy fields such as the removed global
|
|
179
|
+
// `timezone` are tolerated and ignored, never an error.
|
|
180
180
|
const input = parsed as Partial<ToolkitConfig>;
|
|
181
181
|
const locale = LOCALE_RE.test(String(input.locale ?? ""))
|
|
182
182
|
? (input.locale as string)
|
|
@@ -199,7 +199,6 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult<Toolk
|
|
|
199
199
|
localeOptions: Array.isArray(input.localeOptions)
|
|
200
200
|
? input.localeOptions
|
|
201
201
|
: DEFAULTS.localeOptions,
|
|
202
|
-
timezone: input.timezone ?? DEFAULTS.timezone,
|
|
203
202
|
// RL-02/CA-23: the persisted preset is authoritative; derived allowed /
|
|
204
203
|
// protected fields always reset from PRESETS via the one shared merge.
|
|
205
204
|
branchPolicy: mergePreset(
|
|
@@ -246,7 +245,6 @@ export const readConfigFromDir = (dir: string): ToolkitConfig => {
|
|
|
246
245
|
export type ConfigInput = {
|
|
247
246
|
locale?: string;
|
|
248
247
|
localeOptions?: string[];
|
|
249
|
-
timezone?: string;
|
|
250
248
|
preset?: BranchPreset;
|
|
251
249
|
allowed?: string[];
|
|
252
250
|
protectedNames?: string[];
|
|
@@ -259,7 +257,6 @@ export type ConfigInput = {
|
|
|
259
257
|
export const mergeConfigValues = (input: ConfigInput, current: ToolkitConfig): ToolkitConfig => ({
|
|
260
258
|
locale: input.locale ?? current.locale,
|
|
261
259
|
localeOptions: input.localeOptions ?? current.localeOptions,
|
|
262
|
-
timezone: input.timezone ?? current.timezone,
|
|
263
260
|
branchPolicy: mergePreset(input.preset ?? current.branchPolicy.preset, input, current),
|
|
264
261
|
commitPolicy: input.commitPolicy ?? current.commitPolicy,
|
|
265
262
|
});
|
|
@@ -479,12 +479,28 @@ export const actionProposalQuestion = (
|
|
|
479
479
|
: proposal;
|
|
480
480
|
};
|
|
481
481
|
|
|
482
|
+
export type WorkDateInfo = {
|
|
483
|
+
localDate: string;
|
|
484
|
+
timezone: string;
|
|
485
|
+
timezoneSource: "youtrack.json" | "process";
|
|
486
|
+
};
|
|
487
|
+
|
|
482
488
|
export type ResolvedExternalAction = {
|
|
483
489
|
request: ExternalActionRequest;
|
|
484
490
|
descriptorPayload: unknown;
|
|
485
491
|
marker?: string;
|
|
492
|
+
/** Display-only YouTrack work-item date context. Deliberately outside
|
|
493
|
+
* descriptorPayload: the descriptor is the hashed approval binding and must
|
|
494
|
+
* not depend on the process timezone of whoever resolves it. */
|
|
495
|
+
workDate?: WorkDateInfo;
|
|
486
496
|
};
|
|
487
497
|
|
|
498
|
+
const workDateOf = (data: WorkDateInfo): WorkDateInfo => ({
|
|
499
|
+
localDate: data.localDate,
|
|
500
|
+
timezone: data.timezone,
|
|
501
|
+
timezoneSource: data.timezoneSource,
|
|
502
|
+
});
|
|
503
|
+
|
|
488
504
|
export type HostingReadEvidence = {
|
|
489
505
|
outcome: "succeeded" | "unknown";
|
|
490
506
|
evidenceDigest: string;
|
|
@@ -1707,6 +1723,7 @@ const resolveExternalActionAtRoot = (
|
|
|
1707
1723
|
return success(null, null, {
|
|
1708
1724
|
request,
|
|
1709
1725
|
marker,
|
|
1726
|
+
workDate: workDateOf(date.data),
|
|
1710
1727
|
descriptorPayload: {
|
|
1711
1728
|
...request.payload,
|
|
1712
1729
|
resolved: {
|
|
@@ -1750,6 +1767,7 @@ const resolveExternalActionAtRoot = (
|
|
|
1750
1767
|
return success(null, null, {
|
|
1751
1768
|
request,
|
|
1752
1769
|
marker,
|
|
1770
|
+
workDate: workDateOf(date.data),
|
|
1753
1771
|
descriptorPayload: {
|
|
1754
1772
|
...request.payload,
|
|
1755
1773
|
resolved: {
|
package/src/core/init.ts
CHANGED
|
@@ -42,39 +42,28 @@ export const resolveInitProvider = (cwd?: string): string | null => {
|
|
|
42
42
|
};
|
|
43
43
|
|
|
44
44
|
// Port of scripts/init/apply.sh write_youtrack_json — env overrides honored.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
url: "https://enghouseamg.youtrack.cloud/projects/NSXFT/issues/NSXFT-21",
|
|
45
|
+
// Neutral draft (same shape as the setup wizard's): no organization URL, issue
|
|
46
|
+
// IDs, mentions, greetings or timezone. The work-item date uses the process
|
|
47
|
+
// timezone unless the user adds an explicit `timezone` to youtrack.json.
|
|
48
|
+
const youtrackJsonContent = (dir: string): Record<string, any> => {
|
|
49
|
+
const meetingIssue = process.env.WORKFLOW_YT_MEETING_ISSUE;
|
|
50
|
+
return {
|
|
51
|
+
baseUrl: process.env.WORKFLOW_YT_BASE_URL ?? "https://youtrack.example.com",
|
|
52
|
+
tokenFile: process.env.WORKFLOW_YT_TOKEN_FILE ?? path.join(dir, "youtrack.token"),
|
|
53
|
+
...(meetingIssue
|
|
54
|
+
? {
|
|
55
|
+
meetingIssue,
|
|
56
|
+
meetingIssues: { general: { issue: meetingIssue, label: "General meetings" } },
|
|
57
|
+
}
|
|
58
|
+
: {}),
|
|
59
|
+
tokenDefaults: {
|
|
60
|
+
name: "workit",
|
|
61
|
+
description: "OpenCode workit — /wk-issue-update and /wk-meetings",
|
|
62
|
+
scopes: ["YouTrack"],
|
|
63
|
+
profileTab: "account-security",
|
|
65
64
|
},
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
attachmentsHeaderImages: "## Adjunto capturas",
|
|
69
|
-
attachmentsHeaderFiles: "## Archivos adjuntos",
|
|
70
|
-
attachmentsHeaderMixed: "## Adjuntos",
|
|
71
|
-
tokenDefaults: {
|
|
72
|
-
name: "workit",
|
|
73
|
-
description: "OpenCode workit — /wk-issue-update and /wk-meetings",
|
|
74
|
-
scopes: ["YouTrack"],
|
|
75
|
-
profileTab: "account-security",
|
|
76
|
-
},
|
|
77
|
-
});
|
|
65
|
+
};
|
|
66
|
+
};
|
|
78
67
|
|
|
79
68
|
const vcsJsonContent = (): Record<string, any> => {
|
|
80
69
|
// Explicit provider at init: env wins, else the checkout's origin remote
|
|
@@ -157,8 +146,6 @@ export function initApplyData(
|
|
|
157
146
|
baseUrl: base,
|
|
158
147
|
meetingIssue: meeting,
|
|
159
148
|
meetingIssueUrl: base && meeting ? `${base}/issue/${meeting}` : null,
|
|
160
|
-
defaultMention: cfg.defaultMention,
|
|
161
|
-
timezone: cfg.timezone,
|
|
162
149
|
locale: cfg.locale,
|
|
163
150
|
tokenCreate,
|
|
164
151
|
timeLogging: {
|
package/src/core/setup.ts
CHANGED
|
@@ -84,7 +84,6 @@ export function parseList(raw: string): string[] {
|
|
|
84
84
|
export type SetupPreviewInput = {
|
|
85
85
|
platforms?: string[];
|
|
86
86
|
locale: string;
|
|
87
|
-
timezone: string;
|
|
88
87
|
branchPreset: BranchPreset;
|
|
89
88
|
branchAllowed: string;
|
|
90
89
|
branchProtected: string;
|
|
@@ -143,8 +142,6 @@ export type SetupPreview = {
|
|
|
143
142
|
const YT_OVERRIDES: { envKey: string; affects: string }[] = [
|
|
144
143
|
{ envKey: "WORKFLOW_YT_BASE_URL", affects: "youtrack.json baseUrl" },
|
|
145
144
|
{ envKey: "WORKFLOW_YT_TOKEN_FILE", affects: "youtrack.json tokenFile" },
|
|
146
|
-
{ envKey: "WORKFLOW_YT_TIMEZONE", affects: "youtrack.json timezone" },
|
|
147
|
-
{ envKey: "WORKFLOW_YT_MENTION", affects: "youtrack.json defaultMention" },
|
|
148
145
|
{ envKey: "WORKFLOW_YT_MEETING_ISSUE", affects: "youtrack.json meetingIssue" },
|
|
149
146
|
{ envKey: "WORKFLOW_YT_WEB_MEETING_ISSUE", affects: "youtrack.json web meeting issue" },
|
|
150
147
|
];
|
|
@@ -182,7 +179,6 @@ function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record<str
|
|
|
182
179
|
return {
|
|
183
180
|
baseUrl: values.baseUrl.replace(/\/+$/, ""),
|
|
184
181
|
tokenFile: tokenPath,
|
|
185
|
-
timezone: values.timezone,
|
|
186
182
|
locale: values.locale,
|
|
187
183
|
tokenDefaults: {
|
|
188
184
|
name: "workit",
|
|
@@ -265,7 +261,6 @@ export function buildSetupPreview(
|
|
|
265
261
|
value: {
|
|
266
262
|
locale: values.locale,
|
|
267
263
|
localeOptions: current.localeOptions,
|
|
268
|
-
timezone: values.timezone,
|
|
269
264
|
...(values.commitPolicy === undefined ? {} : { commitPolicy: values.commitPolicy }),
|
|
270
265
|
branchPolicy: mergePreset(
|
|
271
266
|
values.branchPreset,
|
package/src/core/templates.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { configDir } from "./config";
|
|
4
4
|
import { assetRoot } from "./package-root";
|
|
5
5
|
|
|
6
|
-
export type TemplateName = "issue-update" | "
|
|
6
|
+
export type TemplateName = "issue-update" | "headers";
|
|
7
7
|
|
|
8
8
|
const repoRoot = assetRoot();
|
|
9
9
|
|
|
@@ -6,6 +6,7 @@ import { configDir, isConfigObject } from "./config";
|
|
|
6
6
|
import {
|
|
7
7
|
context as legacyContext,
|
|
8
8
|
logTime as legacyLogTime,
|
|
9
|
+
meetingWorkItemText,
|
|
9
10
|
parseDuration as legacyParseDuration,
|
|
10
11
|
postUpdate as legacyPostUpdate,
|
|
11
12
|
verifyYouTrackToken,
|
|
@@ -206,18 +207,35 @@ export async function logTimeUpdate(
|
|
|
206
207
|
}
|
|
207
208
|
|
|
208
209
|
export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue {
|
|
209
|
-
if (!value || mode !== "meetings") return value;
|
|
210
|
+
if (!value || mode !== "meetings" || value.error) return value;
|
|
211
|
+
// Operations may return the legacy `{ data }` envelope; normalize inside it.
|
|
212
|
+
if (value.data && typeof value.data === "object" && !Array.isArray(value.data)) {
|
|
213
|
+
const inner = normalizeContext(value.data as Record<string, unknown>, mode);
|
|
214
|
+
return inner && inner.error
|
|
215
|
+
? { ...value, ...inner, data: undefined }
|
|
216
|
+
: { ...value, data: inner };
|
|
217
|
+
}
|
|
210
218
|
const config = value.config as Record<string, unknown> | undefined;
|
|
211
|
-
const issue = String(config?.meetingIssue || "IRPT-12");
|
|
212
219
|
const { meetingIssues: _meetingIssues, ...singleMeetingConfig } = config ?? {};
|
|
213
220
|
const options = Array.isArray(value.meetingOptions)
|
|
214
221
|
? (value.meetingOptions as Array<Record<string, unknown>>)
|
|
215
222
|
: [];
|
|
223
|
+
// The meeting issue comes only from youtrack.json — there is no built-in one.
|
|
224
|
+
const configured = typeof config?.meetingIssue === "string" ? config.meetingIssue : "";
|
|
225
|
+
const resolved = typeof value.issueId === "string" ? value.issueId : "";
|
|
226
|
+
const firstOption = typeof options[0]?.issue === "string" ? options[0].issue : "";
|
|
227
|
+
const issue = configured || resolved || firstOption;
|
|
228
|
+
if (!issue)
|
|
229
|
+
return {
|
|
230
|
+
...value,
|
|
231
|
+
error: "no meeting issue configured — set meetingIssue in youtrack.json",
|
|
232
|
+
requiresIssueInput: true,
|
|
233
|
+
};
|
|
216
234
|
const selected = options.find((option) => option.issue === issue) ?? {
|
|
217
235
|
key: "general",
|
|
218
236
|
issue,
|
|
219
237
|
label: issue,
|
|
220
|
-
workItemText:
|
|
238
|
+
workItemText: meetingWorkItemText(config),
|
|
221
239
|
};
|
|
222
240
|
return {
|
|
223
241
|
...value,
|
|
@@ -225,6 +243,6 @@ export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue
|
|
|
225
243
|
meetingOptions: [selected],
|
|
226
244
|
requiresMeetingChoice: false,
|
|
227
245
|
issueId: issue,
|
|
228
|
-
workItemText: selected
|
|
246
|
+
workItemText: meetingWorkItemText(config, selected),
|
|
229
247
|
};
|
|
230
248
|
}
|
package/src/core/youtrack.ts
CHANGED
|
@@ -101,72 +101,58 @@ export function youTrackConfigLoad(): YouTrackConfigResult {
|
|
|
101
101
|
return { data: redacted };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
function tzParts(
|
|
105
|
-
date: Date,
|
|
106
|
-
tz: string,
|
|
107
|
-
): { y: string; m: string; d: string; hour: string; minute: string } {
|
|
104
|
+
function tzParts(date: Date, tz: string): { y: string; m: string; d: string } {
|
|
108
105
|
const parts = new Intl.DateTimeFormat("en-GB", {
|
|
109
106
|
timeZone: tz,
|
|
110
107
|
year: "numeric",
|
|
111
108
|
month: "2-digit",
|
|
112
109
|
day: "2-digit",
|
|
113
|
-
hour: "2-digit",
|
|
114
|
-
minute: "2-digit",
|
|
115
|
-
hourCycle: "h23",
|
|
116
110
|
}).formatToParts(date);
|
|
117
111
|
const map = Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
118
|
-
return { y: map.year, m: map.month, d: map.day
|
|
112
|
+
return { y: map.year, m: map.month, d: map.day };
|
|
119
113
|
}
|
|
120
114
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
} catch (err) {
|
|
163
|
-
return {
|
|
164
|
-
stdout: "",
|
|
165
|
-
exitCode: 1,
|
|
166
|
-
stderr: err instanceof Error ? err.message : "greeting failed",
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
}
|
|
115
|
+
// The process timezone (honours TZ). Used when youtrack.json has no explicit
|
|
116
|
+
// `timezone` override; there is no hard-coded default zone.
|
|
117
|
+
const processTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
118
|
+
|
|
119
|
+
export type WorkTimezone = { timezone: string; source: "youtrack.json" | "process" };
|
|
120
|
+
|
|
121
|
+
/** The timezone that decides a work item's calendar day: an explicit
|
|
122
|
+
* youtrack.json `timezone` wins, otherwise the process timezone (TZ). */
|
|
123
|
+
export const effectiveWorkTimezone = (
|
|
124
|
+
cfg: Record<string, any> | null | undefined,
|
|
125
|
+
): WorkTimezone => {
|
|
126
|
+
const configured = cfg?.timezone;
|
|
127
|
+
return typeof configured === "string" && configured.trim()
|
|
128
|
+
? { timezone: configured.trim(), source: "youtrack.json" }
|
|
129
|
+
: { timezone: processTimezone(), source: "process" };
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** Neutral default work-item text for meeting time; youtrack.json can set a
|
|
133
|
+
* global `meetingWorkItemText` or a per-meeting `workItemText`. */
|
|
134
|
+
export const DEFAULT_MEETING_WORK_ITEM_TEXT = "Meetings";
|
|
135
|
+
|
|
136
|
+
export const meetingWorkItemText = (
|
|
137
|
+
cfg: Record<string, any> | null | undefined,
|
|
138
|
+
item?: Record<string, any> | null,
|
|
139
|
+
): string => {
|
|
140
|
+
if (typeof item?.workItemText === "string" && item.workItemText) return item.workItemText;
|
|
141
|
+
if (typeof cfg?.meetingWorkItemText === "string" && cfg.meetingWorkItemText)
|
|
142
|
+
return cfg.meetingWorkItemText;
|
|
143
|
+
return DEFAULT_MEETING_WORK_ITEM_TEXT;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// A YouTrack work-item date is the calendar day at UTC midnight. Computing it
|
|
147
|
+
// with Date.UTC keeps it independent of the process timezone (Date.parse of a
|
|
148
|
+
// local "T00:00:00" shifted it a day back east of UTC).
|
|
149
|
+
const utcMidnight = (y: number, m: number, d: number): number | null => {
|
|
150
|
+
const ms = Date.UTC(y, m - 1, d);
|
|
151
|
+
const check = new Date(ms);
|
|
152
|
+
return check.getUTCFullYear() === y && check.getUTCMonth() === m - 1 && check.getUTCDate() === d
|
|
153
|
+
? ms
|
|
154
|
+
: null;
|
|
155
|
+
};
|
|
170
156
|
|
|
171
157
|
/** Port of scripts/youtrack/parse-duration.sh. */
|
|
172
158
|
export function youTrackParseDuration(
|
|
@@ -181,12 +167,21 @@ export function youTrackParseDuration(
|
|
|
181
167
|
return { data: { minutes: total, text: String(text).trim() } };
|
|
182
168
|
}
|
|
183
169
|
|
|
184
|
-
/** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
170
|
+
/** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms.
|
|
171
|
+
* The calendar day comes from youtrack.json `timezone` when set (optional
|
|
172
|
+
* override), otherwise from the process timezone. */
|
|
173
|
+
export function youTrackWorkDateMs(dateRaw: string):
|
|
174
|
+
| {
|
|
175
|
+
data: {
|
|
176
|
+
dateMs: number;
|
|
177
|
+
timezone: string;
|
|
178
|
+
timezoneSource: WorkTimezone["source"];
|
|
179
|
+
localDate: string;
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
| { error: string } {
|
|
188
183
|
const cfgPath = youTrackConfigPath();
|
|
189
|
-
let
|
|
184
|
+
let effective = effectiveWorkTimezone(null);
|
|
190
185
|
// Missing file is a legitimate unconfigured state (reader: "missing" keeps
|
|
191
186
|
// defaults); a parseable non-object is malformed and must propagate the
|
|
192
187
|
// exact-path error instead of silently defaulting the timezone.
|
|
@@ -200,31 +195,30 @@ export function youTrackWorkDateMs(
|
|
|
200
195
|
if (!isConfigObject(parsed)) {
|
|
201
196
|
return { error: `${cfgPath} is not a JSON object` };
|
|
202
197
|
}
|
|
203
|
-
|
|
198
|
+
effective = effectiveWorkTimezone(parsed as Record<string, any>);
|
|
204
199
|
}
|
|
200
|
+
const tz = effective.timezone;
|
|
201
|
+
const meta = { timezone: tz, timezoneSource: effective.source };
|
|
205
202
|
const raw = dateRaw || "auto";
|
|
206
203
|
try {
|
|
207
|
-
if (raw === "auto"
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
return { data: { dateMs,
|
|
204
|
+
if (raw === "auto") {
|
|
205
|
+
const { y, m, d } = tzParts(new Date(), tz);
|
|
206
|
+
const dateMs = utcMidnight(Number(y), Number(m), Number(d));
|
|
207
|
+
if (dateMs === null) return { error: "could not resolve date" };
|
|
208
|
+
return { data: { dateMs, ...meta, localDate: `${y}-${m}-${d}` } };
|
|
212
209
|
}
|
|
213
210
|
if (/^\d+$/.test(raw)) {
|
|
211
|
+
// An explicit epoch is already a work-item date (UTC midnight of the
|
|
212
|
+
// day); its label is that UTC calendar day so it round-trips exactly.
|
|
214
213
|
const dt = new Date(Number(raw));
|
|
215
|
-
|
|
216
|
-
return { data: { dateMs: Number(raw),
|
|
214
|
+
if (Number.isNaN(dt.getTime())) return { error: `invalid date: ${raw}` };
|
|
215
|
+
return { data: { dateMs: Number(raw), ...meta, localDate: dt.toISOString().slice(0, 10) } };
|
|
217
216
|
}
|
|
218
|
-
const
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
dateMs,
|
|
224
|
-
timezone: tz,
|
|
225
|
-
localDate: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
|
|
226
|
-
},
|
|
227
|
-
};
|
|
217
|
+
const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw);
|
|
218
|
+
const dateMs = match ? utcMidnight(Number(match[1]), Number(match[2]), Number(match[3])) : null;
|
|
219
|
+
if (!match || dateMs === null) return { error: `invalid date: ${raw} (expected YYYY-MM-DD)` };
|
|
220
|
+
const localDate = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`;
|
|
221
|
+
return { data: { dateMs, ...meta, localDate } };
|
|
228
222
|
} catch (err) {
|
|
229
223
|
return { error: err instanceof Error ? err.message : "could not resolve date" };
|
|
230
224
|
}
|
|
@@ -341,67 +335,6 @@ export async function youTrackApi(
|
|
|
341
335
|
return { error: "unknown subcommand" };
|
|
342
336
|
}
|
|
343
337
|
|
|
344
|
-
/** Port of scripts/youtrack/verify-token.sh — read-only GET /api/users/me. */
|
|
345
|
-
export async function youTrackVerifyToken(): Promise<
|
|
346
|
-
{ data: Record<string, any> } | { error: string; http_status?: number; path?: string }
|
|
347
|
-
> {
|
|
348
|
-
const cfgPath = youTrackConfigPath();
|
|
349
|
-
if (!fs.existsSync(cfgPath)) return { error: "missing youtrack.json" };
|
|
350
|
-
const creds = youTrackToken();
|
|
351
|
-
if ("error" in creds) return { error: creds.error };
|
|
352
|
-
const { token, base } = creds;
|
|
353
|
-
|
|
354
|
-
const me = await youTrackRequest(`${base}/api/users/me?fields=id,login,name,email`, {
|
|
355
|
-
method: "GET",
|
|
356
|
-
token,
|
|
357
|
-
});
|
|
358
|
-
if (me.status !== 0) {
|
|
359
|
-
const err =
|
|
360
|
-
me.status === 401 || me.status === 403
|
|
361
|
-
? "authentication failed (401/403)"
|
|
362
|
-
: `HTTP error: ${me.stderr.slice(0, 200)}`;
|
|
363
|
-
return { error: err, http_status: me.status };
|
|
364
|
-
}
|
|
365
|
-
let user: Record<string, any>;
|
|
366
|
-
try {
|
|
367
|
-
user = JSON.parse(me.stdout) as Record<string, any>;
|
|
368
|
-
} catch {
|
|
369
|
-
return { error: "invalid JSON from YouTrack /api/users/me" };
|
|
370
|
-
}
|
|
371
|
-
const result: Record<string, any> = {
|
|
372
|
-
ok: true,
|
|
373
|
-
method: "GET /api/users/me",
|
|
374
|
-
baseUrl: base,
|
|
375
|
-
login: user.login,
|
|
376
|
-
name: user.name,
|
|
377
|
-
email: user.email,
|
|
378
|
-
id: user.id,
|
|
379
|
-
};
|
|
380
|
-
const meeting = readYouTrackConfig(false);
|
|
381
|
-
const meetingIssue = "config" in meeting ? meeting.config.meetingIssue : undefined;
|
|
382
|
-
if (meetingIssue) {
|
|
383
|
-
const issue = await youTrackRequest(
|
|
384
|
-
`${base}/api/issues/${meetingIssue}?fields=id,idReadable,summary`,
|
|
385
|
-
{ method: "GET", token },
|
|
386
|
-
);
|
|
387
|
-
if (issue.status === 0) {
|
|
388
|
-
try {
|
|
389
|
-
const parsed = JSON.parse(issue.stdout) as Record<string, any>;
|
|
390
|
-
result.meetingIssue = meetingIssue;
|
|
391
|
-
result.meetingIssueReadable = true;
|
|
392
|
-
result.meetingIssueSummary = parsed.summary;
|
|
393
|
-
} catch {
|
|
394
|
-
/* unreadable */
|
|
395
|
-
}
|
|
396
|
-
} else {
|
|
397
|
-
result.meetingIssue = meetingIssue;
|
|
398
|
-
result.meetingIssueReadable = false;
|
|
399
|
-
result.warning = `token valid but cannot read issue ${meetingIssue}`;
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
return { data: result };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
338
|
/** Port of scripts/youtrack/token-create-url.sh — deep link to Account Security. */
|
|
406
339
|
export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
|
|
407
340
|
const tokenName = process.env.WORKFLOW_YT_TOKEN_NAME ?? "workit";
|
|
@@ -416,12 +349,14 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
|
|
|
416
349
|
defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings",
|
|
417
350
|
);
|
|
418
351
|
const scopes = Array.isArray(defaults.scopes) ? defaults.scopes : ["YouTrack"];
|
|
419
|
-
|
|
352
|
+
// No organization default: without a configured baseUrl there is no
|
|
353
|
+
// account page to link to, so createUrl is null and `error` says why.
|
|
354
|
+
const base = String(config.baseUrl ?? "").replace(/\/+$/, "");
|
|
420
355
|
const tokenFile = String(
|
|
421
356
|
config.tokenFile ?? path.join(path.dirname(loaded.path), "youtrack.token"),
|
|
422
357
|
);
|
|
423
358
|
const tab = String(defaults.profileTab ?? "account-security");
|
|
424
|
-
const createUrl = `${base}/users/me?${new URLSearchParams({ tab })}
|
|
359
|
+
const createUrl = base ? `${base}/users/me?${new URLSearchParams({ tab })}` : null;
|
|
425
360
|
const docsUrl = "https://www.jetbrains.com/help/youtrack/cloud/manage-permanent-token.html";
|
|
426
361
|
return {
|
|
427
362
|
data: {
|
|
@@ -432,6 +367,9 @@ export function youTrackTokenCreateUrl(): { data: Record<string, any> } {
|
|
|
432
367
|
createUrl,
|
|
433
368
|
docsUrl,
|
|
434
369
|
prefillSupported: false,
|
|
370
|
+
...(base
|
|
371
|
+
? {}
|
|
372
|
+
: { error: `baseUrl missing in ${loaded.path} — set your YouTrack base URL first` }),
|
|
435
373
|
steps: [
|
|
436
374
|
"Profile → Account Security → **New token** (or open createUrl)",
|
|
437
375
|
`Name: **${name}**`,
|
|
@@ -469,21 +407,18 @@ export function parseIssueRef(
|
|
|
469
407
|
|
|
470
408
|
export type YouTrackScripts = {
|
|
471
409
|
config(): Record<string, any>;
|
|
472
|
-
greeting(): { stdout: string; exitCode: number; stderr: string };
|
|
473
410
|
parseDuration(text: string): Record<string, any>;
|
|
474
411
|
api(args: string[]): Record<string, any> | Promise<Record<string, any>>;
|
|
475
412
|
};
|
|
476
413
|
|
|
477
414
|
const defaultScripts: YouTrackScripts = {
|
|
478
415
|
config: () => youTrackConfigLoad(),
|
|
479
|
-
greeting: () => youTrackGreeting(),
|
|
480
416
|
parseDuration: (text) => youTrackParseDuration(text),
|
|
481
417
|
api: (args) => youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? ""),
|
|
482
418
|
};
|
|
483
419
|
|
|
484
420
|
const contextScripts: YouTrackScripts = {
|
|
485
421
|
config: readYouTrackContextConfig,
|
|
486
|
-
greeting: () => youTrackGreeting(youTrackReadOnlyConfigPath()),
|
|
487
422
|
parseDuration: (text) => youTrackParseDuration(text),
|
|
488
423
|
api: () => ({ error: "YouTrack context is read-only" }),
|
|
489
424
|
};
|
|
@@ -522,7 +457,7 @@ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
|
|
|
522
457
|
key,
|
|
523
458
|
issue: item.issue,
|
|
524
459
|
label: item.label ?? item.issue,
|
|
525
|
-
workItemText: item
|
|
460
|
+
workItemText: meetingWorkItemText(cfg, item),
|
|
526
461
|
url: item.url ?? (base && item.issue ? `${base}/issue/${item.issue}` : null),
|
|
527
462
|
}));
|
|
528
463
|
}
|
|
@@ -532,7 +467,7 @@ function meetingOptionsFromConfig(cfg: any): Record<string, any>[] {
|
|
|
532
467
|
key: "general",
|
|
533
468
|
issue,
|
|
534
469
|
label: "General meetings",
|
|
535
|
-
workItemText:
|
|
470
|
+
workItemText: meetingWorkItemText(cfg),
|
|
536
471
|
url: base && issue ? `${base}/issue/${issue}` : null,
|
|
537
472
|
},
|
|
538
473
|
];
|
|
@@ -553,14 +488,8 @@ const readOnlyYouTrackConfig = (cfg: Record<string, any>): Record<string, any> =
|
|
|
553
488
|
const safe: Record<string, any> = {};
|
|
554
489
|
const baseUrl = publicUrl(cfg.baseUrl);
|
|
555
490
|
if (baseUrl) safe.baseUrl = baseUrl;
|
|
556
|
-
for (const key of ["timezone", "
|
|
491
|
+
for (const key of ["timezone", "meetingIssue", "meetingWorkItemText"])
|
|
557
492
|
if (typeof cfg[key] === "string") safe[key] = cfg[key];
|
|
558
|
-
if (cfg.greetings && typeof cfg.greetings === "object" && !Array.isArray(cfg.greetings)) {
|
|
559
|
-
const greetings: Record<string, string> = {};
|
|
560
|
-
for (const key of ["morning", "afternoon"])
|
|
561
|
-
if (typeof cfg.greetings[key] === "string") greetings[key] = cfg.greetings[key];
|
|
562
|
-
if (Object.keys(greetings).length) safe.greetings = greetings;
|
|
563
|
-
}
|
|
564
493
|
if (
|
|
565
494
|
cfg.meetingIssues &&
|
|
566
495
|
typeof cfg.meetingIssues === "object" &&
|
|
@@ -605,18 +534,13 @@ export function context(
|
|
|
605
534
|
const cfg = scripts.config();
|
|
606
535
|
if (cfg.error) return { error: cfg.error };
|
|
607
536
|
|
|
608
|
-
const greeting = scripts.greeting();
|
|
609
|
-
if (greeting.exitCode !== 0) {
|
|
610
|
-
return { error: (greeting.stderr || greeting.stdout || "greeting failed").trim() };
|
|
611
|
-
}
|
|
612
|
-
|
|
613
537
|
const safeConfig = readOnlyYouTrackConfig(cfg.data);
|
|
614
538
|
const meetingOptions = meetingOptionsFromConfig(safeConfig);
|
|
615
539
|
|
|
616
540
|
if (mode === "meetings" && !issue_id && !issue_url && !issue_ref) {
|
|
617
541
|
return {
|
|
618
542
|
config: safeConfig,
|
|
619
|
-
|
|
543
|
+
workTimezone: effectiveWorkTimezone(safeConfig),
|
|
620
544
|
mode: "meetings",
|
|
621
545
|
requiresMeetingChoice: true,
|
|
622
546
|
meetingOptions,
|
|
@@ -647,7 +571,7 @@ export function context(
|
|
|
647
571
|
|
|
648
572
|
return {
|
|
649
573
|
config: safeConfig,
|
|
650
|
-
|
|
574
|
+
workTimezone: effectiveWorkTimezone(safeConfig),
|
|
651
575
|
issueId: issue,
|
|
652
576
|
issueUrl,
|
|
653
577
|
mode: mode ?? (selectedMeeting ? "meetings" : "task"),
|
|
@@ -757,7 +681,6 @@ export function buildDraft({
|
|
|
757
681
|
issueId,
|
|
758
682
|
projectName,
|
|
759
683
|
userNotes,
|
|
760
|
-
greeting,
|
|
761
684
|
facts,
|
|
762
685
|
includeProjectOpener,
|
|
763
686
|
includeFacts,
|
|
@@ -765,40 +688,35 @@ export function buildDraft({
|
|
|
765
688
|
issueId: string;
|
|
766
689
|
projectName?: string;
|
|
767
690
|
userNotes?: string;
|
|
768
|
-
greeting?: string;
|
|
769
691
|
facts?: any;
|
|
770
692
|
includeProjectOpener?: boolean;
|
|
771
693
|
includeFacts?: boolean;
|
|
772
694
|
}): Record<string, any> {
|
|
695
|
+
// The wording lives in the editable issue-update template (config
|
|
696
|
+
// templates/issue-update.md overrides the bundled neutral one). Placeholders
|
|
697
|
+
// this build does not fill (e.g. a legacy {{greetingSection}}) render empty.
|
|
773
698
|
const tpl = readTemplate("issue-update").content;
|
|
774
699
|
const para = (value: string): string => (value ? `\n\n${value}` : "");
|
|
775
|
-
const
|
|
776
|
-
|
|
777
|
-
.
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
)
|
|
790
|
-
|
|
791
|
-
"{{gitCommitsSection}}",
|
|
792
|
-
para(
|
|
793
|
-
includeFacts && facts?.git_commits?.length
|
|
794
|
-
? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
|
|
795
|
-
: "",
|
|
796
|
-
),
|
|
797
|
-
);
|
|
700
|
+
const sections: Record<string, string> = {
|
|
701
|
+
"{{projectSection}}": includeProjectOpener && projectName ? `Project: ${projectName}` : "",
|
|
702
|
+
"{{userNotesSection}}": (userNotes ?? "").trim(),
|
|
703
|
+
"{{progressSection}}":
|
|
704
|
+
includeFacts && facts?.progress_excerpt?.length
|
|
705
|
+
? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n")
|
|
706
|
+
: "",
|
|
707
|
+
"{{gitCommitsSection}}":
|
|
708
|
+
includeFacts && facts?.git_commits?.length
|
|
709
|
+
? facts.git_commits.map((c: string) => `- ${c}`).join("\n")
|
|
710
|
+
: "",
|
|
711
|
+
};
|
|
712
|
+
let filled = tpl;
|
|
713
|
+
for (const [placeholder, value] of Object.entries(sections))
|
|
714
|
+
filled = filled.replaceAll(placeholder, para(value));
|
|
715
|
+
filled = filled.replace(/\{\{[A-Za-z0-9_]+\}\}/g, "");
|
|
798
716
|
const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
799
717
|
// Bare draft keeps the header's trailing blank line (matches legacy output);
|
|
800
718
|
// drafts with sections end right after the last one.
|
|
801
|
-
const markdown =
|
|
719
|
+
const markdown = Object.values(sections).some(Boolean) ? collapsed : `${collapsed}\n\n`;
|
|
802
720
|
return { issueId, markdown };
|
|
803
721
|
}
|
|
804
722
|
|
package/templates/greeting.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{{greetingText}}
|