From b68ab2d9fed6355fd512f6771132a04bca1a23d1 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 14:48:13 -0300 Subject: [PATCH 1/4] fix(youtrack): drop greeting, mention and timezone config; fix work-item date YouTrack stays an optional tracker adapter, but core no longer builds a hard-coded Spanish greeting or @mentions a named person, and the wizard no longer asks every user for a timezone that nothing read. - youtrack: remove youTrackGreeting and its port; context() no longer emits or depends on a greeting, so a greeting problem cannot block reading an issue. - youtrack: compute work-item dates with Date.UTC. Date.parse of a local "T00:00:00" shifted the day back when the process ran east of UTC. The day comes from the process timezone, or from an optional youtrack.json `timezone`; there is no hard-coded default zone. Invalid YYYY-MM-DD input now errors instead of producing NaN. - config/wizard/setup/conversion: remove the global `timezone` field and the timezone wizard step; old config.json files that still carry it load and the key is ignored. - init/scaffold/OpenCode init_apply: neutral youtrack.json drafts (no organization URL, issue IDs, mention, greetings or timezone); drop the default_mention/timezone arguments and the WORKFLOW_YT_MENTION and WORKFLOW_YT_TIMEZONE overrides. - issue-update template: neutral "# Update" header and "Project: X" line; wording stays editable via the config templates directory. - docs: README/AGENTS/workit-cli README; replace dev-machine paths in docs/adaptive-workit/plan.md with placeholders. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 6 + README.md | 13 +- docs/adaptive-workit/plan.md | 4 +- packages/workit-cli/README.md | 2 +- .../assets/templates/issue-update.md | 2 +- packages/workit-cli/src/logic.ts | 39 +--- packages/workit-cli/src/steps.tsx | 96 +-------- packages/workit-cli/src/wizard-state.ts | 155 ++++++++++----- .../workit-core/src/core/config-conversion.ts | 5 +- packages/workit-core/src/core/config.ts | 7 +- packages/workit-core/src/core/init.ts | 57 ++---- .../src/core/ports/youtrack-greeting.ts | 10 - packages/workit-core/src/core/setup.ts | 5 - packages/workit-core/src/core/youtrack.ts | 167 +++++----------- .../workit-core/templates/issue-update.md | 2 +- .../workit-opencode/src/shared/init-apply.ts | 4 - packages/workit-opencode/src/shared/tools.ts | 2 - packages/workit-opencode/src/tools/repo.ts | 2 - test/artifacts/phase-0-candidate.test.ts | 1 - test/workit-cli/branch-policy-wizard.test.tsx | 18 +- test/workit-cli/clean-screen.test.ts | 3 +- test/workit-cli/cli-logic.test.ts | 28 ++- test/workit-cli/packed-cli.test.ts | 1 - test/workit-cli/platform-install.test.ts | 5 +- test/workit-cli/upgrade.test.ts | 1 - test/workit-cli/wizard-config.test.ts | 65 ++----- test/workit-cli/wizard-tty.test.tsx | 182 ++---------------- test/workit-cli/workspace-wizard.test.tsx | 15 +- test/workit-core/branch-policy.test.ts | 2 - test/workit-core/branch.test.ts | 1 - test/workit-core/commit-flavor.test.ts | 1 - test/workit-core/config-conversion.test.ts | 4 +- test/workit-core/config.test.ts | 26 ++- test/workit-core/cursor-install-mcp.test.ts | 1 - test/workit-core/opencode-setup-pin.test.ts | 1 - test/workit-core/repo.test.ts | 1 - test/workit-core/templates.test.ts | 2 +- test/workit-core/youtrack-core.test.ts | 44 +++-- test/workit-core/youtrack-work-date.test.ts | 117 +++++++++++ test/workit-opencode/task-tools.test.ts | 2 +- 40 files changed, 431 insertions(+), 668 deletions(-) delete mode 100644 packages/workit-core/src/core/ports/youtrack-greeting.ts create mode 100644 test/workit-core/youtrack-work-date.test.ts diff --git a/AGENTS.md b/AGENTS.md index 7f3ed7ff..99ebf552 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,6 +32,12 @@ matching). Caller-unattested MCP keeps optional mutations unavailable. The CLI (including `--confirm` without a TTY) return `needs_input` and never fabricate an approval receipt. +YouTrack is an optional tracker adapter. Never hard-code greetings, `@mentions`, +people's names, organization URLs or a default timezone in core or tests; +comment wording belongs in the editable `issue-update` template. Work-item +dates are calendar days computed with `Date.UTC` (process timezone unless +`youtrack.json` sets `timezone`), so they never shift with the host zone. + Read-only `workit_context` is available on OpenCode; Pi and the CLI use `context.read` for the enumerated git/PR/YouTrack/changelog/release/affected contexts without diff --git a/README.md b/README.md index 9c03feb1..012f6a98 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,17 @@ or read separate VCS token files. YouTrack retains its permanent token. Existing VCS token files and templates stay untouched. Project setup defaults to No: press `n` to skip adding files when configuring from a parent folder containing multiple repositories. Press `y` only to add hygiene files and gitignore entries -to the displayed directory. Locale and timezone keep their existing selections -until changed. `workit doctor` checks the configured installation. +to the displayed directory. Locale keeps its existing selection until changed. +`workit doctor` checks the configured installation. + +YouTrack is optional. Work-item dates are the calendar day in the process +timezone (honouring `TZ`), sent as that day's UTC midnight. To pin a different +zone, add an IANA `timezone` to `youtrack.json`. Workit adds no greeting or +`@mention` to comments; the comment text comes from the editable +`issue-update` template (`templates/issue-update.md` in the config directory +overrides the bundled neutral one). Older configs load unchanged: a `timezone` +in the global `config.json`, and `defaultMention`, `greetings` or +`greetingCutoff` in `youtrack.json`, are ignored. `workit cutover` is for migrating legacy installations. diff --git a/docs/adaptive-workit/plan.md b/docs/adaptive-workit/plan.md index 1eb940ca..26d5855e 100644 --- a/docs/adaptive-workit/plan.md +++ b/docs/adaptive-workit/plan.md @@ -186,9 +186,9 @@ focused question only when one of those choices blocks a concrete slice. ### Completed: host recovery and reconciliation - **Changes:** OpenCode v2.0.18's global config had one Workit registration, - `file:///home/cristhofer-pincetti/Documents/projects/personal/workflow-toolkit/packages/workit-opencode`. + `file:///packages/workit-opencode`. Removed only that entry with `opencode plugin remove`; backed up the original - to `/home/cristhofer-pincetti/.local/state/opencode/workit-recovery/opencode.json.pre-workit-disable.20260927T150744Z.bak`. + to `~/.local/state/opencode/workit-recovery/opencode.json.pre-workit-disable..bak`. Restarted the running service once so loaded hooks were dropped. - **Checks:** the restarted service is healthy; `opencode plugin list` reports "No plugins found"; `opencode debug config` shows only Command Code remains in diff --git a/packages/workit-cli/README.md b/packages/workit-cli/README.md index 7e7d2015..aefbcf48 100644 --- a/packages/workit-cli/README.md +++ b/packages/workit-cli/README.md @@ -34,7 +34,7 @@ workit uninstall # remove host registrations (keeps ~/ workit # help ``` -`workit init` guides you through: detected host selection, basic global config (locale, timezone, branch policy), optional advanced commit policy, YouTrack, VCS, workspaces (scoped hosting/tracker/branch/commit rules, profiles and release tracks), and project hygiene files. The wizard is a TTY application — `workit init` requires an interactive terminal and prints guidance (exiting nonzero) when stdin is not a TTY. +`workit init` guides you through: detected host selection, basic global config (locale, branch policy), optional advanced commit policy, YouTrack, VCS, workspaces (scoped hosting/tracker/branch/commit rules, profiles and release tracks), and project hygiene files. The wizard is a TTY application — `workit init` requires an interactive terminal and prints guidance (exiting nonzero) when stdin is not a TTY. Authenticate GitHub or GitLab with `gh auth login` or `glab auth login` before hosting actions; Workit does not need a second provider token file. `workit action` Git/hosting payloads accept `cwd` to target any checkout while task state stays in the session directory. Non-Git directories can host tasks for OS work; YouTrack keeps its own permanent token. diff --git a/packages/workit-cli/assets/templates/issue-update.md b/packages/workit-cli/assets/templates/issue-update.md index 980c8177..36d9a618 100644 --- a/packages/workit-cli/assets/templates/issue-update.md +++ b/packages/workit-cli/assets/templates/issue-update.md @@ -1,4 +1,4 @@ -# Actualización +# Update {{greetingSection}} {{projectSection}} {{userNotesSection}} diff --git a/packages/workit-cli/src/logic.ts b/packages/workit-cli/src/logic.ts index 7e892f01..8637d961 100644 --- a/packages/workit-cli/src/logic.ts +++ b/packages/workit-cli/src/logic.ts @@ -34,18 +34,6 @@ export function validateLocale(locale: string): string | null { return null; } -const KNOWN_TIMEZONES: string[] | null = - typeof Intl.supportedValuesOf === "function" ? Intl.supportedValuesOf("timeZone") : null; - -export function validateTimezone(timezone: string): string | null { - const tz = timezone.trim(); - if (!tz) return "timezone is required"; - if (KNOWN_TIMEZONES && !KNOWN_TIMEZONES.includes(tz)) { - return `unknown timezone "${tz}" — check the IANA name (e.g. America/Santiago)`; - } - return null; -} - export function validateBaseUrl(url: string): string | null { let parsed: URL; try { @@ -59,7 +47,6 @@ export function validateBaseUrl(url: string): string | null { export type ConfigInput = { locale?: string; - timezone?: string; preset?: BranchPreset; allowed?: string[]; protectedNames?: string[]; @@ -487,7 +474,7 @@ export function isSetupComplete(results: { export function scaffoldYouTrack( dir: string, baseUrl: string, - opts: { locale?: string; timezone?: string } = {}, + opts: { locale?: string } = {}, ): YouTrackScaffold { mkdirSync(dir, { recursive: true }); const youtrackJson = path.join(dir, "youtrack.json"); @@ -515,29 +502,7 @@ export function scaffoldYouTrack( const config = { baseUrl, tokenFile: tokenPath, - timezone: opts.timezone ?? "America/Santiago", - locale: opts.locale ?? "es-CL", - defaultMention: "Alejandra.Flores", - greetings: { morning: "buenos días", afternoon: "buenas tardes" }, - greetingCutoff: "12:00", - meetingIssue: "IRPT-12", - meetingIssues: { - general: { - issue: "IRPT-12", - label: "General meetings (Reuniones internas Team IRP)", - workItemText: "Reuniones", - }, - web: { - issue: "NSXFT-21", - label: "Web meetings", - workItemText: "Reuniones web", - url: "https://enghouseamg.youtrack.cloud/projects/NSXFT/issues/NSXFT-21", - }, - }, - commentHeader: "# Actualización", - attachmentsHeaderImages: "## Adjunto capturas", - attachmentsHeaderFiles: "## Archivos adjuntos", - attachmentsHeaderMixed: "## Adjuntos", + locale: opts.locale ?? "en", tokenDefaults: { name: "workit", description: "OpenCode workit — /wk-issue-update and /wk-meetings", diff --git a/packages/workit-cli/src/steps.tsx b/packages/workit-cli/src/steps.tsx index d0e7e35c..a364a56e 100644 --- a/packages/workit-cli/src/steps.tsx +++ b/packages/workit-cli/src/steps.tsx @@ -102,7 +102,6 @@ export const BRANCH_PRESET_DESCRIPTIONS: Record = { export const SCREEN_PLACEHOLDERS = { youtrack: "e.g. https://example.youtrack.cloud", localeOther: "e.g. en-US or es-CL", - timezoneOther: "e.g. America/Santiago", branchAllowed: "e.g. feature/*, bugfix/*", branchProtected: "e.g. main, develop", workspaceName: "e.g. work", @@ -225,56 +224,11 @@ const commitPresetOptions = [ { label: "Auto-detect", value: "auto" }, ]; -// Timezone catalog: the runtime's full canonical IANA set when available, -// else a static fallback of common zones. Guard shape mirrors logic.ts -// KNOWN_TIMEZONES — validateTimezone enforces membership exactly when -// supportedValuesOf exists, so the picker then shows precisely that set; -// on the fallback path validation stays open and Other… covers the rest. -const TIMEZONE_FALLBACK = [ - "UTC", - "America/New_York", - "America/Santiago", - "America/Bogota", - "America/Mexico_City", - "America/Sao_Paulo", - "America/Argentina/Buenos_Aires", - "Europe/London", - "Europe/Madrid", - "Europe/Berlin", - "Asia/Tokyo", - "Asia/Shanghai", - "Asia/Kolkata", - "Australia/Sydney", -]; -const TIMEZONES: string[] = - typeof Intl.supportedValuesOf === "function" - ? Intl.supportedValuesOf("timeZone") - : TIMEZONE_FALLBACK; -// Detected host zone seeds the picker preselection — no typing needed. -const DETECTED_TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - -export function timezonePickerOptions(): { label: string; value: string }[] { - // Detected host zone heads the list so its preselection is visible in the - // first window without typing (the full IANA set alone would bury it). - return TIMEZONE_PICKER_OPTIONS; -} -// Built once at module load (TIMEZONES and DETECTED_TIMEZONE are already -// module-eval constants): rebuilding the IANA catalog per render made the -// screen's useMemo ineffective (Task 3 advisory). -const TIMEZONE_PICKER_OPTIONS: { label: string; value: string }[] = [ - { label: DETECTED_TIMEZONE, value: DETECTED_TIMEZONE }, - ...TIMEZONES.filter((timezone) => timezone !== DETECTED_TIMEZONE).map((timezone) => ({ - label: timezone, - value: timezone, - })), - { label: "Other…", value: "other" }, -]; // Text screens cannot offer the 'b' back key (it is a printable character the // TextInput consumes), so there Esc walks back to the parent select screen and // cancel happens from select/confirm screens. Draft state survives either way. const TEXT_SCREENS: ReadonlySet = new Set([ "localeOther", - "timezoneOther", "branchAllowed", "branchProtected", "youtrack", @@ -289,7 +243,7 @@ const TEXT_SCREENS: ReadonlySet = new Set([ // Screens whose SearchSelect owns printable input: a cold 'b' starts a search // instead of navigating back; only a typed-then-cleared query hands 'b' back // to the wizard's back-navigation. -const SEARCH_SCREENS: ReadonlySet = new Set(["locale", "timezone"]); +const SEARCH_SCREENS: ReadonlySet = new Set(["locale"]); // Deterministic match-preview samples derived from the current project path: // the project itself, its parent, and a synthetic child repo. Every accepted @@ -922,7 +876,7 @@ export function Wizard({ else dispatch({ type: "cancel" }); } else if (input.toLowerCase() === "b" && !TEXT_SCREENS.has(draft.screen)) { // While a search is live or being started on a SearchSelect screen - // (locale, timezone), 'b' belongs to the query; only a typed-then-cleared + // (locale), 'b' belongs to the query; only a typed-then-cleared // search navigates back. Other screens keep plain 'b' back-navigation. const search = searchRef.current; const searchOwnsB = SEARCH_SCREENS.has(draft.screen) && !(search.typed && search.q === ""); @@ -1083,49 +1037,6 @@ function Screen({ Enter to continue · Esc Back ); - case "timezone": - return ( - - Step 2 — Global config · Timezone - Timezone (IANA name): - - Current: {draft.values.timezone} - - {/* Searchable timezone picker mirroring the locale screen: the - detected host zone is preselected, typing filters the IANA - catalog, Enter commits the highlighted row. Other… keeps the - existing validated custom-input flow (CA-04). */} - { - if (value === "other") dispatch({ type: "pickOther" }); - else { - dispatch({ type: "set", field: "timezone", value }); - dispatch({ type: "next" }); - } - }} - /> - {draft.errors.timezone && {draft.errors.timezone}} - Type to filter · Enter to continue · b Back · Esc Cancel - - ); - case "timezoneOther": - return ( - - Step 2 — Global config · Timezone (custom) - Type an IANA timezone (e.g. America/Santiago): - dispatch({ type: "set", field: "timezone", value })} - onSubmit={() => dispatch({ type: "next" })} - /> - {draft.errors.timezone && {draft.errors.timezone}} - Enter to continue · Esc Back - - ); case "branchPreset": { const policy = effectivePolicy(draft.values); return ( @@ -1556,9 +1467,6 @@ function Screen({ Locale: {draft.values.locale} - - Timezone: {draft.values.timezone} - Branch policy: {policy.preset} — allowed:{" "} {policy.allowed.join(", ")} · protected: {policy.protected.join(", ")} diff --git a/packages/workit-cli/src/wizard-state.ts b/packages/workit-cli/src/wizard-state.ts index eb9af4cc..d97cdece 100644 --- a/packages/workit-cli/src/wizard-state.ts +++ b/packages/workit-cli/src/wizard-state.ts @@ -21,7 +21,6 @@ import { createReleaseTrack, validateBaseUrl, validateLocale, - validateTimezone, type VcsProvider, type ProfileEditorField, type TrackEditorField, @@ -37,8 +36,6 @@ export type WizardScreen = | "platforms" | "locale" | "localeOther" - | "timezone" - | "timezoneOther" | "branchPreset" | "branchAllowed" | "branchProtected" @@ -68,7 +65,12 @@ export type BranchPolicyProposal = { integration: "pr" | "merge"; protected: string[]; allowed: string[]; - prefixes: { feature: string; bugfix: string; release: string; hotfix: string }; + prefixes: { + feature: string; + bugfix: string; + release: string; + hotfix: string; + }; }; export type IssueTracker = "youtrack" | "github" | "gitlab" | "none"; @@ -76,7 +78,6 @@ export type IssueTracker = "youtrack" | "github" | "gitlab" | "none"; export type SetupValues = { platforms: string[]; locale: string; - timezone: string; branchPreset: BranchPreset; branchAllowed: string; branchProtected: string; @@ -129,7 +130,7 @@ export type WizardAction = | { type: "set"; field: "branchPolicyDevelop"; value: string } | { type: "set"; - field: "locale" | "timezone" | "branchAllowed" | "branchProtected" | "baseUrl" | "basePath"; + field: "locale" | "branchAllowed" | "branchProtected" | "baseUrl" | "basePath"; value: string; } | { type: "pickOther" } @@ -141,15 +142,32 @@ export type WizardAction = | { type: "workspaceDraftName"; value: string } | { type: "workspaceDraftGlob"; value: string } | { type: "workspaceDraftProvider"; value: string } - | { type: "workspaceAdvancedSelect"; field: import("./logic").WorkspaceEditorField } - | { type: "workspaceAdvancedSet"; field: import("./logic").WorkspaceEditorField; value: string } + | { + type: "workspaceAdvancedSelect"; + field: import("./logic").WorkspaceEditorField; + } + | { + type: "workspaceAdvancedSet"; + field: import("./logic").WorkspaceEditorField; + value: string; + } | { type: "workspaceProfileCreate"; name: string } | { type: "workspaceProfileDelete"; name: string } | { type: "workspaceProfileDefault"; name: string } - | { type: "workspaceProfileSet"; name: string; field: ProfileEditorField; value: string } + | { + type: "workspaceProfileSet"; + name: string; + field: ProfileEditorField; + value: string; + } | { type: "workspaceTrackCreate"; name: string } | { type: "workspaceTrackDelete"; name: string } - | { type: "workspaceTrackSet"; name: string; field: TrackEditorField; value: string } + | { + type: "workspaceTrackSet"; + name: string; + field: TrackEditorField; + value: string; + } | { type: "workspaceSave" } | { type: "branchPolicyEditDevelop" } | { type: "next" } @@ -159,10 +177,8 @@ export type WizardAction = const NEXT: Record = { platforms: "locale", - locale: "timezone", - localeOther: "timezone", - timezone: "branchPreset", - timezoneOther: "branchPreset", + locale: "branchPreset", + localeOther: "branchPreset", branchPreset: "branchAllowed", branchAllowed: "branchProtected", branchProtected: "issueTracker", @@ -189,9 +205,7 @@ const PREV: Record = { platforms: null, locale: "platforms", localeOther: "locale", - timezone: "locale", - timezoneOther: "timezone", - branchPreset: "timezone", + branchPreset: "locale", branchAllowed: "branchPreset", branchProtected: "branchAllowed", issueTracker: "branchProtected", @@ -308,29 +322,32 @@ function validateScreen(draft: WizardDraft): { field: string; message: string } case "platforms": return values.platforms.length > 0 ? null - : { field: "platforms", message: "Select at least one platform to continue." }; + : { + field: "platforms", + message: "Select at least one platform to continue.", + }; // WZ-07: the select screens can carry an empty value only when the custom // (Other) input was cleared before walking back — block committing it. case "locale": return values.locale.trim() ? null : { field: "locale", message: "locale is required" }; - case "timezone": - return values.timezone.trim() ? null : { field: "timezone", message: "timezone is required" }; case "localeOther": { const error = validateLocale(values.locale); return error ? { field: "locale", message: error } : null; } - case "timezoneOther": { - const error = validateTimezone(values.timezone); - return error ? { field: "timezone", message: error } : null; - } case "branchAllowed": return parseList(values.branchAllowed).length > 0 ? null - : { field: "branchAllowed", message: "at least one allowed branch pattern is required" }; + : { + field: "branchAllowed", + message: "at least one allowed branch pattern is required", + }; case "branchProtected": return parseList(values.branchProtected).length > 0 ? null - : { field: "branchProtected", message: "at least one protected branch name is required" }; + : { + field: "branchProtected", + message: "at least one protected branch name is required", + }; case "youtrack": { // WZ-04: YouTrack is optional — an empty base URL means "skip this // integration" and produces no youtrack mutations in the preview. @@ -348,7 +365,11 @@ function validateScreen(draft: WizardDraft): { field: string; message: string } : { field: "workspaceName", message: "workspace name is required" }; case "workspaceGlob": { const glob = (draft.workspaceDraft?.glob ?? "").trim(); - if (!glob) return { field: "workspaceGlob", message: "workspace pattern is required" }; + if (!glob) + return { + field: "workspaceGlob", + message: "workspace pattern is required", + }; const v = validateWorkspaceGlob(glob); return v.ok ? null : { field: "workspaceGlob", message: v.error }; } @@ -374,27 +395,25 @@ const decodeIssueTracker = (value: string, fallback: IssueTracker): IssueTracker function setTextValue( draft: WizardDraft, - field: "locale" | "timezone" | "branchAllowed" | "branchProtected" | "baseUrl" | "basePath", + field: "locale" | "branchAllowed" | "branchProtected" | "baseUrl" | "basePath", value: string, ): WizardDraft { const message = field === "locale" ? validateLocale(value) - : field === "timezone" - ? validateTimezone(value) - : field === "branchAllowed" + : field === "branchAllowed" + ? parseList(value).length > 0 + ? null + : "at least one allowed branch pattern is required" + : field === "branchProtected" ? parseList(value).length > 0 ? null - : "at least one allowed branch pattern is required" - : field === "branchProtected" - ? parseList(value).length > 0 + : "at least one protected branch name is required" + : field === "basePath" + ? basePathMessage(value) + : value.trim() === "" ? null - : "at least one protected branch name is required" - : field === "basePath" - ? basePathMessage(value) - : value.trim() === "" - ? null - : validateBaseUrl(value); + : validateBaseUrl(value); // D-02: an unchanged value whose validation message is also unchanged is a // no-op — return the same draft so useReducer bails out instead of re-rendering // the control and re-firing its onChange (the update-depth feedback loop). @@ -424,7 +443,6 @@ export function createInitialDraft( values: { platforms: opts.platforms ?? [], locale: config.locale, - timezone: config.timezone, branchPreset: config.branchPolicy.preset, branchAllowed: policy.allowed.join(", "), branchProtected: policy.protected.join(", "), @@ -456,7 +474,10 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { case "platforms": // D-02: ordered element equality — the same selection is a no-op. if (isDeepStrictEqual(action.value, draft.values.platforms)) return draft; - return { ...draft, values: { ...draft.values, platforms: action.value } }; + return { + ...draft, + values: { ...draft.values, platforms: action.value }, + }; case "branchPreset": { const next = decodeBranchPreset(action.value, draft.values.branchPreset); if (next === draft.values.branchPreset) return draft; @@ -492,11 +513,17 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { } case "applyProject": if (action.value === draft.values.applyProject) return draft; - return { ...draft, values: { ...draft.values, applyProject: action.value } }; + return { + ...draft, + values: { ...draft.values, applyProject: action.value }, + }; // CA-06: branch-policy fields hold objects, never setTextValue — the // detected proposal and the accepted policy are stored as-is. case "branchPolicyDetected": - return { ...draft, values: { ...draft.values, branchPolicyDetected: action.value } }; + return { + ...draft, + values: { ...draft.values, branchPolicyDetected: action.value }, + }; case "branchPolicy": // I1: an already-edited policy wins — edits compose and survive the // Accept hop instead of being overwritten by the raw detected proposal. @@ -551,14 +578,16 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { } case "pickOther": if (draft.screen === "locale") return { ...draft, screen: "localeOther" }; - if (draft.screen === "timezone") return { ...draft, screen: "timezoneOther" }; return draft; case "branchPolicyEditDevelop": return { ...draft, screen: "branchPolicyDevelop" }; case "next": { const invalid = validateScreen(draft); if (invalid) - return { ...draft, errors: { ...draft.errors, [invalid.field]: invalid.message } }; + return { + ...draft, + errors: { ...draft.errors, [invalid.field]: invalid.message }, + }; return { ...draft, screen: nextScreen( @@ -679,7 +708,11 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { }; } case "workspaceAdvancedSelect": - return { ...draft, screen: "workspaceAdvancedValue", workspaceEditorField: action.field }; + return { + ...draft, + screen: "workspaceAdvancedValue", + workspaceEditorField: action.field, + }; case "workspaceAdvancedSet": { if (!draft.workspaceDraft) return draft; return { @@ -721,7 +754,10 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { ...draft, values: { ...draft.values, - commitPolicy: { ...draft.values.commitPolicy, pattern: action.value || undefined }, + commitPolicy: { + ...draft.values.commitPolicy, + pattern: action.value || undefined, + }, }, }; case "globalCommitPatternDone": @@ -733,7 +769,10 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { ...draft.workspaceDraft.profiles, [name]: { commitPolicy: { preset: "conventional" as const } }, }; - return { ...draft, workspaceDraft: { ...draft.workspaceDraft, profiles } }; + return { + ...draft, + workspaceDraft: { ...draft.workspaceDraft, profiles }, + }; } case "workspaceProfileDelete": { if (!draft.workspaceDraft?.profiles?.[action.name]) return draft; @@ -753,7 +792,13 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { } case "workspaceProfileDefault": { if (!draft.workspaceDraft?.profiles?.[action.name]) return draft; - return { ...draft, workspaceDraft: { ...draft.workspaceDraft, defaultProfile: action.name } }; + return { + ...draft, + workspaceDraft: { + ...draft.workspaceDraft, + defaultProfile: action.name, + }, + }; } case "workspaceProfileSet": { const profile = draft.workspaceDraft?.profiles?.[action.name]; @@ -780,7 +825,10 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { ...draft, workspaceDraft: { ...draft.workspaceDraft, - releaseTracks: { ...draft.workspaceDraft.releaseTracks, [name]: createReleaseTrack() }, + releaseTracks: { + ...draft.workspaceDraft.releaseTracks, + [name]: createReleaseTrack(), + }, }, }; } @@ -788,7 +836,10 @@ export function reducer(draft: WizardDraft, action: WizardAction): WizardDraft { if (!draft.workspaceDraft?.releaseTracks?.[action.name]) return draft; const releaseTracks = { ...draft.workspaceDraft.releaseTracks }; delete releaseTracks[action.name]; - return { ...draft, workspaceDraft: { ...draft.workspaceDraft, releaseTracks } }; + return { + ...draft, + workspaceDraft: { ...draft.workspaceDraft, releaseTracks }, + }; } case "workspaceTrackSet": { const track = draft.workspaceDraft?.releaseTracks?.[action.name]; diff --git a/packages/workit-core/src/core/config-conversion.ts b/packages/workit-core/src/core/config-conversion.ts index 06257141..7c036916 100644 --- a/packages/workit-core/src/core/config-conversion.ts +++ b/packages/workit-core/src/core/config-conversion.ts @@ -65,9 +65,6 @@ export function previewConversion(input: ConversionInput): ConversionPreview { if (config.locale !== undefined) { mappings.push({ key: "locale", from: config.locale, to: config.locale }); } - if (config.timezone !== undefined) { - mappings.push({ key: "timezone", from: config.timezone, to: config.timezone }); - } if (config.branchPolicy !== undefined && isConfigObject(config.branchPolicy)) { const bp = config.branchPolicy as ToolkitConfig["branchPolicy"] & { allowed?: string[]; @@ -142,7 +139,7 @@ export function applyConversionConfig( const next: Record = { ...raw }; for (const mapping of preview.mappings) { - if (mapping.key === "locale" || mapping.key === "timezone") { + if (mapping.key === "locale") { next[mapping.key] = mapping.to; } if (mapping.key === "branchPolicy" && !resolutions["branchPolicy.allowed"]) { diff --git a/packages/workit-core/src/core/config.ts b/packages/workit-core/src/core/config.ts index 87a97325..8033e15e 100644 --- a/packages/workit-core/src/core/config.ts +++ b/packages/workit-core/src/core/config.ts @@ -26,7 +26,6 @@ export type BranchPreset = "gitflow" | "github-flow" | "trunk-based" | "custom"; export type ToolkitConfig = { locale: string; localeOptions: string[]; - timezone: string; branchPolicy: { preset: BranchPreset; allowed: string[]; protected: string[] }; commitPolicy: { preset: CommitFlavorPreset; pattern?: string }; }; @@ -131,7 +130,6 @@ export const LOCALE_RE = /^[a-z]{2,3}(-(?:[A-Z]{2}|[0-9]{3}))?$/; const DEFAULTS: ToolkitConfig = { locale: "en", localeOptions: ["en", "es-CL", "es-MX", "es-AR", "pt-BR"], - timezone: "America/Santiago", branchPolicy: { preset: "gitflow", allowed: [...PRESETS.gitflow.allowed], @@ -177,6 +175,8 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult; const locale = LOCALE_RE.test(String(input.locale ?? "")) ? (input.locale as string) @@ -199,7 +199,6 @@ const parseConfigResult = (raw: string | null, file: string): ReaderResult { export type ConfigInput = { locale?: string; localeOptions?: string[]; - timezone?: string; preset?: BranchPreset; allowed?: string[]; protectedNames?: string[]; @@ -259,7 +257,6 @@ export type ConfigInput = { export const mergeConfigValues = (input: ConfigInput, current: ToolkitConfig): ToolkitConfig => ({ locale: input.locale ?? current.locale, localeOptions: input.localeOptions ?? current.localeOptions, - timezone: input.timezone ?? current.timezone, branchPolicy: mergePreset(input.preset ?? current.branchPolicy.preset, input, current), commitPolicy: input.commitPolicy ?? current.commitPolicy, }); diff --git a/packages/workit-core/src/core/init.ts b/packages/workit-core/src/core/init.ts index dc92d650..a598f722 100644 --- a/packages/workit-core/src/core/init.ts +++ b/packages/workit-core/src/core/init.ts @@ -100,8 +100,6 @@ export function initStatusData(configDirPath = configDir()): Record meetingIssue: meeting, meetingIssues, meetingIssueUrl: base && meeting ? `${base}/issue/${meeting}` : null, - defaultMention: ytParsed.defaultMention, - timezone: ytParsed.timezone, locale: ytParsed.locale, tokenFile: resolvedTokenFile, tokenDefaults: ytParsed.tokenDefaults, @@ -279,39 +277,28 @@ export async function toolkitStatusData(configDirPath = configDir()): Promise => ({ - baseUrl: process.env.WORKFLOW_YT_BASE_URL ?? "https://enghouseamg.youtrack.cloud", - tokenFile: process.env.WORKFLOW_YT_TOKEN_FILE ?? path.join(dir, "youtrack.token"), - timezone: process.env.WORKFLOW_YT_TIMEZONE ?? "America/Santiago", - locale: "es-CL", - defaultMention: process.env.WORKFLOW_YT_MENTION ?? "Alejandra.Flores", - greetings: { morning: "buenos días", afternoon: "buenas tardes" }, - greetingCutoff: "12:00", - meetingIssue: process.env.WORKFLOW_YT_MEETING_ISSUE ?? "IRPT-12", - meetingIssues: { - general: { - issue: process.env.WORKFLOW_YT_MEETING_ISSUE ?? "IRPT-12", - label: "General meetings (Reuniones internas Team IRP)", - workItemText: "Reuniones", - }, - web: { - issue: process.env.WORKFLOW_YT_WEB_MEETING_ISSUE ?? "NSXFT-21", - label: "Web meetings", - workItemText: "Reuniones web", - url: "https://enghouseamg.youtrack.cloud/projects/NSXFT/issues/NSXFT-21", +// Neutral draft (same shape as the setup wizard's): no organization URL, issue +// IDs, mentions, greetings or timezone. The work-item date uses the process +// timezone unless the user adds an explicit `timezone` to youtrack.json. +const youtrackJsonContent = (dir: string): Record => { + const meetingIssue = process.env.WORKFLOW_YT_MEETING_ISSUE; + return { + baseUrl: process.env.WORKFLOW_YT_BASE_URL ?? "https://youtrack.example.com", + tokenFile: process.env.WORKFLOW_YT_TOKEN_FILE ?? path.join(dir, "youtrack.token"), + ...(meetingIssue + ? { + meetingIssue, + meetingIssues: { general: { issue: meetingIssue, label: "General meetings" } }, + } + : {}), + tokenDefaults: { + name: "workit", + description: "OpenCode workit — /wk-issue-update and /wk-meetings", + scopes: ["YouTrack"], + profileTab: "account-security", }, - }, - commentHeader: "# Actualización", - attachmentsHeaderImages: "## Adjunto capturas", - attachmentsHeaderFiles: "## Archivos adjuntos", - attachmentsHeaderMixed: "## Adjuntos", - tokenDefaults: { - name: "workit", - description: "OpenCode workit — /wk-issue-update and /wk-meetings", - scopes: ["YouTrack"], - profileTab: "account-security", - }, -}); + }; +}; const vcsJsonContent = (): Record => { // Explicit provider at init: env wins, else the checkout's origin remote @@ -394,8 +381,6 @@ export function initApplyData( baseUrl: base, meetingIssue: meeting, meetingIssueUrl: base && meeting ? `${base}/issue/${meeting}` : null, - defaultMention: cfg.defaultMention, - timezone: cfg.timezone, locale: cfg.locale, tokenCreate, timeLogging: { diff --git a/packages/workit-core/src/core/ports/youtrack-greeting.ts b/packages/workit-core/src/core/ports/youtrack-greeting.ts deleted file mode 100644 index 098dceaf..00000000 --- a/packages/workit-core/src/core/ports/youtrack-greeting.ts +++ /dev/null @@ -1,10 +0,0 @@ -// CLI port of scripts/youtrack/greeting.sh. -import { youTrackGreeting } from "../youtrack"; - -const override = process.argv[2]; -const result = youTrackGreeting(override); -if (result.exitCode !== 0) { - console.error(result.stderr || "greeting failed"); - process.exit(1); -} -process.stdout.write(result.stdout); diff --git a/packages/workit-core/src/core/setup.ts b/packages/workit-core/src/core/setup.ts index dbfd6762..be8a34d3 100644 --- a/packages/workit-core/src/core/setup.ts +++ b/packages/workit-core/src/core/setup.ts @@ -84,7 +84,6 @@ export function parseList(raw: string): string[] { export type SetupPreviewInput = { platforms?: string[]; locale: string; - timezone: string; branchPreset: BranchPreset; branchAllowed: string; branchProtected: string; @@ -143,8 +142,6 @@ export type SetupPreview = { const YT_OVERRIDES: { envKey: string; affects: string }[] = [ { envKey: "WORKFLOW_YT_BASE_URL", affects: "youtrack.json baseUrl" }, { envKey: "WORKFLOW_YT_TOKEN_FILE", affects: "youtrack.json tokenFile" }, - { envKey: "WORKFLOW_YT_TIMEZONE", affects: "youtrack.json timezone" }, - { envKey: "WORKFLOW_YT_MENTION", affects: "youtrack.json defaultMention" }, { envKey: "WORKFLOW_YT_MEETING_ISSUE", affects: "youtrack.json meetingIssue" }, { envKey: "WORKFLOW_YT_WEB_MEETING_ISSUE", affects: "youtrack.json web meeting issue" }, ]; @@ -182,7 +179,6 @@ function youtrackDraft(values: SetupPreviewInput, tokenPath: string): Record [p.type, p.value])); - return { y: map.year, m: map.month, d: map.day, hour: map.hour, minute: map.minute }; + return { y: map.year, m: map.month, d: map.day }; } -/** Port of scripts/youtrack/greeting.sh. */ -export function youTrackGreeting(configOverride?: string): { - stdout: string; - exitCode: number; - stderr: string; -} { - const cfgPath = configOverride ?? youTrackConfigPath(); - let parsed: unknown; - try { - parsed = JSON.parse(fs.readFileSync(cfgPath, "utf8")); - } catch { - return { - stdout: "", - exitCode: 1, - stderr: fs.existsSync(cfgPath) - ? `${cfgPath} is not valid JSON` - : `missing youtrack.json: ${cfgPath}`, - }; - } - if (!isConfigObject(parsed)) { - return { stdout: "", exitCode: 1, stderr: `${cfgPath} is not a JSON object` }; - } - const config = parsed as Record; - try { - const tz = String(config.timezone ?? "America/Santiago"); - const now = new Date(); - const { y, m, d, hour, minute } = tzParts(now, tz); - const cutoff = String(config.greetingCutoff ?? "12:00").split(":"); - const cutoffHour = Number(cutoff[0]); - const cutoffMinute = Number(cutoff[1] ?? 0); - const greetings = (config.greetings ?? {}) as Record; - const isMorning = - Number(hour) < cutoffHour || (Number(hour) === cutoffHour && Number(minute) < cutoffMinute); - const greeting = isMorning - ? (greetings.morning ?? "buenos días") - : (greetings.afternoon ?? "buenas tardes"); - const mention = String(config.defaultMention ?? "Alejandra.Flores"); - void y; - void m; - void d; - return { stdout: `@${mention} Hola, ${greeting}.\n`, exitCode: 0, stderr: "" }; - } catch (err) { - return { - stdout: "", - exitCode: 1, - stderr: err instanceof Error ? err.message : "greeting failed", - }; - } -} +// The process timezone (honours TZ). Used when youtrack.json has no explicit +// `timezone` override; there is no hard-coded default zone. +const processTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; + +// A YouTrack work-item date is the calendar day at UTC midnight. Computing it +// with Date.UTC keeps it independent of the process timezone (Date.parse of a +// local "T00:00:00" shifted it a day back east of UTC). +const utcMidnight = (y: number, m: number, d: number): number | null => { + const ms = Date.UTC(y, m - 1, d); + const check = new Date(ms); + return check.getUTCFullYear() === y && check.getUTCMonth() === m - 1 && check.getUTCDate() === d + ? ms + : null; +}; /** Port of scripts/youtrack/parse-duration.sh. */ export function youTrackParseDuration( @@ -181,12 +140,14 @@ export function youTrackParseDuration( return { data: { minutes: total, text: String(text).trim() } }; } -/** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms. */ +/** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms. + * The calendar day comes from youtrack.json `timezone` when set (optional + * override), otherwise from the process timezone. */ export function youTrackWorkDateMs( dateRaw: string, ): { data: { dateMs: number; timezone: string; localDate: string } } | { error: string } { const cfgPath = youTrackConfigPath(); - let tz = "America/Santiago"; + let tz = processTimezone(); // Missing file is a legitimate unconfigured state (reader: "missing" keeps // defaults); a parseable non-object is malformed and must propagate the // exact-path error instead of silently defaulting the timezone. @@ -200,14 +161,15 @@ export function youTrackWorkDateMs( if (!isConfigObject(parsed)) { return { error: `${cfgPath} is not a JSON object` }; } - tz = String((parsed as Record).timezone ?? "America/Santiago"); + const configured = (parsed as Record).timezone; + if (typeof configured === "string" && configured.trim()) tz = configured.trim(); } const raw = dateRaw || "auto"; try { - if (raw === "auto" || !raw) { - const now = new Date(); - const { y, m, d } = tzParts(now, tz); - const dateMs = Math.floor(Date.parse(`${y}-${m}-${d}T00:00:00`) / 86400000) * 86400000; + if (raw === "auto") { + const { y, m, d } = tzParts(new Date(), tz); + const dateMs = utcMidnight(Number(y), Number(m), Number(d)); + if (dateMs === null) return { error: "could not resolve date" }; return { data: { dateMs, timezone: tz, localDate: `${y}-${m}-${d}` } }; } if (/^\d+$/.test(raw)) { @@ -215,16 +177,11 @@ export function youTrackWorkDateMs( const { y, m, d } = tzParts(dt, tz); return { data: { dateMs: Number(raw), timezone: tz, localDate: `${y}-${m}-${d}` } }; } - const [y, m, d] = raw.split("-").map(Number); - const iso = `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}T00:00:00`; - const dateMs = Math.floor(Date.parse(iso) / 86400000) * 86400000; - return { - data: { - dateMs, - timezone: tz, - localDate: `${y}-${String(m).padStart(2, "0")}-${String(d).padStart(2, "0")}`, - }, - }; + const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw); + const dateMs = match ? utcMidnight(Number(match[1]), Number(match[2]), Number(match[3])) : null; + if (!match || dateMs === null) return { error: `invalid date: ${raw} (expected YYYY-MM-DD)` }; + const localDate = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`; + return { data: { dateMs, timezone: tz, localDate } }; } catch (err) { return { error: err instanceof Error ? err.message : "could not resolve date" }; } @@ -469,21 +426,18 @@ export function parseIssueRef( export type YouTrackScripts = { config(): Record; - greeting(): { stdout: string; exitCode: number; stderr: string }; parseDuration(text: string): Record; api(args: string[]): Record | Promise>; }; const defaultScripts: YouTrackScripts = { config: () => youTrackConfigLoad(), - greeting: () => youTrackGreeting(), parseDuration: (text) => youTrackParseDuration(text), api: (args) => youTrackApi(args, process.env.WORKFLOW_YT_WRITE ?? ""), }; const contextScripts: YouTrackScripts = { config: readYouTrackContextConfig, - greeting: () => youTrackGreeting(youTrackReadOnlyConfigPath()), parseDuration: (text) => youTrackParseDuration(text), api: () => ({ error: "YouTrack context is read-only" }), }; @@ -553,14 +507,8 @@ const readOnlyYouTrackConfig = (cfg: Record): Record = const safe: Record = {}; const baseUrl = publicUrl(cfg.baseUrl); if (baseUrl) safe.baseUrl = baseUrl; - for (const key of ["timezone", "greetingCutoff", "defaultMention", "meetingIssue"]) + for (const key of ["timezone", "meetingIssue"]) if (typeof cfg[key] === "string") safe[key] = cfg[key]; - if (cfg.greetings && typeof cfg.greetings === "object" && !Array.isArray(cfg.greetings)) { - const greetings: Record = {}; - for (const key of ["morning", "afternoon"]) - if (typeof cfg.greetings[key] === "string") greetings[key] = cfg.greetings[key]; - if (Object.keys(greetings).length) safe.greetings = greetings; - } if ( cfg.meetingIssues && typeof cfg.meetingIssues === "object" && @@ -605,18 +553,12 @@ export function context( const cfg = scripts.config(); if (cfg.error) return { error: cfg.error }; - const greeting = scripts.greeting(); - if (greeting.exitCode !== 0) { - return { error: (greeting.stderr || greeting.stdout || "greeting failed").trim() }; - } - const safeConfig = readOnlyYouTrackConfig(cfg.data); const meetingOptions = meetingOptionsFromConfig(safeConfig); if (mode === "meetings" && !issue_id && !issue_url && !issue_ref) { return { config: safeConfig, - greeting: greeting.stdout.trim(), mode: "meetings", requiresMeetingChoice: true, meetingOptions, @@ -647,7 +589,6 @@ export function context( return { config: safeConfig, - greeting: greeting.stdout.trim(), issueId: issue, issueUrl, mode: mode ?? (selectedMeeting ? "meetings" : "task"), @@ -770,35 +711,31 @@ export function buildDraft({ includeProjectOpener?: boolean; includeFacts?: boolean; }): Record { + // The wording lives in the editable issue-update template (config + // templates/issue-update.md overrides the bundled neutral one). `greeting` + // is an optional caller-supplied opening line; there is no built-in text. const tpl = readTemplate("issue-update").content; const para = (value: string): string => (value ? `\n\n${value}` : ""); - const filled = tpl - .replaceAll("{{greetingSection}}", para(greeting ? `${greeting}` : "")) - .replaceAll( - "{{projectSection}}", - para(includeProjectOpener && projectName ? `Hoy estuve full con ${projectName}.` : ""), - ) - .replaceAll("{{userNotesSection}}", para((userNotes ?? "").trim())) - .replaceAll( - "{{progressSection}}", - para( - includeFacts && facts?.progress_excerpt?.length - ? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n") - : "", - ), - ) - .replaceAll( - "{{gitCommitsSection}}", - para( - includeFacts && facts?.git_commits?.length - ? facts.git_commits.map((c: string) => `- ${c}`).join("\n") - : "", - ), - ); + const sections: Record = { + "{{greetingSection}}": (greeting ?? "").trim(), + "{{projectSection}}": includeProjectOpener && projectName ? `Project: ${projectName}` : "", + "{{userNotesSection}}": (userNotes ?? "").trim(), + "{{progressSection}}": + includeFacts && facts?.progress_excerpt?.length + ? facts.progress_excerpt.map((l: string) => `- ${l}`).join("\n") + : "", + "{{gitCommitsSection}}": + includeFacts && facts?.git_commits?.length + ? facts.git_commits.map((c: string) => `- ${c}`).join("\n") + : "", + }; + let filled = tpl; + for (const [placeholder, value] of Object.entries(sections)) + filled = filled.replaceAll(placeholder, para(value)); const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd(); // Bare draft keeps the header's trailing blank line (matches legacy output); // drafts with sections end right after the last one. - const markdown = collapsed === "# Actualización" ? `${collapsed}\n\n` : collapsed; + const markdown = Object.values(sections).some(Boolean) ? collapsed : `${collapsed}\n\n`; return { issueId, markdown }; } diff --git a/packages/workit-core/templates/issue-update.md b/packages/workit-core/templates/issue-update.md index 980c8177..36d9a618 100644 --- a/packages/workit-core/templates/issue-update.md +++ b/packages/workit-core/templates/issue-update.md @@ -1,4 +1,4 @@ -# Actualización +# Update {{greetingSection}} {{projectSection}} {{userNotesSection}} diff --git a/packages/workit-opencode/src/shared/init-apply.ts b/packages/workit-opencode/src/shared/init-apply.ts index 5eb2257e..5b037356 100644 --- a/packages/workit-opencode/src/shared/init-apply.ts +++ b/packages/workit-opencode/src/shared/init-apply.ts @@ -39,7 +39,6 @@ export type InitApplyArgs = { | "hygiene" | "branch_policy"; base_url?: string; - default_mention?: string; meeting_issue?: string; vcs_provider?: "gitlab" | "github"; vcs_target_branch?: string; @@ -48,7 +47,6 @@ export type InitApplyArgs = { integration?: "pr" | "merge"; locale?: string; locale_options?: string[]; - timezone?: string; branch_policy_preset?: "gitflow" | "github-flow" | "trunk-based" | "custom"; branch_policy_allowed?: string[]; branch_policy_protected?: string[]; @@ -92,7 +90,6 @@ export const executeInitApply = ( { locale: args.locale, localeOptions: args.locale_options, - timezone: args.timezone, preset: args.branch_policy_preset as BranchPreset, allowed: args.branch_policy_allowed, protectedNames: args.branch_policy_protected, @@ -105,7 +102,6 @@ export const executeInitApply = ( const env = Object.fromEntries( Object.entries({ WORKFLOW_YT_BASE_URL: args.base_url, - WORKFLOW_YT_MENTION: args.default_mention, WORKFLOW_YT_MEETING_ISSUE: args.meeting_issue, WORKFLOW_VCS_PROVIDER: args.vcs_provider, WORKFLOW_VCS_TARGET_BRANCH: args.vcs_target_branch, diff --git a/packages/workit-opencode/src/shared/tools.ts b/packages/workit-opencode/src/shared/tools.ts index a624a2a2..abb64802 100644 --- a/packages/workit-opencode/src/shared/tools.ts +++ b/packages/workit-opencode/src/shared/tools.ts @@ -66,7 +66,6 @@ export const WORKIT_TOOL_CATALOG: readonly WorkitToolSpec[] = [ ], }, base_url: { type: "string" }, - default_mention: { type: "string" }, meeting_issue: { type: "string" }, vcs_provider: { type: "string", enum: ["gitlab", "github"] }, vcs_target_branch: { type: "string" }, @@ -75,7 +74,6 @@ export const WORKIT_TOOL_CATALOG: readonly WorkitToolSpec[] = [ integration: { type: "string", enum: ["pr", "merge"] }, locale: { type: "string" }, locale_options: { type: "array", items: { type: "string" } }, - timezone: { type: "string" }, branch_policy_preset: { type: "string", enum: ["gitflow", "github-flow", "trunk-based", "custom"], diff --git a/packages/workit-opencode/src/tools/repo.ts b/packages/workit-opencode/src/tools/repo.ts index 265495e5..14b7464e 100644 --- a/packages/workit-opencode/src/tools/repo.ts +++ b/packages/workit-opencode/src/tools/repo.ts @@ -213,7 +213,6 @@ export function createRepoTools(runtime: RepoRuntime = defaultRuntime) { "branch_policy", ]), base_url: tool.schema.string().optional(), - default_mention: tool.schema.string().optional(), meeting_issue: tool.schema.string().optional(), vcs_provider: tool.schema.enum(["gitlab", "github"]).optional(), vcs_target_branch: tool.schema.string().optional(), @@ -222,7 +221,6 @@ export function createRepoTools(runtime: RepoRuntime = defaultRuntime) { integration: tool.schema.enum(["pr", "merge"]).optional(), locale: tool.schema.string().optional(), locale_options: tool.schema.array(tool.schema.string()).optional(), - timezone: tool.schema.string().optional(), branch_policy_preset: tool.schema .enum(["gitflow", "github-flow", "trunk-based", "custom"]) .optional(), diff --git a/test/artifacts/phase-0-candidate.test.ts b/test/artifacts/phase-0-candidate.test.ts index a322509d..d7b2a4ce 100644 --- a/test/artifacts/phase-0-candidate.test.ts +++ b/test/artifacts/phase-0-candidate.test.ts @@ -325,7 +325,6 @@ import { scaffoldYouTrack, scaffoldVcs } from "./logic.ts"; const cfg = path.join(process.env.HOME!, ".config", "workflow-toolkit"); const yt = scaffoldYouTrack(cfg, "https://example.youtrack.cloud", { locale: "en", - timezone: "America/Santiago", }, { timeout: 60_000 }); const vcs = scaffoldVcs(cfg, "gitlab"); console.log( diff --git a/test/workit-cli/branch-policy-wizard.test.tsx b/test/workit-cli/branch-policy-wizard.test.tsx index ac47efc7..f083ca5b 100644 --- a/test/workit-cli/branch-policy-wizard.test.tsx +++ b/test/workit-cli/branch-policy-wizard.test.tsx @@ -55,25 +55,11 @@ test("CA-06: wizard branch-policy apply equals the host init action write", asyn detection={{ ...emptyDetection(), opencode: { detected: true, configured: false } }} />, ); - // platforms SPACE+ENTER, locale/timezone/branchPreset ENTERs, issueTracker + // platforms SPACE+ENTER, locale/branchPreset ENTERs, issueTracker // ENTER (YouTrack), youtrack/vcs ENTERs, workspaces Done ENTER -> // branchPolicy (git repo), accept defaults ENTER -> project, project y -> // summary, summary y -> apply -> exit - await tty.keys( - SPACE, - ENTER, - ENTER, - ENTER, - ENTER, - ENTER, - ENTER, - ENTER, - ENTER, - ENTER, - ENTER, - "y", - "y", - ); + await tty.keys(SPACE, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER, "y", "y"); tty.unmount(); expect( exitValues?.branchPolicy, diff --git a/test/workit-cli/clean-screen.test.ts b/test/workit-cli/clean-screen.test.ts index 5b2f1cba..df80dc7a 100644 --- a/test/workit-cli/clean-screen.test.ts +++ b/test/workit-cli/clean-screen.test.ts @@ -275,8 +275,7 @@ test("apply path: first chunk clears, exactly one post-exit clear precedes the f { waitFor: "OpenCode · detected" }, ENTER, // accept the detected OpenCode host and enter the wizard { waitFor: "Locale" }, - ENTER, // locale -> timezone - ENTER, // timezone -> branchPreset + ENTER, // locale -> branchPreset ENTER, // branchPreset -> issueTracker ENTER, // issueTracker (YouTrack) -> youtrack "https://yt.example.com", diff --git a/test/workit-cli/cli-logic.test.ts b/test/workit-cli/cli-logic.test.ts index 0f9ec187..bb220b7e 100644 --- a/test/workit-cli/cli-logic.test.ts +++ b/test/workit-cli/cli-logic.test.ts @@ -14,7 +14,6 @@ import { TOKEN_PLACEHOLDER, validateBaseUrl, validateLocale, - validateTimezone, writeWorkspaces, } from "@/packages/workit-cli/src/logic"; import { readWorkspacesResult, resolveWorkspace } from "@/packages/workit-core/src/core/workspaces"; @@ -24,7 +23,6 @@ import { withTempConfigDir as withConfigDir } from "@/test/shared/helpers/env"; const current: ToolkitConfig = { locale: "en", localeOptions: ["en", "es-CL"], - timezone: "America/Santiago", branchPolicy: { preset: "gitflow", allowed: [...PRESETS.gitflow.allowed], @@ -41,16 +39,8 @@ test("validateLocale accepts BCP-47, rejects bad formats", () => { expect(validateLocale("")).not.toBeNull(); }); -test("validateTimezone accepts known IANA zones, rejects junk when Intl supports it", () => { - expect(validateTimezone("America/Santiago")).toBeNull(); - expect(validateTimezone("")).not.toBeNull(); - if (typeof Intl.supportedValuesOf === "function") { - expect(validateTimezone("Mars/Olympus")).not.toBeNull(); - } -}); - test("validateBaseUrl requires https", () => { - expect(validateBaseUrl("https://enghouseamg.youtrack.cloud")).toBeNull(); + expect(validateBaseUrl("https://example.youtrack.cloud")).toBeNull(); expect(validateBaseUrl("http://example.com")).not.toBeNull(); expect(validateBaseUrl("not a url")).not.toBeNull(); expect(validateBaseUrl("")).not.toBeNull(); @@ -67,9 +57,9 @@ test("parseList splits on commas and trims", () => { }); test("collectConfigValues merges with current config", () => { - const merged = collectConfigValues({ locale: "es-CL", timezone: "Europe/Madrid" }, current); + const merged = collectConfigValues({ locale: "es-CL" }, current); expect(merged.locale).toBe("es-CL"); - expect(merged.timezone).toBe("Europe/Madrid"); + expect(merged).not.toHaveProperty("timezone"); expect(merged.branchPolicy).toEqual(current.branchPolicy); expect(merged.localeOptions).toEqual(current.localeOptions); }); @@ -140,13 +130,20 @@ test("scaffoldYouTrack writes youtrack.json + placeholder token + token URL", () try { const s = scaffoldYouTrack(dir, "https://youtrack.example.com", { locale: "es-CL", - timezone: "America/Santiago", }); expect(s.tokenCreateUrl).toBe("https://youtrack.example.com/users/me?tab=account-security"); const cfg = JSON.parse(readFileSync(s.youtrackJson, "utf8")); expect(cfg.baseUrl).toBe("https://youtrack.example.com"); expect(cfg.locale).toBe("es-CL"); - expect(cfg.timezone).toBe("America/Santiago"); + // Neutral draft: no timezone, mention, greeting or organization defaults. + for (const key of [ + "timezone", + "defaultMention", + "greetings", + "greetingCutoff", + "meetingIssues", + ]) + expect(cfg).not.toHaveProperty(key); expect(readFileSync(s.tokenPath, "utf8").trim()).toBe(TOKEN_PLACEHOLDER); } finally { rmSync(dir, { recursive: true, force: true }); @@ -177,7 +174,6 @@ test("scaffoldYouTrack preserves an existing token byte-for-byte (WZ-05)", () => writeFileSync(tokenPath, "perm_abcdef123456\n", { mode: 0o600 }); const s = scaffoldYouTrack(dir, "https://youtrack.example.com", { locale: "es-CL", - timezone: "America/Santiago", }); expect(s.ok).toBe(true); expect(s.status).toBe("preserved"); diff --git a/test/workit-cli/packed-cli.test.ts b/test/workit-cli/packed-cli.test.ts index aa41c72d..89921373 100644 --- a/test/workit-cli/packed-cli.test.ts +++ b/test/workit-cli/packed-cli.test.ts @@ -67,7 +67,6 @@ const tmp = (prefix: string) => mkdtempSync(path.join(os.tmpdir(), prefix)); const PREVIEW_VALUES = { platforms: ["opencode", "cursor"], locale: "en", - timezone: "UTC", branchPreset: "gitflow", branchAllowed: "feature/*", branchProtected: "main", diff --git a/test/workit-cli/platform-install.test.ts b/test/workit-cli/platform-install.test.ts index 2df894ea..a83dae8b 100644 --- a/test/workit-cli/platform-install.test.ts +++ b/test/workit-cli/platform-install.test.ts @@ -36,7 +36,6 @@ const clean = (dir: string) => rmSync(dir, { recursive: true, force: true }); const values = (over: Partial = {}): SetupPreviewInput => ({ platforms: ["opencode"], locale: "en", - timezone: "UTC", branchPreset: "gitflow", branchAllowed: "feature/*, bugfix/*", branchProtected: "main, develop", @@ -433,7 +432,7 @@ test("file-vs-ignore: existing files merged/skipped, new files created (WZ-05/CA JSON.stringify({ baseUrl: "https://org.youtrack.cloud", meetingIssue: "ORG-1", - greetingCutoff: "11:00", + customKey: "kept", }), ); writeFileSync(ytToken, "perm_supersecret\n", { mode: 0o600 }); @@ -449,7 +448,7 @@ test("file-vs-ignore: existing files merged/skipped, new files created (WZ-05/CA expect(statusOf(result, ytJson)).toBe("Configured"); const merged = JSON.parse(readFileSync(ytJson, "utf8")); expect(merged.meetingIssue).toBe("ORG-1"); - expect(merged.greetingCutoff).toBe("11:00"); + expect(merged.customKey).toBe("kept"); expect(merged.baseUrl).toBe("https://new.example.com"); // existing token byte-for-byte preserved and reported Skipped diff --git a/test/workit-cli/upgrade.test.ts b/test/workit-cli/upgrade.test.ts index f99c03e2..80afe7ed 100644 --- a/test/workit-cli/upgrade.test.ts +++ b/test/workit-cli/upgrade.test.ts @@ -19,7 +19,6 @@ const fixture = () => { config, JSON.stringify({ locale: "en", - timezone: "UTC", trustedPaths: ["/old"], custom: { keep: true }, }), diff --git a/test/workit-cli/wizard-config.test.ts b/test/workit-cli/wizard-config.test.ts index c8ec339f..b2bf5bf4 100644 --- a/test/workit-cli/wizard-config.test.ts +++ b/test/workit-cli/wizard-config.test.ts @@ -26,7 +26,6 @@ import { buildSetupPreview, TOKEN_PLACEHOLDER, collectConfigValues, - validateTimezone, type SetupMutation, type SetupPreviewInput, } from "@/packages/workit-cli/src/logic"; @@ -41,7 +40,6 @@ import { SCREEN_PLACEHOLDERS, externalHostGuidance, platformOptions, - timezonePickerOptions, } from "@/packages/workit-cli/src/steps"; import { emptyDetection, @@ -66,7 +64,6 @@ function clean(dir: string): void { const config = (over: Partial = {}): ToolkitConfig => ({ locale: "en", localeOptions: ["en", "es-CL"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], @@ -78,7 +75,6 @@ const config = (over: Partial = {}): ToolkitConfi const values = (over: Partial = {}): SetupPreviewInput => ({ locale: "en", - timezone: "UTC", branchPreset: "gitflow", branchAllowed: "feature/*, bugfix/*", branchProtected: "main, develop", @@ -114,7 +110,8 @@ test("wizard defaults contain no organization-specific data (WZ-04/CA-14)", () = const serialized = JSON.stringify(draft.values); expect(serialized).not.toContain("enghouseamg"); expect(serialized).not.toContain("IRPT"); - expect(serialized).not.toContain("Alejandra.Flores"); + expect(serialized).not.toContain("defaultMention"); + expect(serialized).not.toContain("timezone"); expect(serialized).not.toContain("youtrack.cloud"); } finally { delete process.env.WORKFLOW_TOOLKIT_CONFIG; @@ -385,7 +382,8 @@ test("buildSetupPreview emits exact typed mutations for every section (WZ-08)", expect(ytValue).toContain("https://yt.example.com"); expect(ytValue).not.toContain("IRPT"); expect(ytValue).not.toContain("defaultMention"); - expect(ytValue).not.toContain("Alejandra.Flores"); + expect(ytValue).not.toContain("greeting"); + expect(ytValue).not.toContain("timezone"); const ws = byType("update-workspaces").find((m) => m.path.endsWith("workspaces.json")); expect((ws as { entries: unknown[] }).entries).toEqual([wsEntry("work", "/work/**")]); @@ -685,56 +683,18 @@ test("selecting a mapped row commits its BCP-47 locale through the reducer", () d = reducer(d, { type: "set", field: "locale", value: row!.locale }); expect(d.errors.locale, LOCALE_RE.test(row!.locale) ? undefined : row!.locale).toBeUndefined(); d = reducer(d, { type: "next" }); - expect(d.screen).toBe("timezone"); + expect(d.screen).toBe("branchPreset"); expect(d.values.locale).toBe("es-419"); }); -// --------------------------------------------------------------------------- -// Timezone SearchSelect (Task 4): full IANA catalog through filterOptions, -// catalog consistency with the KNOWN_TIMEZONES guard in logic.ts, and reducer -// commit. Other… keeps validateTimezone (CA-04) untouched. -// --------------------------------------------------------------------------- - -test('filterOptions("Santiago") caps at 5 rows including America/Santiago', () => { - const matches = filterOptions(timezonePickerOptions(), "Santiago"); - expect(matches.length).toBeGreaterThan(0); - expect(matches.length).toBeLessThanOrEqual(5); - expect(matches.map((m) => m.value)).toContain("America/Santiago"); -}); - -test("timezone catalog matches the KNOWN_TIMEZONES guard and contains the detected zone", () => { - const options = timezonePickerOptions(); - expect(options[options.length - 1].value).toBe("other"); - const detected = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - // The detected zone heads the list so its preselection is visible without - // typing; the remainder is the runtime's IANA set when supportedValuesOf - // exists (validateTimezone enforces membership exactly then — the same - // guard shape as logic.ts KNOWN_TIMEZONES), else the static fallback while - // validation stays open. - expect(options[0].value).toBe(detected); - const rest = options.slice(1, -1).map((option) => option.value); - if (typeof Intl.supportedValuesOf === "function") { - expect([...rest].sort()).toEqual( - Intl.supportedValuesOf("timeZone") - .filter((tz) => tz !== detected) - .sort(), - ); - } else { - expect(validateTimezone("Not/AZone")).toBeNull(); - } -}); - -test("committing a searched zone updates the draft through the reducer", () => { +test("Given the setup wizard, When locale is committed, Then it goes straight to the branch preset with no timezone step", () => { let d = createInitialDraft(config()); d = reducer(d, { type: "set", field: "platforms", value: ["opencode"] }); d = reducer(d, { type: "next" }); // -> locale - d = reducer(d, { type: "next" }); // -> timezone - d = reducer(d, { type: "set", field: "timezone", value: "America/Santiago" }); - expect(validateTimezone("America/Santiago")).toBeNull(); - expect(d.errors.timezone).toBeUndefined(); d = reducer(d, { type: "next" }); expect(d.screen).toBe("branchPreset"); - expect(d.values.timezone).toBe("America/Santiago"); + expect(reducer(d, { type: "back" }).screen).toBe("locale"); + expect(d.values).not.toHaveProperty("timezone"); }); // --------------------------------------------------------------------------- @@ -750,7 +710,6 @@ const startCustom = (): ReturnType => { ); d = reducer(d, { type: "set", field: "platforms", value: ["opencode"] }); d = reducer(d, { type: "next" }); // -> locale - d = reducer(d, { type: "next" }); // -> timezone d = reducer(d, { type: "next" }); // -> branchPreset d = reducer(d, { type: "set", field: "branchPreset", value: "custom" }); d = reducer(d, { type: "next" }); // -> branchAllowed @@ -782,7 +741,6 @@ test("none/github skip the youtrack screen in both directions; non-custom preset let g = createInitialDraft(config()); g = reducer(g, { type: "set", field: "platforms", value: ["opencode"] }); g = reducer(g, { type: "next" }); // -> locale - g = reducer(g, { type: "next" }); // -> timezone g = reducer(g, { type: "next" }); // -> branchPreset g = reducer(g, { type: "next" }); // skips branchAllowed/branchProtected expect(g.screen).toBe("issueTracker"); @@ -916,12 +874,12 @@ test("youtrack mode stays byte-identical: preview equals the legacy literal inpu // workspace-preview and hygiene-target code path. // --------------------------------------------------------------------------- -// gitflow preset walk to the vcs screen: platforms → locale → timezone → -// branchPreset → issueTracker → youtrack → vcs (six `next`s after platforms). +// gitflow preset walk to the vcs screen: platforms → locale → branchPreset → +// issueTracker → youtrack → vcs (five `next`s after platforms). const walkToVcs = (): ReturnType => { let d = createInitialDraft(config()); d = reducer(d, { type: "set", field: "platforms", value: ["opencode"] }); - for (let i = 0; i < 6; i++) d = reducer(d, { type: "next" }); + for (let i = 0; i < 5; i++) d = reducer(d, { type: "next" }); return d; }; @@ -1062,7 +1020,6 @@ test("every wizard TextInput screen carries a non-empty example placeholder (CA- for (const screen of [ "youtrack", "localeOther", - "timezoneOther", "branchAllowed", "branchProtected", "workspaceName", diff --git a/test/workit-cli/wizard-tty.test.tsx b/test/workit-cli/wizard-tty.test.tsx index 5444ba75..75e8ba29 100644 --- a/test/workit-cli/wizard-tty.test.tsx +++ b/test/workit-cli/wizard-tty.test.tsx @@ -7,7 +7,6 @@ import { Wizard as WorkitWizard, SelectList, SCREEN_PLACEHOLDERS, - timezonePickerOptions, } from "../../packages/workit-cli/src/steps"; import { renderInk } from "../shared/helpers/ink-tty"; import { REPO_ROOT } from "../shared/helpers/packages"; @@ -63,7 +62,6 @@ async function withNonGitRoot(run: () => void | Promise): Promise { const seedConfig: ToolkitConfig = { locale: "en", localeOptions: ["en", "es-CL"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], @@ -90,7 +88,6 @@ function draft(preset: BranchPreset): WizardDraft { return createInitialDraft({ locale: "en", localeOptions: ["en", "es-CL"], - timezone: "UTC", branchPolicy: { preset, allowed: [], protected: [] }, commitPolicy: { preset: "conventional" }, }); @@ -115,7 +112,6 @@ test("next advances through the sequential screens", async () => { // screen is skipped in both directions here (see the D-06 tests). const sequence: WizardScreen[] = [ "locale", - "timezone", "branchPreset", "issueTracker", "youtrack", @@ -176,15 +172,11 @@ test("back reverses through screens and skips custom branch screens when not cus test("back from a custom-value screen returns to its parent select screen", () => { let d = at("gitflow", "localeOther"); expect(reducer(d, { type: "back" }).screen).toBe("locale"); - d = at("gitflow", "timezoneOther"); - expect(reducer(d, { type: "back" }).screen).toBe("timezone"); }); -test("pickOther opens the custom-value screen for locale and timezone", () => { +test("pickOther opens the custom-value screen for locale", () => { let d = at("gitflow", "locale"); expect(reducer(d, { type: "pickOther" }).screen).toBe("localeOther"); - d = at("gitflow", "timezone"); - expect(reducer(d, { type: "pickOther" }).screen).toBe("timezoneOther"); d = at("gitflow", "vcs"); expect(reducer(d, { type: "pickOther" }).screen).toBe("vcs"); }); @@ -214,18 +206,11 @@ test("cancel and apply both terminate on the exit screen with the right flag", ( expect(applied.cancelled).toBe(false); }); -test("empty locale and timezone cannot be committed from the select screens", () => { +test("empty locale cannot be committed from the select screen", () => { let d = { ...at("gitflow", "locale"), values: { ...at("gitflow", "locale").values, locale: "" } }; d = reducer(d, { type: "next" }); expect(d.screen).toBe("locale"); expect(d.errors.locale).toContain("locale is required"); - d = { - ...at("gitflow", "timezone"), - values: { ...at("gitflow", "timezone").values, timezone: "" }, - }; - d = reducer(d, { type: "next" }); - expect(d.screen).toBe("timezone"); - expect(d.errors.timezone).toContain("timezone is required"); }); // --------------------------------------------------------------------------- @@ -239,7 +224,6 @@ test("unchanged set values return the same draft object", () => { // message and an unchanged dispatch is a true no-op. let settled = draft("custom"); settled = reducer(settled, { type: "set", field: "locale", value: "es-CL" }); - settled = reducer(settled, { type: "set", field: "timezone", value: "America/Santiago" }); settled = reducer(settled, { type: "set", field: "branchAllowed", value: "feature/*" }); settled = reducer(settled, { type: "set", field: "branchProtected", value: "main" }); settled = reducer(settled, { type: "set", field: "baseUrl", value: "https://yt.example.com" }); @@ -270,11 +254,6 @@ test("unchanged set values return the same draft object", () => { action: { type: "set", field: "platforms", value: ["opencode"] }, }, { name: "locale", state: settled, action: { type: "set", field: "locale", value: "es-CL" } }, - { - name: "timezone", - state: settled, - action: { type: "set", field: "timezone", value: "America/Santiago" }, - }, { name: "branchPreset", state: settled, @@ -410,9 +389,8 @@ test("exactly one input control is mounted on every screen", async () => { // SearchSelect owns its input handling; its display-only TextInput is // disabled and never subscribes, so the invariant holds unchanged expect(tty.inputListenerCount()).toBe(3); - await tty.keys(ENTER); // locale -> timezone + await tty.keys(ENTER); // locale -> branchPreset expect(tty.inputListenerCount()).toBe(3); - await tty.keys(ENTER); // timezone -> branchPreset await tty.keys(DOWN, ENTER); // github-flow -> issueTracker await tty.keys(ENTER); // YouTrack -> youtrack await tty.keys(ENTER); // youtrack -> vcs @@ -431,7 +409,7 @@ test("exactly one input control is mounted on every screen", async () => { }); }); -test("locale and timezone inputs are independent; revisiting shows the current value", async () => { +test("locale goes straight to the branch policy (no timezone step); revisiting shows the current value", async () => { const cleanup = withSeedConfig(seedConfig); try { const tty = await renderInk(); @@ -441,16 +419,13 @@ test("locale and timezone inputs are independent; revisiting shows the current v expect(localeFrame).not.toContain("Timezone"); await tty.keys("mx", ENTER); // search narrows to Español (México) -> commits es-MX - const tzFrame = tty.lastFrame(); - expect(tzFrame).toContain("Timezone"); - expect(tzFrame).not.toContain("es-MX"); + const presetFrame = tty.lastFrame(); + expect(presetFrame).toContain("Branch policy"); + expect(presetFrame).not.toContain("Timezone"); - // timezone search owns cold 'b'; clear the query to hand 'b' back to nav - await tty.keys("b", BACKSPACE); await tty.key("b"); // back -> locale const backFrame = tty.lastFrame(); expect(backFrame).toContain("es-MX"); - expect(backFrame).not.toContain("UTC"); tty.unmount(); } finally { cleanup(); @@ -470,7 +445,7 @@ test("a custom Other value is validated before advancing", async () => { expect(invalid).toContain("custom"); for (let i = 0; i < "en_US".length; i++) await tty.key(BACKSPACE); await tty.keys("es-MX", ENTER); - expect(tty.lastFrame()).toContain("Timezone"); + expect(tty.lastFrame()).toContain("Branch policy"); tty.unmount(); } finally { cleanup(); @@ -481,7 +456,7 @@ test("branch policy screen shows the resolved policy, not the raw preset", async const cleanup = withSeedConfig(seedConfig); try { const tty = await renderInk(); - await tty.keys(ENTER, ENTER, ENTER); // -> branchPreset + await tty.keys(ENTER, ENTER); // -> branchPreset const gitflow = tty.lastFrame(); expect(gitflow).toContain("feature/*"); expect(gitflow).toContain("main"); @@ -502,7 +477,7 @@ test("custom branch policy requires nonempty allowed and protected patterns", as }); try { const tty = await renderInk(); - await tty.keys(ENTER, ENTER, ENTER); // -> branchPreset (custom) + await tty.keys(ENTER, ENTER); // -> branchPreset (custom) await tty.keys(ENTER); // -> branchAllowed expect(tty.lastFrame()).toContain("Allowed branch patterns"); expect(tty.lastFrame()).toContain(SCREEN_PLACEHOLDERS.branchAllowed); // CA-09 wiring @@ -529,8 +504,7 @@ test("Back preserves the draft values entered so far", async () => { try { const tty = await renderInk(); await tty.keys(ENTER); // -> locale - await tty.keys("mx", ENTER); // search narrows to Español (México) -> timezone - await tty.keys(ENTER); // -> branchPreset + await tty.keys("mx", ENTER); // search narrows to Español (México) -> branchPreset await tty.keys(DOWN, ENTER); // github-flow -> issueTracker await tty.keys(ENTER); // YouTrack -> youtrack await tty.keys(ENTER); // -> vcs @@ -546,9 +520,7 @@ test("Back preserves the draft values entered so far", async () => { expect(tty.lastFrame()).toContain("Issue tracker"); await tty.keys("b"); // back -> branchPreset (custom screens skipped) expect(tty.lastFrame()).toContain("GitHub Flow"); - await tty.keys("b"); // back -> timezone - await tty.keys("b", BACKSPACE); // cold 'b' searches; clearing hands it back… - await tty.key("b"); // …then navigates back -> locale + await tty.keys("b"); // back -> locale expect(tty.lastFrame()).toContain("es-MX"); tty.unmount(); } finally { @@ -566,7 +538,7 @@ test("Escape cancels without writing anything", async () => { const exitCalls: boolean[] = []; const tty = await renderInk( exitCalls.push(complete)} />); await tty.keys(ENTER); // -> locale - await tty.keys("mx", ENTER); // -> timezone (searched pick) + await tty.keys("mx", ENTER); // -> branchPreset (searched pick) await tty.burst(ESC); // cancel (select screen): single chunk, no pending-byte race expect(exitCalls).toEqual([false]); expect(existsSync(configPath)).toBe(false); @@ -583,9 +555,9 @@ test("no competing Enter/provider race — one submit path per screen", async () const cleanup = withSeedConfig(seedConfig); try { const tty = await renderInk(); - // platforms, locale, timezone, branchPreset walks to issueTracker + // platforms, locale, branchPreset walks to issueTracker // (custom screens skipped for the gitflow seed). - await tty.keys(ENTER, ENTER, ENTER, ENTER); // -> issueTracker + await tty.keys(ENTER, ENTER, ENTER); // -> issueTracker expect(tty.lastFrame()).toContain("Issue tracker"); await tty.keys(ENTER); // issueTracker (YouTrack) -> youtrack expect(tty.lastFrame()).toContain(SCREEN_PLACEHOLDERS.youtrack); // CA-09 wiring @@ -795,7 +767,7 @@ test("project setup can be skipped after previously selecting it", async () => { const cleanup = withSeedConfig(seedConfig); const tty = await renderInk(); try { - await tty.keys(ENTER, ENTER, ENTER, DOWN, ENTER, ENTER, ENTER, ENTER, ENTER); + await tty.keys(ENTER, ENTER, DOWN, ENTER, ENTER, ENTER, ENTER, ENTER); expect(tty.lastFrame()).toContain("Step 6 — Project setup"); await tty.key("y"); expect(tty.lastFrame()).toContain("Project hygiene: yes"); @@ -839,9 +811,7 @@ test("arrows move within the filtered set and Enter commits the highlighted row" await tty.keys(DOWN, DOWN); // highlight Español (Chile) expect(tty.lastFrame()).toContain("❯ Español (Chile)"); await tty.key(ENTER); - expect(tty.lastFrame()).toContain("Timezone"); - // timezone search owns cold 'b'; clear the query to hand 'b' back to nav - await tty.keys("b", BACKSPACE); + expect(tty.lastFrame()).toContain("Branch policy"); await tty.key("b"); // back -> the select screen shows the committed value expect(tty.lastFrame()).toContain("Current: es-CL"); tty.unmount(); @@ -858,7 +828,7 @@ test("'Other…' routes to the existing validated custom-locale flow (CA-03)", a await tty.keys("other", ENTER); // query isolates Other… -> pickOther flow expect(tty.lastFrame()).toContain("custom"); await tty.keys("es-419", ENTER); // 3-digit region subtag validates via LOCALE_RE - expect(tty.lastFrame()).toContain("Timezone"); + expect(tty.lastFrame()).toContain("Branch policy"); tty.unmount(); } finally { cleanup(); @@ -908,118 +878,6 @@ test("'b' starts a search instead of walking back; once cleared it navigates bac } }); -// --------------------------------------------------------------------------- -// Timezone SearchSelect (Task 4): full IANA catalog with the detected host -// zone preselected; identical consumed-'b' semantics as locale (the wizard's -// global back handler would otherwise eat the first query character). -// --------------------------------------------------------------------------- - -const detectedTz = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; - -test("the detected zone is preselected without typing", async () => { - const cleanup = withSeedConfig(seedConfig); - try { - const tty = await renderInk(); - await tty.keys(ENTER); // -> locale - await tty.keys(ENTER); // commit highlighted locale -> timezone - const frame = tty.lastFrame(); - expect(frame).toContain("Timezone"); - expect(frame).toContain("Type to filter"); // searchable picker mounted - expect(frame).toContain(`❯ ${detectedTz()}`); // preselected, zero typing - expect(frame).not.toContain("Use current"); // fixed SelectList gone - tty.unmount(); - } finally { - cleanup(); - } -}); - -test("typing narrows the timezone picker and Enter commits the searched zone", async () => { - const cleanup = withSeedConfig(seedConfig); - try { - const tty = await renderInk(); - await tty.keys(ENTER, ENTER); // -> timezone - await tty.keys("santiago"); - const narrowed = tty.lastFrame(); - expect(narrowed).toContain("America/Santiago"); - expect(narrowed).not.toContain("Europe/London"); - await tty.key(ENTER); // commit America/Santiago -> branchPreset - expect(tty.lastFrame()).toContain("Branch policy"); - await tty.keys("b"); // back -> the picker shows the committed draft value - expect(tty.lastFrame()).toContain("Current: America/Santiago"); - tty.unmount(); - } finally { - cleanup(); - } -}); - -test("revisiting the timezone picker highlights the committed draft zone, not the host zone", async () => { - const cleanup = withSeedConfig(seedConfig); - try { - const tty = await renderInk(); - await tty.keys(ENTER, ENTER); // -> timezone - // Commit the second catalog row: guaranteed inside the first window and, - // by construction, never the detected host zone (row 0). - const committed = timezonePickerOptions()[1].value; - await tty.keys(committed.split("/")[1].toLowerCase()); // search narrows - await tty.key(ENTER); // commit -> branchPreset - expect(tty.lastFrame()).toContain("Branch policy"); - await tty.keys("b"); // walk back -> the picker - const frame = tty.lastFrame(); - expect(frame).toContain("Current: " + committed); - expect(frame).toContain(`❯ ${committed}`); // highlight follows the committed value - expect(frame).not.toContain(`❯ ${detectedTz()}`); - tty.unmount(); - } finally { - cleanup(); - } -}); - -test("'b' starts a timezone search instead of walking back; once cleared it navigates back", async () => { - const cleanup = withSeedConfig(seedConfig); - try { - const tty = await renderInk(); - await tty.keys(ENTER, ENTER); // -> timezone - await tty.key("b"); // cold 'b' must reach the query, never walk back - const searching = tty.lastFrame(); - expect(searching).toContain("Timezone"); // still the picker… - expect(searching).not.toContain("Locale"); // …never walked back - expect(searching).not.toContain("Type to search timezones…"); // live query owns the field - await tty.key("b"); // live query keeps consuming 'b' - expect(tty.lastFrame()).not.toContain("Type to search timezones…"); - for (let i = 0; i < 2; i++) await tty.key(BACKSPACE); // clear the query - const restored = tty.lastFrame(); - expect(restored).toContain("Type to search timezones…"); // full window restored… - expect(restored).toContain(`❯ ${detectedTz()}`); // …detected zone re-highlighted - await tty.key("b"); // cleared search hands 'b' back to navigation - expect(tty.lastFrame()).toContain("Locale"); - tty.unmount(); - } finally { - cleanup(); - } -}); - -test("'Other…' keeps the validated custom-timezone flow (CA-04)", async () => { - const cleanup = withSeedConfig(seedConfig); - try { - const tty = await renderInk(); - await tty.keys(ENTER, ENTER); // -> timezone - // DOWN isolates Other… ("other" also substring-matches Antarctica/Rothera) - await tty.keys("other", DOWN, ENTER); - expect(tty.lastFrame()).toContain("custom"); - expect(tty.lastFrame()).toContain(SCREEN_PLACEHOLDERS.timezoneOther); // CA-09 wiring - await tty.keys("Not/AZone", ENTER); // validateTimezone blocks unknown names - const invalid = tty.lastFrame(); - expect(invalid).toContain("unknown timezone"); - expect(invalid).toContain("custom"); - for (let i = 0; i < "Not/AZone".length; i++) await tty.key(BACKSPACE); - await tty.keys("Europe/Madrid", ENTER); - expect(tty.lastFrame()).toContain("Branch policy"); - tty.unmount(); - } finally { - cleanup(); - } -}); - test("the develop-branch editor carries its example placeholder (CA-09 wiring)", async () => { const cleanup = withSeedConfig(seedConfig); // The branchPolicy screen only mounts over a git repo; pin the resolution @@ -1029,7 +887,7 @@ test("the develop-branch editor carries its example placeholder (CA-09 wiring)", try { const tty = await renderInk(); await tty.keys(ENTER); // -> locale - await tty.keys(ENTER, ENTER, ENTER, ENTER, ENTER, ENTER); // -> workspaces + await tty.keys(ENTER, ENTER, ENTER, ENTER, ENTER); // -> workspaces await tty.keys(ENTER); // Done -> branchPolicy (repo is git) expect(tty.lastFrame()).toContain("Step 5 — Branch policy"); await tty.keys(DOWN, DOWN, ENTER); // Edit develop -> text editor screen @@ -1070,7 +928,6 @@ test("summary shows the authoritative preview and Apply completes with it", asyn const exitCalls: boolean[] = []; const tty = await renderInk( exitCalls.push(ok)} />); await tty.keys(ENTER); // -> locale - await tty.keys(ENTER); // -> timezone await tty.keys(ENTER); // -> branchPreset await tty.keys(ENTER); // -> issueTracker await tty.keys(ENTER); // YouTrack -> youtrack @@ -1125,7 +982,6 @@ test("malformed configuration blocks Apply in the TTY flow (WZ-06)", async () => const exitCalls: boolean[] = []; const tty = await renderInk( exitCalls.push(ok)} />); await tty.keys(ENTER); // -> locale - await tty.keys(ENTER); // -> timezone await tty.keys(ENTER); // -> branchPreset await tty.keys(ENTER); // -> issueTracker await tty.keys(ENTER); // YouTrack -> youtrack diff --git a/test/workit-cli/workspace-wizard.test.tsx b/test/workit-cli/workspace-wizard.test.tsx index 60dc4488..f85a12f0 100644 --- a/test/workit-cli/workspace-wizard.test.tsx +++ b/test/workit-cli/workspace-wizard.test.tsx @@ -52,7 +52,6 @@ const availableHosts = { const seedConfig: ToolkitConfig = { locale: "en", localeOptions: ["en", "es-CL"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], @@ -108,7 +107,6 @@ function draftWith(workspaces: WorkspaceConfig[]): WizardDraft { values: { platforms: [], locale: "en", - timezone: "UTC", branchPreset: "gitflow", branchAllowed: "", branchProtected: "", @@ -136,7 +134,6 @@ const entry = (name: string, glob: string): WorkspaceConfig => ({ const previewValues = (over: Partial = {}): SetupPreviewInput => ({ platforms: [], locale: "en", - timezone: "UTC", branchPreset: "gitflow", branchAllowed: "feature/*, bugfix/*", branchProtected: "main, develop", @@ -148,10 +145,10 @@ const previewValues = (over: Partial = {}): SetupPreviewInput }); async function gotoWorkspaces(tty: Awaited>) { - // platforms SPACE+ENTER, locale/timezone ENTERs, branchPreset ENTER (gitflow + // platforms SPACE+ENTER, locale ENTER, branchPreset ENTER (gitflow // skips the custom screens), issueTracker ENTER (YouTrack default), // youtrack ENTER on the empty base URL. - await tty.keys(SPACE, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER); + await tty.keys(SPACE, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER); expect(tty.lastFrame()).toContain("Step 5 — Workspaces"); } @@ -1004,7 +1001,7 @@ test("choosing None skips the baseUrl screen: summary shows — and applies no y const tty = await renderInk( exitCalls.push(ok)} detection={availableHosts} />, ); - await tty.keys(SPACE, ENTER, ENTER, ENTER, ENTER); // -> issueTracker + await tty.keys(SPACE, ENTER, ENTER, ENTER); // -> issueTracker await tty.keys(DOWN, DOWN, DOWN, ENTER); // None -> vcs (youtrack skipped) await tty.keys(ENTER); // gitlab -> workspaces await tty.keys(ENTER); // Done -> project @@ -1050,7 +1047,7 @@ test("choosing GitHub Issues defaults new workspaces to github with issues linke }} />, ); - await tty.keys(SPACE, ENTER, ENTER, ENTER, ENTER); // -> issueTracker + await tty.keys(SPACE, ENTER, ENTER, ENTER); // -> issueTracker await tty.keys(DOWN, ENTER); // GitHub Issues -> vcs await tty.keys(DOWN, ENTER); // github provider -> workspaces await tty.keys(UP, ENTER); // Use current project -> entry added @@ -1095,8 +1092,8 @@ test( try { withConfigDir(configDir); const tty = await renderInk(); - // platforms SPACE+ENTER, then ENTERs to vcs (locale/timezone/preset/tracker/youtrack) - await tty.keys(SPACE, ENTER, ENTER, ENTER, ENTER, ENTER, ENTER); // -> vcs + // platforms SPACE+ENTER, then ENTERs to vcs (locale/preset/tracker/youtrack) + await tty.keys(SPACE, ENTER, ENTER, ENTER, ENTER, ENTER); // -> vcs await tty.keys(ENTER); // vcs -> base-path prompt (env unset) expect(tty.lastFrame()).toContain("Workspace root"); await tty.keys(ENTER); // empty submit refuses to advance diff --git a/test/workit-core/branch-policy.test.ts b/test/workit-core/branch-policy.test.ts index 69dfe3db..a98e058b 100644 --- a/test/workit-core/branch-policy.test.ts +++ b/test/workit-core/branch-policy.test.ts @@ -29,7 +29,6 @@ beforeAll(() => { { locale: "en", localeOptions: ["en"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], @@ -713,7 +712,6 @@ test( writeConfig({ locale: "en", localeOptions: ["en"], - timezone: "UTC", branchPolicy: { preset: "custom", allowed: ["codex/*"], protected: ["main"] }, commitPolicy: { preset: "conventional" }, }); diff --git a/test/workit-core/branch.test.ts b/test/workit-core/branch.test.ts index e8e88da9..4ca3c6e3 100644 --- a/test/workit-core/branch.test.ts +++ b/test/workit-core/branch.test.ts @@ -24,7 +24,6 @@ beforeAll(() => { { locale: "en", localeOptions: ["en"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], diff --git a/test/workit-core/commit-flavor.test.ts b/test/workit-core/commit-flavor.test.ts index 6d3d5681..3bd5cb1a 100644 --- a/test/workit-core/commit-flavor.test.ts +++ b/test/workit-core/commit-flavor.test.ts @@ -14,7 +14,6 @@ import type { ToolkitConfig } from "@/packages/workit-core/src/core/config"; const baseConfig = (over: Partial = {}): ToolkitConfig => ({ locale: "en", localeOptions: ["en"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: [], protected: [] }, commitPolicy: { preset: "conventional" }, ...over, diff --git a/test/workit-core/config-conversion.test.ts b/test/workit-core/config-conversion.test.ts index 76bd9957..c44ef891 100644 --- a/test/workit-core/config-conversion.test.ts +++ b/test/workit-core/config-conversion.test.ts @@ -17,10 +17,10 @@ test("conversion preview is read only and redacts secrets", () => { expect(managedBytes(fixture)).toEqual(before); }); -test("supported preference mappings carry locale timezone and branchPolicy forward", () => { +test("supported preference mappings carry locale and branchPolicy forward; the removed timezone is not mapped", () => { const preview = previewConversion({ configDir: fixture.configDir }); expect(preview.mappings.find((m) => m.key === "locale")?.to).toBe("es-CL"); - expect(preview.mappings.find((m) => m.key === "timezone")?.to).toBe("America/Santiago"); + expect(preview.mappings.some((m) => m.key === "timezone")).toBe(false); expect(preview.mappings.find((m) => m.key === "branchPolicy")?.to).toMatchObject({ preset: "custom", }); diff --git a/test/workit-core/config.test.ts b/test/workit-core/config.test.ts index 4254bfc6..41da1e41 100644 --- a/test/workit-core/config.test.ts +++ b/test/workit-core/config.test.ts @@ -53,7 +53,6 @@ test("writeConfig + readConfig round trip", () => { const cfg: ToolkitConfig = { locale: "es-CL", localeOptions: ["en", "es-CL"], - timezone: "America/Santiago", branchPolicy: { preset: "custom", allowed: ["feature/*", "codex/*"], protected: ["main"] }, commitPolicy: { preset: "conventional" }, }; @@ -117,7 +116,6 @@ test("resolveBranchPolicy honors preset and custom overrides", () => { writeConfig({ locale: "en", localeOptions: ["en"], - timezone: "UTC", branchPolicy: { preset: "custom", allowed: ["codex/*"], protected: ["main"] }, commitPolicy: { preset: "conventional" }, }); @@ -226,7 +224,6 @@ test("RL-02/CA-23: mergeConfigValues routes every consumer through mergePreset", const current: ToolkitConfig = { locale: "en", localeOptions: ["en"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: ["feature/*"], protected: ["main"] }, commitPolicy: { preset: "conventional" }, }; @@ -252,3 +249,26 @@ test("RL-02/CA-23: mergeConfigValues routes every consumer through mergePreset", protected: ["main", "develop"], }); }); + +test("Given an old config.json that still has a timezone, When it is read, Then it loads and the field is ignored", () => { + const dir = mkdtempSync(path.join(os.tmpdir(), "wf-config-legacy-tz-")); + try { + writeFileSync( + path.join(dir, "config.json"), + JSON.stringify({ + locale: "es-CL", + timezone: "America/Santiago", + branchPolicy: { preset: "github-flow" }, + }), + "utf8", + ); + const result = readConfigTyped(dir); + expect(result.status).toBe("valid"); + expect(result.config?.locale).toBe("es-CL"); + expect(result.config?.branchPolicy.preset).toBe("github-flow"); + expect(result.config).not.toHaveProperty("timezone"); + expect(readConfigFromDir(dir)).not.toHaveProperty("timezone"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/workit-core/cursor-install-mcp.test.ts b/test/workit-core/cursor-install-mcp.test.ts index 0e6f3282..8f088a6b 100644 --- a/test/workit-core/cursor-install-mcp.test.ts +++ b/test/workit-core/cursor-install-mcp.test.ts @@ -24,7 +24,6 @@ const clean = (dir: string) => rmSync(dir, { recursive: true, force: true }); const values = (over: Partial = {}): SetupPreviewInput => ({ platforms: ["cursor"], locale: "en", - timezone: "UTC", branchPreset: "gitflow", branchAllowed: "feature/*, bugfix/*", branchProtected: "main, develop", diff --git a/test/workit-core/opencode-setup-pin.test.ts b/test/workit-core/opencode-setup-pin.test.ts index ab36d780..3f68a3e9 100644 --- a/test/workit-core/opencode-setup-pin.test.ts +++ b/test/workit-core/opencode-setup-pin.test.ts @@ -19,7 +19,6 @@ const clean = (dir: string) => rmSync(dir, { recursive: true, force: true }); const values = (): SetupPreviewInput => ({ platforms: ["opencode"], locale: "en", - timezone: "UTC", branchPreset: "gitflow", branchAllowed: "feature/*, bugfix/*", branchProtected: "main, develop", diff --git a/test/workit-core/repo.test.ts b/test/workit-core/repo.test.ts index e553b567..fac10fa3 100644 --- a/test/workit-core/repo.test.ts +++ b/test/workit-core/repo.test.ts @@ -23,7 +23,6 @@ beforeAll(() => { { locale: "en", localeOptions: ["en"], - timezone: "UTC", branchPolicy: { preset: "gitflow", allowed: ["feature/*", "bugfix/*", "hotfix/*", "release/*"], diff --git a/test/workit-core/templates.test.ts b/test/workit-core/templates.test.ts index 9e06b388..38404a54 100644 --- a/test/workit-core/templates.test.ts +++ b/test/workit-core/templates.test.ts @@ -66,7 +66,7 @@ test("writeTemplate requires confirmed", () => { test("listTemplates reports sources", () => { const dir = cfgDir(); try { - writeTemplate("greeting", "hola", true); + writeTemplate("greeting", "Hello", true); const list = listTemplates(); const issue = list.find((t) => t.name === "issue-update"); const greeting = list.find((t) => t.name === "greeting"); diff --git a/test/workit-core/youtrack-core.test.ts b/test/workit-core/youtrack-core.test.ts index 984e0efd..e89b20db 100644 --- a/test/workit-core/youtrack-core.test.ts +++ b/test/workit-core/youtrack-core.test.ts @@ -23,7 +23,6 @@ const cfg = (overrides: Record = {}) => ({ const scripts = (overrides: Partial = {}): YouTrackScripts => ({ config: () => ({ data: cfg() }), - greeting: () => ({ stdout: "Hola", exitCode: 0, stderr: "" }), parseDuration: (text: string) => ({ minutes: text === "30m" ? 30 : 0 }), api: (args: string[]) => ({ ok: true, args }), ...overrides, @@ -90,17 +89,11 @@ test("context falls back to meeting issue and spec/plan YouTrack ref", () => { } }); -test("context errors: config failure, greeting failure, missing issue", () => { +test("context errors: config failure, missing issue", () => { expect( context({ workspace_root: os.tmpdir() }, scripts({ config: () => ({ error: "cfg down" }) })) .error, ).toBe("cfg down"); - expect( - context( - { workspace_root: os.tmpdir() }, - scripts({ greeting: () => ({ stdout: "", exitCode: 1, stderr: "no greet" }) }), - ).error, - ).toContain("no greet"); expect( context( { workspace_root: os.tmpdir() }, @@ -175,14 +168,34 @@ test("logTime validates, formats date arg, and delegates", async () => { ).toBe("api down"); }); -test("buildDraft composes header, greeting, project, notes, and facts", () => { +test("Given an old youtrack.json with greeting and mention fields, When context is read, Then it succeeds and emits no greeting or mention", () => { + const legacy = cfg({ + timezone: "America/Santiago", + defaultMention: "Some.Person", + greetings: { morning: "good morning", afternoon: "good afternoon" }, + greetingCutoff: "12:00", + }); + const result = context( + { issue_id: "NSR-40", workspace_root: os.tmpdir() }, + scripts({ config: () => ({ data: legacy }) }), + ); + expect(result.error).toBeUndefined(); + expect(result.issueId).toBe("NSR-40"); + expect(result).not.toHaveProperty("greeting"); + const serialized = JSON.stringify(result); + for (const banned of ["defaultMention", "Some.Person", "greetings", "greetingCutoff"]) + expect(serialized, banned).not.toContain(banned); + expect(result.config.timezone).toBe("America/Santiago"); +}); + +test("buildDraft composes a neutral header, optional opener, project, notes, and facts", () => { const bare = buildDraft({ issueId: "NSR-1" }); - expect(bare.markdown).toBe("# Actualización\n\n"); + expect(bare.markdown).toBe("# Update\n\n"); const full = buildDraft({ issueId: "NSR-1", projectName: "Tracer", - userNotes: "Terminé el modulo", - greeting: "Hola equipo", + userNotes: "Finished the module", + greeting: "Caller-supplied opener", includeProjectOpener: true, includeFacts: true, facts: { @@ -190,9 +203,10 @@ test("buildDraft composes header, greeting, project, notes, and facts", () => { git_commits: ["abc123 fix"], }, }); - expect(full.markdown).toContain("Hola equipo"); - expect(full.markdown).toContain("Hoy estuve full con Tracer"); - expect(full.markdown).toContain("Terminé el modulo"); + expect(full.markdown.startsWith("# Update\n\nCaller-supplied opener\n\nProject: Tracer")).toBe( + true, + ); + expect(full.markdown).toContain("Finished the module"); expect(full.markdown).toContain("- Task 1: done"); expect(full.markdown).toContain("- abc123 fix"); }); diff --git a/test/workit-core/youtrack-work-date.test.ts b/test/workit-core/youtrack-work-date.test.ts new file mode 100644 index 00000000..a917e0b2 --- /dev/null +++ b/test/workit-core/youtrack-work-date.test.ts @@ -0,0 +1,117 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { youTrackWorkDateMs } from "@/packages/workit-core/src/core/youtrack"; + +// The work-item date must be the same UTC-midnight epoch for a given calendar +// day no matter which timezone the process runs in. Each case runs in a fresh +// process with TZ set, because runtimes cache the default Intl timezone. +const ZONES = ["Asia/Tokyo", "America/Santiago", "UTC", "Pacific/Kiritimati", "Pacific/Pago_Pago"]; +const MODULE = path.resolve(import.meta.dir, "../../packages/workit-core/src/core/youtrack.ts"); + +let dir: string; +let savedConfig: string | undefined; + +type WorkDate = ReturnType; + +const workDateInTz = (tz: string, raw: string): WorkDate => { + const script = `const { youTrackWorkDateMs } = await import(${JSON.stringify(MODULE)}); +console.log(JSON.stringify(youTrackWorkDateMs(${JSON.stringify(raw)})));`; + const out = Bun.spawnSync([process.execPath, "-e", script], { + env: { ...process.env, TZ: tz }, + stdout: "pipe", + stderr: "pipe", + }); + if (out.exitCode !== 0) throw new Error(out.stderr.toString()); + return JSON.parse(out.stdout.toString().trim().split("\n").at(-1) ?? "null") as WorkDate; +}; + +const writeYouTrackJson = (value: Record): void => { + const file = path.join(dir, "youtrack.json"); + writeFileSync(file, JSON.stringify(value), "utf8"); + process.env.WORKFLOW_YOUTRACK_CONFIG = file; +}; + +beforeEach(() => { + dir = mkdtempSync(path.join(os.tmpdir(), "wf-yt-date-")); + savedConfig = process.env.WORKFLOW_YOUTRACK_CONFIG; +}); + +afterEach(() => { + if (savedConfig === undefined) delete process.env.WORKFLOW_YOUTRACK_CONFIG; + else process.env.WORKFLOW_YOUTRACK_CONFIG = savedConfig; + rmSync(dir, { recursive: true, force: true }); +}); + +test("Given an explicit YYYY-MM-DD date, When the process runs in any timezone, Then dateMs is that day's UTC midnight", () => { + writeYouTrackJson({ baseUrl: "https://yt.example.test" }); + for (const tz of ZONES) { + const out = workDateInTz(tz, "2026-10-03"); + expect("data" in out, tz).toBe(true); + if (!("data" in out)) continue; + expect(out.data.dateMs, tz).toBe(Date.UTC(2026, 9, 3)); + expect(out.data.localDate, tz).toBe("2026-10-03"); + } +}); + +test("Given an old youtrack.json with a timezone, When the process runs east of UTC, Then the explicit date is not shifted a day back", () => { + writeYouTrackJson({ baseUrl: "https://yt.example.test", timezone: "America/Santiago" }); + for (const tz of ZONES) { + const out = workDateInTz(tz, "2026-10-03"); + expect("data" in out && out.data.dateMs, tz).toBe(Date.UTC(2026, 9, 3)); + } +}); + +test("Given no configured timezone, When the date is auto, Then it is today's calendar day in the process timezone at UTC midnight", () => { + writeYouTrackJson({ baseUrl: "https://yt.example.test" }); + for (const tz of ZONES) { + const today = () => + new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); + const before = today(); + const out = { result: workDateInTz(tz, "auto"), today: today() }; + if (before !== out.today) continue; // crossed midnight in that zone mid-test + expect("data" in out.result, tz).toBe(true); + if (!("data" in out.result)) continue; + const [y, m, d] = out.today.split("-").map(Number); + expect(out.result.data.localDate, tz).toBe(out.today); + expect(out.result.data.dateMs, tz).toBe(Date.UTC(y, m - 1, d)); + expect(out.result.data.timezone, tz).toBe(tz); + } +}); + +test("Given a youtrack.json timezone override, When the date is auto, Then today's day is taken in that zone and stored at UTC midnight", () => { + writeYouTrackJson({ baseUrl: "https://yt.example.test", timezone: "Asia/Tokyo" }); + const out = workDateInTz("America/Santiago", "auto"); + const today = new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Tokyo", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(new Date()); + const [y, m, d] = today.split("-").map(Number); + expect("data" in out && out.data).toEqual({ + dateMs: Date.UTC(y, m - 1, d), + timezone: "Asia/Tokyo", + localDate: today, + }); +}); + +test("Given no youtrack.json, When the date is auto, Then the process timezone is used instead of a hard-coded zone", () => { + process.env.WORKFLOW_YOUTRACK_CONFIG = path.join(dir, "missing.json"); + const out = workDateInTz("Asia/Tokyo", "auto"); + expect("data" in out && out.data.timezone).toBe("Asia/Tokyo"); +}); + +test("Given an invalid YYYY-MM-DD date, When it is resolved, Then an error is returned instead of NaN", () => { + writeYouTrackJson({ baseUrl: "https://yt.example.test" }); + for (const raw of ["2026-13-01", "2026-02-30", "not-a-date"]) { + const out = youTrackWorkDateMs(raw); + expect("error" in out, raw).toBe(true); + } +}); diff --git a/test/workit-opencode/task-tools.test.ts b/test/workit-opencode/task-tools.test.ts index 31493c0a..95880c36 100644 --- a/test/workit-opencode/task-tools.test.ts +++ b/test/workit-opencode/task-tools.test.ts @@ -396,7 +396,7 @@ test("OpenCode YouTrack context rejects spec and plan paths outside the workspac try { writeFileSync( configPath, - JSON.stringify({ baseUrl: "https://youtrack.example.test", greetings: { morning: "hola" } }), + JSON.stringify({ baseUrl: "https://youtrack.example.test", greetings: { morning: "hello" } }), ); writeFileSync(outsideSpec, "**YouTrack:** NSR-40\n"); symlinkSync(outsideSpec, link); From 27e911ba140848a3a0777de54605a6f1fbce4f10 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 14:50:16 -0300 Subject: [PATCH 2/4] fix(youtrack): guard against hard-coded greeting strings and isolate draft test - Add a Given/When/Then scan over packages/ that fails if a hard-coded greeting, @mention key or default timezone comes back. - Run the buildDraft test in a temporary config dir so a user's own templates/issue-update.md override cannot leak into the assertion. Co-Authored-By: Claude Opus 5.5 --- test/workit-core/youtrack-core.test.ts | 42 +++++++++++---------- test/workit-core/youtrack-work-date.test.ts | 22 ++++++++++- 2 files changed, 44 insertions(+), 20 deletions(-) diff --git a/test/workit-core/youtrack-core.test.ts b/test/workit-core/youtrack-core.test.ts index e89b20db..99aaad10 100644 --- a/test/workit-core/youtrack-core.test.ts +++ b/test/workit-core/youtrack-core.test.ts @@ -2,6 +2,7 @@ import { expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; +import { withTempConfigDir } from "@/test/shared/helpers/env"; import { buildDraft, context, @@ -189,26 +190,29 @@ test("Given an old youtrack.json with greeting and mention fields, When context }); test("buildDraft composes a neutral header, optional opener, project, notes, and facts", () => { - const bare = buildDraft({ issueId: "NSR-1" }); - expect(bare.markdown).toBe("# Update\n\n"); - const full = buildDraft({ - issueId: "NSR-1", - projectName: "Tracer", - userNotes: "Finished the module", - greeting: "Caller-supplied opener", - includeProjectOpener: true, - includeFacts: true, - facts: { - progress_excerpt: ["Task 1: done"], - git_commits: ["abc123 fix"], - }, + // Isolated config dir: the bundled template is under test, never a user override. + withTempConfigDir(() => { + const bare = buildDraft({ issueId: "NSR-1" }); + expect(bare.markdown).toBe("# Update\n\n"); + const full = buildDraft({ + issueId: "NSR-1", + projectName: "Tracer", + userNotes: "Finished the module", + greeting: "Caller-supplied opener", + includeProjectOpener: true, + includeFacts: true, + facts: { + progress_excerpt: ["Task 1: done"], + git_commits: ["abc123 fix"], + }, + }); + expect(full.markdown.startsWith("# Update\n\nCaller-supplied opener\n\nProject: Tracer")).toBe( + true, + ); + expect(full.markdown).toContain("Finished the module"); + expect(full.markdown).toContain("- Task 1: done"); + expect(full.markdown).toContain("- abc123 fix"); }); - expect(full.markdown.startsWith("# Update\n\nCaller-supplied opener\n\nProject: Tracer")).toBe( - true, - ); - expect(full.markdown).toContain("Finished the module"); - expect(full.markdown).toContain("- Task 1: done"); - expect(full.markdown).toContain("- abc123 fix"); }); test("postUpdate validates confirmed, issueId, and markdown", async () => { diff --git a/test/workit-core/youtrack-work-date.test.ts b/test/workit-core/youtrack-work-date.test.ts index a917e0b2..2c37557e 100644 --- a/test/workit-core/youtrack-work-date.test.ts +++ b/test/workit-core/youtrack-work-date.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { youTrackWorkDateMs } from "@/packages/workit-core/src/core/youtrack"; @@ -115,3 +115,23 @@ test("Given an invalid YYYY-MM-DD date, When it is resolved, Then an error is re expect("error" in out, raw).toBe(true); } }); + +test("Given the shipped packages, When their sources and templates are scanned, Then no hard-coded greeting, mention or default timezone remains", () => { + const root = path.resolve(import.meta.dir, "../../packages"); + const banned = + /\b(?:Hola|buenos d[ií]as|buenas tardes|Hoy estuve|defaultMention|greetingCutoff|WORKFLOW_YT_MENTION|WORKFLOW_YT_TIMEZONE)\b|America\/Santiago/; + const hits: string[] = []; + const walk = (dir: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist") continue; + const full = path.join(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (/\.(?:ts|tsx|md|json)$/.test(entry.name)) { + const text = readFileSync(full, "utf8"); + if (banned.test(text)) hits.push(path.relative(root, full)); + } + } + }; + walk(root); + expect(hits).toEqual([]); +}); From ac3307fffce3b507a1f6f5aa733149ffe73187c1 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 15:18:36 -0300 Subject: [PATCH 3/4] fix(youtrack): drop org defaults and greeting template; surface work timezone Review follow-ups for #151. - No organization defaults: the token-create link has no fallback host (createUrl null plus an error naming youtrack.json when baseUrl is missing); the meetings normalizer no longer falls back to a built-in issue and asks for meetingIssue instead; meeting work-item text comes from the entry's workItemText, then youtrack.json meetingWorkItemText, then the neutral "Meetings". Existing configs that set them keep working. - Greeting leftovers: delete the greeting template (core + cli assets), drop "greeting" from TemplateName and the greeting param / {{greetingSection}} from buildDraft and the bundled template. Unknown placeholders in user templates now render empty. - Numeric dateMs: localDate is the UTC calendar day of the epoch, so it round-trips with the YYYY-MM-DD path. - Effective timezone is visible: youTrackWorkDateMs returns timezoneSource, context() returns workTimezone {timezone, source}, and resolved youtrack.update/meeting/time descriptors carry workDate. - Replace the hand-picked string blocklist with a source scan for concrete *.youtrack.cloud hosts and literal issue-id fallbacks. - Docs: README and AGENTS.md describe the youtrack.json-only settings and the surfaced timezone. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 11 ++- README.md | 31 +++++-- .../workit-cli/assets/templates/greeting.md | 1 - .../assets/templates/issue-update.md | 1 - .../src/core/external-action-effects.ts | 10 +++ packages/workit-core/src/core/init.ts | 4 +- packages/workit-core/src/core/templates.ts | 4 +- .../workit-core/src/core/youtrack-tools.ts | 26 +++++- packages/workit-core/src/core/youtrack.ts | 82 ++++++++++++++----- packages/workit-core/templates/greeting.md | 1 - .../workit-core/templates/issue-update.md | 1 - test/workit-cli/wizard-config.test.ts | 5 +- test/workit-core/templates.test.ts | 7 +- test/workit-core/youtrack-core.test.ts | 50 ++++++++++- test/workit-core/youtrack-work-date.test.ts | 32 ++++++-- test/workit-core/youtrack.test.ts | 48 ++++++++--- 16 files changed, 244 insertions(+), 70 deletions(-) delete mode 100644 packages/workit-cli/assets/templates/greeting.md delete mode 100644 packages/workit-core/templates/greeting.md diff --git a/AGENTS.md b/AGENTS.md index 99ebf552..63a2ff3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,10 +33,13 @@ matching). Caller-unattested MCP keeps optional mutations unavailable. The CLI an approval receipt. YouTrack is an optional tracker adapter. Never hard-code greetings, `@mentions`, -people's names, organization URLs or a default timezone in core or tests; -comment wording belongs in the editable `issue-update` template. Work-item -dates are calendar days computed with `Date.UTC` (process timezone unless -`youtrack.json` sets `timezone`), so they never shift with the host zone. +people's names, organization hosts, issue ids, meeting wording or a default +timezone in core or tests; they come from `youtrack.json`, and comment wording +belongs in the editable `issue-update` template (a source scan in +`test/workit-core/youtrack-work-date.test.ts` guards hosts and issue-id +defaults). Work-item dates are calendar days computed with `Date.UTC` (process +timezone unless `youtrack.json` sets `timezone`), and the effective zone is +surfaced as `workTimezone` / `workDate` so agents can see it. Read-only `workit_context` is available on OpenCode; Pi and the CLI use `context.read` for the diff --git a/README.md b/README.md index 012f6a98..445fe626 100644 --- a/README.md +++ b/README.md @@ -53,14 +53,29 @@ multiple repositories. Press `y` only to add hygiene files and gitignore entries to the displayed directory. Locale keeps its existing selection until changed. `workit doctor` checks the configured installation. -YouTrack is optional. Work-item dates are the calendar day in the process -timezone (honouring `TZ`), sent as that day's UTC midnight. To pin a different -zone, add an IANA `timezone` to `youtrack.json`. Workit adds no greeting or -`@mention` to comments; the comment text comes from the editable -`issue-update` template (`templates/issue-update.md` in the config directory -overrides the bundled neutral one). Older configs load unchanged: a `timezone` -in the global `config.json`, and `defaultMention`, `greetings` or -`greetingCutoff` in `youtrack.json`, are ignored. +YouTrack is optional and everything organization-specific comes from +`youtrack.json`; there are no built-in hosts, issues or wording: + +- `baseUrl` is required. Without it the token-create link is unavailable and + the error names the config file. +- `meetingIssue` / `meetingIssues` choose the meeting issue(s); meetings mode + asks for one when none is configured. Meeting time uses each entry's + `workItemText`, else a global `meetingWorkItemText`, else `Meetings`. +- Work-item dates are a calendar day sent as that day's UTC midnight. "auto" + means today in the process timezone (honouring `TZ`); an IANA `timezone` in + `youtrack.json` overrides it. YouTrack context reports the effective zone as + `workTimezone: { timezone, source }` (`source` is `youtrack.json` or + `process`), and resolved `youtrack.update` / `youtrack.meeting` / + `youtrack.time` actions carry `workDate: { localDate, timezone, + timezoneSource }`. An explicit epoch `dateMs` is labelled with its UTC day. +- Workit adds no greeting or `@mention` to comments. The text comes from the + editable `issue-update` template (`templates/issue-update.md` in the config + directory overrides the bundled neutral one); placeholders Workit does not + fill, such as a legacy `{{greetingSection}}`, render empty. + +Older configs load unchanged: a `timezone` in the global `config.json`, and +`defaultMention`, `greetings` or `greetingCutoff` in `youtrack.json`, are +ignored. `workit cutover` is for migrating legacy installations. diff --git a/packages/workit-cli/assets/templates/greeting.md b/packages/workit-cli/assets/templates/greeting.md deleted file mode 100644 index 3266dacc..00000000 --- a/packages/workit-cli/assets/templates/greeting.md +++ /dev/null @@ -1 +0,0 @@ -{{greetingText}} diff --git a/packages/workit-cli/assets/templates/issue-update.md b/packages/workit-cli/assets/templates/issue-update.md index 36d9a618..6f3da264 100644 --- a/packages/workit-cli/assets/templates/issue-update.md +++ b/packages/workit-cli/assets/templates/issue-update.md @@ -1,5 +1,4 @@ # Update -{{greetingSection}} {{projectSection}} {{userNotesSection}} {{progressSection}} diff --git a/packages/workit-core/src/core/external-action-effects.ts b/packages/workit-core/src/core/external-action-effects.ts index 3a4a2842..86f56907 100644 --- a/packages/workit-core/src/core/external-action-effects.ts +++ b/packages/workit-core/src/core/external-action-effects.ts @@ -1714,6 +1714,11 @@ const resolveExternalActionAtRoot = ( issueId: request.payload.issueId, marker, dateMs: date.data.dateMs, + workDate: { + localDate: date.data.localDate, + timezone: date.data.timezone, + timezoneSource: date.data.timezoneSource, + }, workText, minutes: request.payload.minutes, steps: ["time"], @@ -1757,6 +1762,11 @@ const resolveExternalActionAtRoot = ( issueId: request.payload.issueId, marker, dateMs: date.data.dateMs, + workDate: { + localDate: date.data.localDate, + timezone: date.data.timezone, + timezoneSource: date.data.timezoneSource, + }, ...(request.operation === "youtrack.update" ? { commentText: annotated } : {}), workText: annotated, ...(request.payload.minutes !== undefined diff --git a/packages/workit-core/src/core/init.ts b/packages/workit-core/src/core/init.ts index a598f722..476023ac 100644 --- a/packages/workit-core/src/core/init.ts +++ b/packages/workit-core/src/core/init.ts @@ -7,7 +7,7 @@ import { writeFileExclusive } from "./safe-write"; import { resolveWorkspace, workspacesPath } from "./workspaces"; import { applyWorkspaceBranchPolicy } from "./setup"; import { vcsConfig, vcsVerifyToken } from "./vcs-config"; -import { youTrackTokenCreateUrl, youTrackVerifyToken } from "./youtrack"; +import { meetingWorkItemText, youTrackTokenCreateUrl, youTrackVerifyToken } from "./youtrack"; const TOKEN_PLACEHOLDER = "YOUR_TOKEN_HERE"; @@ -80,7 +80,7 @@ export function initStatusData(configDirPath = configDir()): Record key, issue: iss, label: item?.label ?? iss, - workItemText: item?.workItemText ?? "Reuniones", + workItemText: meetingWorkItemText(ytParsed, item), url: item?.url ?? (base && iss ? `${base}/issue/${iss}` : null), }); } diff --git a/packages/workit-core/src/core/templates.ts b/packages/workit-core/src/core/templates.ts index a6caa0ea..dc20cab8 100644 --- a/packages/workit-core/src/core/templates.ts +++ b/packages/workit-core/src/core/templates.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { configDir } from "./config"; import { assetRoot } from "./package-root"; -export type TemplateName = "issue-update" | "greeting" | "headers"; +export type TemplateName = "issue-update" | "headers"; const repoRoot = assetRoot(); @@ -38,7 +38,7 @@ export const listTemplates = (): { source: "config" | "repo" | "missing"; path: string; }[] => - (["issue-update", "greeting", "headers"] as TemplateName[]).map((name) => { + (["issue-update", "headers"] as TemplateName[]).map((name) => { const cfg = templatePath(name); const repoFile = path.join(repoRoot, "templates", `${name}.md`); if (existsSync(cfg)) return { name, source: "config", path: cfg }; diff --git a/packages/workit-core/src/core/youtrack-tools.ts b/packages/workit-core/src/core/youtrack-tools.ts index 7ea08fb8..1f7f1d98 100644 --- a/packages/workit-core/src/core/youtrack-tools.ts +++ b/packages/workit-core/src/core/youtrack-tools.ts @@ -6,6 +6,7 @@ import { configDir, isConfigObject } from "./config"; import { context as legacyContext, logTime as legacyLogTime, + meetingWorkItemText, parseDuration as legacyParseDuration, postUpdate as legacyPostUpdate, verifyYouTrackToken, @@ -206,18 +207,35 @@ export async function logTimeUpdate( } export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue { - if (!value || mode !== "meetings") return value; + if (!value || mode !== "meetings" || value.error) return value; + // Operations may return the legacy `{ data }` envelope; normalize inside it. + if (value.data && typeof value.data === "object" && !Array.isArray(value.data)) { + const inner = normalizeContext(value.data as Record, mode); + return inner && inner.error + ? { ...value, ...inner, data: undefined } + : { ...value, data: inner }; + } const config = value.config as Record | undefined; - const issue = String(config?.meetingIssue || "IRPT-12"); const { meetingIssues: _meetingIssues, ...singleMeetingConfig } = config ?? {}; const options = Array.isArray(value.meetingOptions) ? (value.meetingOptions as Array>) : []; + // The meeting issue comes only from youtrack.json — there is no built-in one. + const configured = typeof config?.meetingIssue === "string" ? config.meetingIssue : ""; + const resolved = typeof value.issueId === "string" ? value.issueId : ""; + const firstOption = typeof options[0]?.issue === "string" ? options[0].issue : ""; + const issue = configured || resolved || firstOption; + if (!issue) + return { + ...value, + error: "no meeting issue configured — set meetingIssue in youtrack.json", + requiresIssueInput: true, + }; const selected = options.find((option) => option.issue === issue) ?? { key: "general", issue, label: issue, - workItemText: "Reuniones", + workItemText: meetingWorkItemText(config), }; return { ...value, @@ -225,6 +243,6 @@ export function normalizeContext(value: LegacyValue, mode?: string): LegacyValue meetingOptions: [selected], requiresMeetingChoice: false, issueId: issue, - workItemText: selected.workItemText ?? "Reuniones", + workItemText: meetingWorkItemText(config, selected), }; } diff --git a/packages/workit-core/src/core/youtrack.ts b/packages/workit-core/src/core/youtrack.ts index 032d1418..f682cde9 100644 --- a/packages/workit-core/src/core/youtrack.ts +++ b/packages/workit-core/src/core/youtrack.ts @@ -116,6 +116,33 @@ function tzParts(date: Date, tz: string): { y: string; m: string; d: string } { // `timezone` override; there is no hard-coded default zone. const processTimezone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC"; +export type WorkTimezone = { timezone: string; source: "youtrack.json" | "process" }; + +/** The timezone that decides a work item's calendar day: an explicit + * youtrack.json `timezone` wins, otherwise the process timezone (TZ). */ +export const effectiveWorkTimezone = ( + cfg: Record | null | undefined, +): WorkTimezone => { + const configured = cfg?.timezone; + return typeof configured === "string" && configured.trim() + ? { timezone: configured.trim(), source: "youtrack.json" } + : { timezone: processTimezone(), source: "process" }; +}; + +/** Neutral default work-item text for meeting time; youtrack.json can set a + * global `meetingWorkItemText` or a per-meeting `workItemText`. */ +export const DEFAULT_MEETING_WORK_ITEM_TEXT = "Meetings"; + +export const meetingWorkItemText = ( + cfg: Record | null | undefined, + item?: Record | null, +): string => { + if (typeof item?.workItemText === "string" && item.workItemText) return item.workItemText; + if (typeof cfg?.meetingWorkItemText === "string" && cfg.meetingWorkItemText) + return cfg.meetingWorkItemText; + return DEFAULT_MEETING_WORK_ITEM_TEXT; +}; + // A YouTrack work-item date is the calendar day at UTC midnight. Computing it // with Date.UTC keeps it independent of the process timezone (Date.parse of a // local "T00:00:00" shifted it a day back east of UTC). @@ -143,11 +170,18 @@ export function youTrackParseDuration( /** Port of scripts/youtrack/work-date-ms.sh — resolve work-item date as epoch ms. * The calendar day comes from youtrack.json `timezone` when set (optional * override), otherwise from the process timezone. */ -export function youTrackWorkDateMs( - dateRaw: string, -): { data: { dateMs: number; timezone: string; localDate: string } } | { error: string } { +export function youTrackWorkDateMs(dateRaw: string): + | { + data: { + dateMs: number; + timezone: string; + timezoneSource: WorkTimezone["source"]; + localDate: string; + }; + } + | { error: string } { const cfgPath = youTrackConfigPath(); - let tz = processTimezone(); + let effective = effectiveWorkTimezone(null); // Missing file is a legitimate unconfigured state (reader: "missing" keeps // defaults); a parseable non-object is malformed and must propagate the // exact-path error instead of silently defaulting the timezone. @@ -161,27 +195,30 @@ export function youTrackWorkDateMs( if (!isConfigObject(parsed)) { return { error: `${cfgPath} is not a JSON object` }; } - const configured = (parsed as Record).timezone; - if (typeof configured === "string" && configured.trim()) tz = configured.trim(); + effective = effectiveWorkTimezone(parsed as Record); } + const tz = effective.timezone; + const meta = { timezone: tz, timezoneSource: effective.source }; const raw = dateRaw || "auto"; try { if (raw === "auto") { const { y, m, d } = tzParts(new Date(), tz); const dateMs = utcMidnight(Number(y), Number(m), Number(d)); if (dateMs === null) return { error: "could not resolve date" }; - return { data: { dateMs, timezone: tz, localDate: `${y}-${m}-${d}` } }; + return { data: { dateMs, ...meta, localDate: `${y}-${m}-${d}` } }; } if (/^\d+$/.test(raw)) { + // An explicit epoch is already a work-item date (UTC midnight of the + // day); its label is that UTC calendar day so it round-trips exactly. const dt = new Date(Number(raw)); - const { y, m, d } = tzParts(dt, tz); - return { data: { dateMs: Number(raw), timezone: tz, localDate: `${y}-${m}-${d}` } }; + if (Number.isNaN(dt.getTime())) return { error: `invalid date: ${raw}` }; + return { data: { dateMs: Number(raw), ...meta, localDate: dt.toISOString().slice(0, 10) } }; } const match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(raw); const dateMs = match ? utcMidnight(Number(match[1]), Number(match[2]), Number(match[3])) : null; if (!match || dateMs === null) return { error: `invalid date: ${raw} (expected YYYY-MM-DD)` }; const localDate = `${match[1]}-${match[2].padStart(2, "0")}-${match[3].padStart(2, "0")}`; - return { data: { dateMs, timezone: tz, localDate } }; + return { data: { dateMs, ...meta, localDate } }; } catch (err) { return { error: err instanceof Error ? err.message : "could not resolve date" }; } @@ -373,12 +410,14 @@ export function youTrackTokenCreateUrl(): { data: Record } { defaults.description ?? "OpenCode workit — /wk-issue-update and /wk-meetings", ); const scopes = Array.isArray(defaults.scopes) ? defaults.scopes : ["YouTrack"]; - const base = String(config.baseUrl ?? "https://enghouseamg.youtrack.cloud").replace(/\/+$/, ""); + // No organization default: without a configured baseUrl there is no + // account page to link to, so createUrl is null and `error` says why. + const base = String(config.baseUrl ?? "").replace(/\/+$/, ""); const tokenFile = String( config.tokenFile ?? path.join(path.dirname(loaded.path), "youtrack.token"), ); const tab = String(defaults.profileTab ?? "account-security"); - const createUrl = `${base}/users/me?${new URLSearchParams({ tab })}`; + const createUrl = base ? `${base}/users/me?${new URLSearchParams({ tab })}` : null; const docsUrl = "https://www.jetbrains.com/help/youtrack/cloud/manage-permanent-token.html"; return { data: { @@ -389,6 +428,9 @@ export function youTrackTokenCreateUrl(): { data: Record } { createUrl, docsUrl, prefillSupported: false, + ...(base + ? {} + : { error: `baseUrl missing in ${loaded.path} — set your YouTrack base URL first` }), steps: [ "Profile → Account Security → **New token** (or open createUrl)", `Name: **${name}**`, @@ -476,7 +518,7 @@ function meetingOptionsFromConfig(cfg: any): Record[] { key, issue: item.issue, label: item.label ?? item.issue, - workItemText: item.workItemText ?? "Reuniones", + workItemText: meetingWorkItemText(cfg, item), url: item.url ?? (base && item.issue ? `${base}/issue/${item.issue}` : null), })); } @@ -486,7 +528,7 @@ function meetingOptionsFromConfig(cfg: any): Record[] { key: "general", issue, label: "General meetings", - workItemText: "Reuniones", + workItemText: meetingWorkItemText(cfg), url: base && issue ? `${base}/issue/${issue}` : null, }, ]; @@ -507,7 +549,7 @@ const readOnlyYouTrackConfig = (cfg: Record): Record = const safe: Record = {}; const baseUrl = publicUrl(cfg.baseUrl); if (baseUrl) safe.baseUrl = baseUrl; - for (const key of ["timezone", "meetingIssue"]) + for (const key of ["timezone", "meetingIssue", "meetingWorkItemText"]) if (typeof cfg[key] === "string") safe[key] = cfg[key]; if ( cfg.meetingIssues && @@ -559,6 +601,7 @@ export function context( if (mode === "meetings" && !issue_id && !issue_url && !issue_ref) { return { config: safeConfig, + workTimezone: effectiveWorkTimezone(safeConfig), mode: "meetings", requiresMeetingChoice: true, meetingOptions, @@ -589,6 +632,7 @@ export function context( return { config: safeConfig, + workTimezone: effectiveWorkTimezone(safeConfig), issueId: issue, issueUrl, mode: mode ?? (selectedMeeting ? "meetings" : "task"), @@ -698,7 +742,6 @@ export function buildDraft({ issueId, projectName, userNotes, - greeting, facts, includeProjectOpener, includeFacts, @@ -706,18 +749,16 @@ export function buildDraft({ issueId: string; projectName?: string; userNotes?: string; - greeting?: string; facts?: any; includeProjectOpener?: boolean; includeFacts?: boolean; }): Record { // The wording lives in the editable issue-update template (config - // templates/issue-update.md overrides the bundled neutral one). `greeting` - // is an optional caller-supplied opening line; there is no built-in text. + // templates/issue-update.md overrides the bundled neutral one). Placeholders + // this build does not fill (e.g. a legacy {{greetingSection}}) render empty. const tpl = readTemplate("issue-update").content; const para = (value: string): string => (value ? `\n\n${value}` : ""); const sections: Record = { - "{{greetingSection}}": (greeting ?? "").trim(), "{{projectSection}}": includeProjectOpener && projectName ? `Project: ${projectName}` : "", "{{userNotesSection}}": (userNotes ?? "").trim(), "{{progressSection}}": @@ -732,6 +773,7 @@ export function buildDraft({ let filled = tpl; for (const [placeholder, value] of Object.entries(sections)) filled = filled.replaceAll(placeholder, para(value)); + filled = filled.replace(/\{\{[A-Za-z0-9_]+\}\}/g, ""); const collapsed = filled.replace(/\n{3,}/g, "\n\n").trimEnd(); // Bare draft keeps the header's trailing blank line (matches legacy output); // drafts with sections end right after the last one. diff --git a/packages/workit-core/templates/greeting.md b/packages/workit-core/templates/greeting.md deleted file mode 100644 index 3266dacc..00000000 --- a/packages/workit-core/templates/greeting.md +++ /dev/null @@ -1 +0,0 @@ -{{greetingText}} diff --git a/packages/workit-core/templates/issue-update.md b/packages/workit-core/templates/issue-update.md index 36d9a618..6f3da264 100644 --- a/packages/workit-core/templates/issue-update.md +++ b/packages/workit-core/templates/issue-update.md @@ -1,5 +1,4 @@ # Update -{{greetingSection}} {{projectSection}} {{userNotesSection}} {{progressSection}} diff --git a/test/workit-cli/wizard-config.test.ts b/test/workit-cli/wizard-config.test.ts index b2bf5bf4..439d98f3 100644 --- a/test/workit-cli/wizard-config.test.ts +++ b/test/workit-cli/wizard-config.test.ts @@ -108,8 +108,7 @@ test("wizard defaults contain no organization-specific data (WZ-04/CA-14)", () = const draft = createInitialDraft(config()); expect(draft.values.baseUrl).toBe(""); const serialized = JSON.stringify(draft.values); - expect(serialized).not.toContain("enghouseamg"); - expect(serialized).not.toContain("IRPT"); + expect(serialized).not.toContain("meetingIssue"); expect(serialized).not.toContain("defaultMention"); expect(serialized).not.toContain("timezone"); expect(serialized).not.toContain("youtrack.cloud"); @@ -380,7 +379,7 @@ test("buildSetupPreview emits exact typed mutations for every section (WZ-08)", expect(yt).toBeDefined(); const ytValue = JSON.stringify((yt as { value: unknown }).value); expect(ytValue).toContain("https://yt.example.com"); - expect(ytValue).not.toContain("IRPT"); + expect(ytValue).not.toContain("meetingIssue"); expect(ytValue).not.toContain("defaultMention"); expect(ytValue).not.toContain("greeting"); expect(ytValue).not.toContain("timezone"); diff --git a/test/workit-core/templates.test.ts b/test/workit-core/templates.test.ts index 38404a54..beae021a 100644 --- a/test/workit-core/templates.test.ts +++ b/test/workit-core/templates.test.ts @@ -66,12 +66,13 @@ test("writeTemplate requires confirmed", () => { test("listTemplates reports sources", () => { const dir = cfgDir(); try { - writeTemplate("greeting", "Hello", true); + writeTemplate("headers", "## Attachments", true); const list = listTemplates(); const issue = list.find((t) => t.name === "issue-update"); - const greeting = list.find((t) => t.name === "greeting"); + const headers = list.find((t) => t.name === "headers"); expect(issue?.source).toBe("repo"); - expect(greeting?.source).toBe("config"); + expect(headers?.source).toBe("config"); + expect(list.map((t) => t.name)).toEqual(["issue-update", "headers"]); } finally { cleanupEnv(); rmSync(dir, { recursive: true, force: true }); diff --git a/test/workit-core/youtrack-core.test.ts b/test/workit-core/youtrack-core.test.ts index 99aaad10..1c6bb09b 100644 --- a/test/workit-core/youtrack-core.test.ts +++ b/test/workit-core/youtrack-core.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { withTempConfigDir } from "@/test/shared/helpers/env"; +import { writeTemplate } from "@/packages/workit-core/src/core/templates"; import { buildDraft, context, @@ -77,7 +78,7 @@ test("context falls back to meeting issue and spec/plan YouTrack ref", () => { scripts(), ); expect(meetings.mode).toBe("meetings"); - expect(meetings.workItemText).toBe("Reuniones"); + expect(meetings.workItemText).toBe("Meetings"); const root = mkdtempSync(path.join(os.tmpdir(), "wf-yt-paths-")); try { @@ -125,6 +126,35 @@ test("context uses meetingIssues map with custom labels and urls", () => { expect(result.meetingOptions[1].url).toBe("https://custom/MEET-3"); }); +test("Given youtrack.json meeting text settings, When meeting context is read, Then the configured text is used and the default is neutral", () => { + const read = (data: Record) => + context( + { mode: "meetings", workspace_root: os.tmpdir() }, + scripts({ config: () => ({ data: cfg(data) }) }), + ).meetingOptions; + expect(read({})[0].workItemText).toBe("Meetings"); + expect(read({ meetingWorkItemText: "Team sync" })[0].workItemText).toBe("Team sync"); + expect( + read({ + meetingWorkItemText: "Team sync", + meetingIssues: { a: { issue: "MEET-2", workItemText: "Planning" }, b: { issue: "MEET-3" } }, + }).map((o: { workItemText: string }) => o.workItemText), + ).toEqual(["Planning", "Team sync"]); +}); + +test("Given a youtrack.json with or without timezone, When context is read, Then it surfaces the effective work-item timezone and its source", () => { + const configured = context( + { issue_id: "NSR-1", workspace_root: os.tmpdir() }, + scripts({ config: () => ({ data: cfg({ timezone: "Asia/Tokyo" }) }) }), + ); + expect(configured.workTimezone).toEqual({ timezone: "Asia/Tokyo", source: "youtrack.json" }); + const fallback = context({ issue_id: "NSR-1", workspace_root: os.tmpdir() }, scripts()); + expect(fallback.workTimezone).toEqual({ + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + source: "process", + }); +}); + test("parseDuration delegates and maps errors", () => { const ok = scripts({ parseDuration: () => ({ data: { minutes: 30 } }) }); expect(parseDuration("30m", os.tmpdir(), ok)).toEqual({ minutes: 30 }); @@ -189,7 +219,7 @@ test("Given an old youtrack.json with greeting and mention fields, When context expect(result.config.timezone).toBe("America/Santiago"); }); -test("buildDraft composes a neutral header, optional opener, project, notes, and facts", () => { +test("buildDraft composes a neutral header, project, notes, and facts", () => { // Isolated config dir: the bundled template is under test, never a user override. withTempConfigDir(() => { const bare = buildDraft({ issueId: "NSR-1" }); @@ -198,7 +228,6 @@ test("buildDraft composes a neutral header, optional opener, project, notes, and issueId: "NSR-1", projectName: "Tracer", userNotes: "Finished the module", - greeting: "Caller-supplied opener", includeProjectOpener: true, includeFacts: true, facts: { @@ -206,7 +235,7 @@ test("buildDraft composes a neutral header, optional opener, project, notes, and git_commits: ["abc123 fix"], }, }); - expect(full.markdown.startsWith("# Update\n\nCaller-supplied opener\n\nProject: Tracer")).toBe( + expect(full.markdown.startsWith("# Update\n\nProject: Tracer\n\nFinished the module")).toBe( true, ); expect(full.markdown).toContain("Finished the module"); @@ -215,6 +244,19 @@ test("buildDraft composes a neutral header, optional opener, project, notes, and }); }); +test("Given a user issue-update template with a legacy {{greetingSection}}, When a draft is built, Then it renders and the unknown placeholder is empty", () => { + withTempConfigDir(() => { + writeTemplate( + "issue-update", + "# Status\n{{greetingSection}}\n{{userNotesSection}}\n{{someFuturePlaceholder}}\n", + true, + ); + const draft = buildDraft({ issueId: "NSR-1", userNotes: "Shipped the fix" }); + expect(draft.markdown).toBe("# Status\n\nShipped the fix"); + expect(draft.markdown).not.toContain("{{"); + }); +}); + test("postUpdate validates confirmed, issueId, and markdown", async () => { expect((await postUpdate({ confirmed: false, issueId: "NSR-1", markdown: "x" })).error).toContain( "confirmed", diff --git a/test/workit-core/youtrack-work-date.test.ts b/test/workit-core/youtrack-work-date.test.ts index 2c37557e..7a86cdde 100644 --- a/test/workit-core/youtrack-work-date.test.ts +++ b/test/workit-core/youtrack-work-date.test.ts @@ -98,6 +98,7 @@ test("Given a youtrack.json timezone override, When the date is auto, Then today expect("data" in out && out.data).toEqual({ dateMs: Date.UTC(y, m - 1, d), timezone: "Asia/Tokyo", + timezoneSource: "youtrack.json", localDate: today, }); }); @@ -106,6 +107,7 @@ test("Given no youtrack.json, When the date is auto, Then the process timezone i process.env.WORKFLOW_YOUTRACK_CONFIG = path.join(dir, "missing.json"); const out = workDateInTz("Asia/Tokyo", "auto"); expect("data" in out && out.data.timezone).toBe("Asia/Tokyo"); + expect("data" in out && out.data.timezoneSource).toBe("process"); }); test("Given an invalid YYYY-MM-DD date, When it is resolved, Then an error is returned instead of NaN", () => { @@ -116,19 +118,37 @@ test("Given an invalid YYYY-MM-DD date, When it is resolved, Then an error is re } }); -test("Given the shipped packages, When their sources and templates are scanned, Then no hard-coded greeting, mention or default timezone remains", () => { +test("Given an epoch dateMs, When it is resolved in any timezone, Then localDate is its UTC calendar day so it round-trips", () => { + writeYouTrackJson({ baseUrl: "https://yt.example.test", timezone: "America/Santiago" }); + const epoch = Date.UTC(2026, 9, 3); + for (const tz of ZONES) { + const out = workDateInTz(tz, String(epoch)); + expect("data" in out && out.data.localDate, tz).toBe("2026-10-03"); + const back = workDateInTz(tz, "data" in out ? out.data.localDate : ""); + expect("data" in back && back.data.dateMs, tz).toBe(epoch); + } +}); + +// Guard for the AGENTS.md rule: shipped source must not carry organization +// specifics — a concrete YouTrack Cloud host or a literal issue id used as a +// default. Example hosts (example.*) and docs/help URLs are allowed. +test("Given the shipped package sources, When they are scanned, Then no organization YouTrack host or literal issue-id default remains", () => { const root = path.resolve(import.meta.dir, "../../packages"); - const banned = - /\b(?:Hola|buenos d[ií]as|buenas tardes|Hoy estuve|defaultMention|greetingCutoff|WORKFLOW_YT_MENTION|WORKFLOW_YT_TIMEZONE)\b|America\/Santiago/; + const orgHost = /https?:\/\/(?!example\.)[a-z0-9-]+\.youtrack\.cloud/i; + // A quoted ABC-123 literal used as a fallback value (`?? "X-1"`, `|| "X-1"`). + const issueDefault = /(?:\?\?|\|\|)\s*["'`][A-Z][A-Z0-9]+-\d+["'`]/; const hits: string[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name === "node_modules" || entry.name === "dist") continue; + if (["node_modules", "dist", "build"].includes(entry.name)) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); - else if (/\.(?:ts|tsx|md|json)$/.test(entry.name)) { + else if ( + /\.(?:ts|tsx|md|json)$/.test(entry.name) && + full.includes(`${path.sep}src${path.sep}`) + ) { const text = readFileSync(full, "utf8"); - if (banned.test(text)) hits.push(path.relative(root, full)); + if (orgHost.test(text) || issueDefault.test(text)) hits.push(path.relative(root, full)); } } }; diff --git a/test/workit-core/youtrack.test.ts b/test/workit-core/youtrack.test.ts index 4961a80b..3f0685e2 100644 --- a/test/workit-core/youtrack.test.ts +++ b/test/workit-core/youtrack.test.ts @@ -297,13 +297,13 @@ test.skipIf(process.platform === "win32")("bundled YouTrack scripts honor XDG_CO JSON.stringify({ tokenFile: tokenPath, baseUrl: "https://youtrack.example.test", - meetingIssue: "IRPT-12", + meetingIssue: "MEET-1", }), ); withNeutralXdg(xdg, () => { const out = youTrackConfigLoad(); - expect("data" in out ? out.data.meetingIssue : null).toBe("IRPT-12"); + expect("data" in out ? out.data.meetingIssue : null).toBe("MEET-1"); }); }); @@ -458,17 +458,36 @@ test("token helper runtime output uses OpenCode-neutral descriptions", () => { } }); +test("Given a youtrack.json without baseUrl, When the token-create link is built, Then there is no organization default and a clear error", () => { + const root = mkdtempSync(path.join(os.tmpdir(), "wf-token-nobase-")); + const config = path.join(root, "youtrack.json"); + writeFileSync(config, JSON.stringify({})); + const previous = process.env.WORKFLOW_YOUTRACK_CONFIG; + process.env.WORKFLOW_YOUTRACK_CONFIG = config; + try { + const output = youTrackTokenCreateUrl().data; + expect(output.createUrl).toBeNull(); + expect(output.error).toContain("baseUrl missing"); + expect(output.error).toContain(config); + expect(JSON.stringify(output)).not.toMatch(/youtrack\.cloud/); + } finally { + if (previous === undefined) delete process.env.WORKFLOW_YOUTRACK_CONFIG; + else process.env.WORKFLOW_YOUTRACK_CONFIG = previous; + rmSync(root, { recursive: true, force: true }); + } +}); + test("meeting context exposes only the configured meetingIssue", () => { expect( normalizeContext( { config: { - meetingIssue: "IRPT-12", - meetingIssues: { web: { issue: "NSXFT-21" } }, + meetingIssue: "MEET-1", + meetingIssues: { web: { issue: "MEET-9" } }, }, meetingOptions: [ - { key: "general", issue: "IRPT-12", label: "General", workItemText: "Reuniones" }, - { key: "web", issue: "NSXFT-21", label: "Web", workItemText: "Reuniones web" }, + { key: "general", issue: "MEET-1", label: "General", workItemText: "Meetings" }, + { key: "web", issue: "MEET-9", label: "Web", workItemText: "Web meetings" }, ], requiresMeetingChoice: true, issueId: null, @@ -476,16 +495,25 @@ test("meeting context exposes only the configured meetingIssue", () => { "meetings", ), ).toEqual({ - config: { meetingIssue: "IRPT-12" }, + config: { meetingIssue: "MEET-1" }, meetingOptions: [ - { key: "general", issue: "IRPT-12", label: "General", workItemText: "Reuniones" }, + { key: "general", issue: "MEET-1", label: "General", workItemText: "Meetings" }, ], requiresMeetingChoice: false, - issueId: "IRPT-12", - workItemText: "Reuniones", + issueId: "MEET-1", + workItemText: "Meetings", }); }); +test("Given no meeting issue in youtrack.json, When meeting context is normalized, Then it asks for one instead of using a built-in issue", () => { + const out = normalizeContext( + { config: { baseUrl: "https://yt.example.test" }, meetingOptions: [], issueId: null }, + "meetings", + ) as Record; + expect(out.error).toContain("meetingIssue"); + expect(out.requiresIssueInput).toBe(true); +}); + test("bundled API failures never expose the token or authorization header", async () => { const root = mkdtempSync(path.join(os.tmpdir(), "wf-youtrack-redact-")); const tokenPath = path.join(root, "youtrack.token"); From 000327b6091eca6bcb7e621c71656a915472b762 Mon Sep 17 00:00:00 2001 From: Cristhofer Pincetti Date: Sat, 3 Oct 2026 15:29:08 -0300 Subject: [PATCH 4/4] fix(youtrack): keep work date out of the hashed approval descriptor workDate was inside descriptorPayload.resolved, which externalActionDescriptor hashes, so the same request and dateMs produced a different descriptor under a different process timezone. An action approved in one process (e.g. TZ=America/Santiago) then failed with "approved external action target changed before execution" in another (TZ=UTC), and actions approved before the field existed could never match. - Move workDate to a display-only ResolvedExternalAction.workDate beside descriptorPayload; `workit action --preview` reports it. - Tests (subprocesses with TZ set): the descriptor is identical across Santiago/Tokyo/UTC and has no workDate; a youtrack.time approved under Santiago executes under UTC; an old-shape youtrack.update (no workDate) still matches. All three fail on the previous commit. - Tighten the org-specifics guard: 2+ char project keys, standard identifiers (UTF-8, SHA-256, ...) excluded, issue ids in issue fields (`meetingIssue: "ABC-12"`) and hard-coded IANA region zones flagged; hit and non-hit cases are tested. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 9 +- README.md | 8 +- packages/workit-cli/src/task.ts | 1 + .../src/core/external-action-effects.ts | 28 +++-- .../youtrack-descriptor-tz.test.ts | 116 ++++++++++++++++++ test/workit-core/youtrack-work-date.test.ts | 57 +++++++-- 6 files changed, 190 insertions(+), 29 deletions(-) create mode 100644 test/workit-core/youtrack-descriptor-tz.test.ts diff --git a/AGENTS.md b/AGENTS.md index 63a2ff3c..b8d6a3d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,10 +36,11 @@ YouTrack is an optional tracker adapter. Never hard-code greetings, `@mentions`, people's names, organization hosts, issue ids, meeting wording or a default timezone in core or tests; they come from `youtrack.json`, and comment wording belongs in the editable `issue-update` template (a source scan in -`test/workit-core/youtrack-work-date.test.ts` guards hosts and issue-id -defaults). Work-item dates are calendar days computed with `Date.UTC` (process -timezone unless `youtrack.json` sets `timezone`), and the effective zone is -surfaced as `workTimezone` / `workDate` so agents can see it. +`test/workit-core/youtrack-work-date.test.ts` guards hosts, issue-id literals +and hard-coded zones). Work-item dates are calendar days computed with +`Date.UTC` (process timezone unless `youtrack.json` sets `timezone`). The +effective zone is surfaced as `workTimezone` / `workDate` for agents, but never +inside the hashed approval descriptor, which must not vary by process timezone. Read-only `workit_context` is available on OpenCode; Pi and the CLI use `context.read` for the diff --git a/README.md b/README.md index 445fe626..85e245c9 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,11 @@ YouTrack is optional and everything organization-specific comes from means today in the process timezone (honouring `TZ`); an IANA `timezone` in `youtrack.json` overrides it. YouTrack context reports the effective zone as `workTimezone: { timezone, source }` (`source` is `youtrack.json` or - `process`), and resolved `youtrack.update` / `youtrack.meeting` / - `youtrack.time` actions carry `workDate: { localDate, timezone, - timezoneSource }`. An explicit epoch `dateMs` is labelled with its UTC day. + `process`). Resolved `youtrack.update` / `youtrack.meeting` / + `youtrack.time` actions (and `workit action --preview`) report + `workDate: { localDate, timezone, timezoneSource }` beside the approval + descriptor, never inside it, so an approval matches in any process + timezone. An explicit epoch `dateMs` is labelled with its UTC day. - Workit adds no greeting or `@mention` to comments. The text comes from the editable `issue-update` template (`templates/issue-update.md` in the config directory overrides the bundled neutral one); placeholders Workit does not diff --git a/packages/workit-cli/src/task.ts b/packages/workit-cli/src/task.ts index c91d5547..1e4a30d0 100644 --- a/packages/workit-cli/src/task.ts +++ b/packages/workit-cli/src/task.ts @@ -673,6 +673,7 @@ export async function runActionCommand(argv: string[], deps: TaskCliDeps = {}): operation: normalized.operation, descriptor, payload: normalized.payload, + ...(resolved.data.workDate ? { workDate: resolved.data.workDate } : {}), }); if (json) jsonResult(outOf(deps), result); else printHuman(result, deps); diff --git a/packages/workit-core/src/core/external-action-effects.ts b/packages/workit-core/src/core/external-action-effects.ts index 86f56907..f3d539af 100644 --- a/packages/workit-core/src/core/external-action-effects.ts +++ b/packages/workit-core/src/core/external-action-effects.ts @@ -479,12 +479,28 @@ export const actionProposalQuestion = ( : proposal; }; +export type WorkDateInfo = { + localDate: string; + timezone: string; + timezoneSource: "youtrack.json" | "process"; +}; + export type ResolvedExternalAction = { request: ExternalActionRequest; descriptorPayload: unknown; marker?: string; + /** Display-only YouTrack work-item date context. Deliberately outside + * descriptorPayload: the descriptor is the hashed approval binding and must + * not depend on the process timezone of whoever resolves it. */ + workDate?: WorkDateInfo; }; +const workDateOf = (data: WorkDateInfo): WorkDateInfo => ({ + localDate: data.localDate, + timezone: data.timezone, + timezoneSource: data.timezoneSource, +}); + export type HostingReadEvidence = { outcome: "succeeded" | "unknown"; evidenceDigest: string; @@ -1707,6 +1723,7 @@ const resolveExternalActionAtRoot = ( return success(null, null, { request, marker, + workDate: workDateOf(date.data), descriptorPayload: { ...request.payload, resolved: { @@ -1714,11 +1731,6 @@ const resolveExternalActionAtRoot = ( issueId: request.payload.issueId, marker, dateMs: date.data.dateMs, - workDate: { - localDate: date.data.localDate, - timezone: date.data.timezone, - timezoneSource: date.data.timezoneSource, - }, workText, minutes: request.payload.minutes, steps: ["time"], @@ -1755,6 +1767,7 @@ const resolveExternalActionAtRoot = ( return success(null, null, { request, marker, + workDate: workDateOf(date.data), descriptorPayload: { ...request.payload, resolved: { @@ -1762,11 +1775,6 @@ const resolveExternalActionAtRoot = ( issueId: request.payload.issueId, marker, dateMs: date.data.dateMs, - workDate: { - localDate: date.data.localDate, - timezone: date.data.timezone, - timezoneSource: date.data.timezoneSource, - }, ...(request.operation === "youtrack.update" ? { commentText: annotated } : {}), workText: annotated, ...(request.payload.minutes !== undefined diff --git a/test/workit-core/youtrack-descriptor-tz.test.ts b/test/workit-core/youtrack-descriptor-tz.test.ts new file mode 100644 index 00000000..9d1e592c --- /dev/null +++ b/test/workit-core/youtrack-descriptor-tz.test.ts @@ -0,0 +1,116 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// The external-action descriptor is the hashed approval binding. It must be +// identical no matter which process timezone resolves it, so an action approved +// in one process (TZ=America/Santiago) still matches when it executes in +// another (TZ=UTC). Each phase runs in its own process with TZ set, because +// runtimes cache the default Intl timezone. +const EFFECTS = path.resolve( + import.meta.dir, + "../../packages/workit-core/src/core/external-action-effects.ts", +); +const ACTION = path.resolve( + import.meta.dir, + "../../packages/workit-core/src/core/external-action.ts", +); + +let root: string; + +beforeEach(() => { + root = mkdtempSync(path.join(os.tmpdir(), "wf-yt-desc-tz-")); + const tokenPath = path.join(root, "youtrack.token"); + writeFileSync(tokenPath, "test-token\n"); + chmodSync(tokenPath, 0o600); + // No youtrack.json timezone: the process timezone decides "auto". + writeFileSync( + path.join(root, "youtrack.json"), + JSON.stringify({ baseUrl: "https://yt.example.test", tokenFile: tokenPath }), + ); +}); + +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const runInTz = (tz: string, script: string, extraEnv: Record = {}): any => { + const out = Bun.spawnSync([process.execPath, "-e", script], { + cwd: root, + env: { + ...process.env, + TZ: tz, + WORKFLOW_YOUTRACK_CONFIG: path.join(root, "youtrack.json"), + WORKFLOW_YT_WRITE: "1", + ...extraEnv, + }, + stdout: "pipe", + stderr: "pipe", + }); + if (out.exitCode !== 0) throw new Error(out.stderr.toString()); + return JSON.parse(out.stdout.toString().trim().split("\n").at(-1) ?? "null"); +}; + +const resolveScript = (request: unknown): string => ` +const { resolveExternalActionRequest } = await import(${JSON.stringify(EFFECTS)}); +const { externalActionDescriptor } = await import(${JSON.stringify(ACTION)}); +const resolved = resolveExternalActionRequest(${JSON.stringify(root)}, ${JSON.stringify(request)}); +if (!resolved.ok) { console.log(JSON.stringify({ error: resolved })); process.exit(0); } +console.log(JSON.stringify({ + resolved: resolved.data, + descriptor: externalActionDescriptor(resolved.data.request.operation, resolved.data.descriptorPayload), +}));`; + +const executeScript = (approved: unknown): string => ` +const { executeResolvedExternalAction } = await import(${JSON.stringify(EFFECTS)}); +globalThis.fetch = async (input, init) => ({ + ok: true, + status: 200, + text: async () => JSON.stringify({ id: "item-1", idReadable: "ABC-1" }), +}); +const result = await executeResolvedExternalAction(${JSON.stringify(approved)}, ${JSON.stringify(root)}); +console.log(JSON.stringify(result));`; + +const TIME_REQUEST = { + operation: "youtrack.time", + payload: { issueId: "ABC-1", minutes: 30, text: "work", dateMs: Date.UTC(2026, 9, 3) }, +}; + +test("Given the same youtrack.time request, When it is resolved under different process timezones, Then the hashed descriptor is identical and carries no workDate", () => { + const santiago = runInTz("America/Santiago", resolveScript(TIME_REQUEST)); + const tokyo = runInTz("Asia/Tokyo", resolveScript(TIME_REQUEST)); + const utc = runInTz("UTC", resolveScript(TIME_REQUEST)); + expect(santiago.error).toBeUndefined(); + expect(santiago.descriptor).toBe(tokyo.descriptor); + expect(santiago.descriptor).toBe(utc.descriptor); + // Old (pre-workDate) descriptor shape: the hashed payload has no workDate. + expect(santiago.resolved.descriptorPayload.resolved).not.toHaveProperty("workDate"); + expect(santiago.descriptor).not.toContain("workDate"); + // The effective zone is still surfaced, beside the hashed payload. + expect(santiago.resolved.workDate).toEqual({ + localDate: "2026-10-03", + timezone: "America/Santiago", + timezoneSource: "process", + }); + expect(utc.resolved.workDate.timezone).toBe("UTC"); +}); + +test("Given a youtrack.time action approved under TZ=America/Santiago, When it executes under TZ=UTC with the same dateMs, Then the approved target still matches", () => { + const approved = runInTz("America/Santiago", resolveScript(TIME_REQUEST)).resolved; + const result = runInTz("UTC", executeScript(approved)); + expect(JSON.stringify(result)).not.toContain("target changed"); + expect(result.ok).toBe(true); +}); + +test("Given an approved youtrack.update in the old descriptor shape (no workDate), When it executes under another timezone, Then it still matches", () => { + const request = { + operation: "youtrack.update", + payload: { issueId: "ABC-1", markdown: "Approved update" }, + }; + const approved = runInTz("America/Santiago", resolveScript(request)).resolved; + // Strip the display-only field: a descriptor recorded before it existed. + const { workDate: _display, ...oldShape } = approved; + expect(oldShape.descriptorPayload.resolved).not.toHaveProperty("workDate"); + const result = runInTz("UTC", executeScript(oldShape)); + expect(JSON.stringify(result)).not.toContain("target changed"); + expect(result.ok).toBe(true); +}); diff --git a/test/workit-core/youtrack-work-date.test.ts b/test/workit-core/youtrack-work-date.test.ts index 7a86cdde..c431f693 100644 --- a/test/workit-core/youtrack-work-date.test.ts +++ b/test/workit-core/youtrack-work-date.test.ts @@ -130,25 +130,58 @@ test("Given an epoch dateMs, When it is resolved in any timezone, Then localDate }); // Guard for the AGENTS.md rule: shipped source must not carry organization -// specifics — a concrete YouTrack Cloud host or a literal issue id used as a -// default. Example hosts (example.*) and docs/help URLs are allowed. -test("Given the shipped package sources, When they are scanned, Then no organization YouTrack host or literal issue-id default remains", () => { +// specifics. It flags (1) a concrete YouTrack Cloud host (example.* allowed), +// (2) a literal issue id used as a fallback (`?? "ABC-12"`, `|| "ABC-12"`) or +// as an issue field value (`meetingIssue: "ABC-12"`), and (3) a hard-coded +// IANA region zone used as a fallback or timezone field value. Standard +// identifiers that look like issue ids (UTF-8, SHA-256, ...) are not issues. +const NOT_ISSUE_KEYS = new Set(["UTF", "SHA", "ISO", "RFC", "AES", "TLS", "SSL", "HTTP", "ES"]); +const ORG_HOST = /https?:\/\/(?!example\.)[a-z0-9-]+\.youtrack\.cloud/gi; +const ISSUE_LITERAL = + /(?:\?\?|\|\||\b(?:meetingIssue|issue|issueId)\s*:)\s*["'`]([A-Z][A-Z0-9]+)-\d+["'`]/g; +const ZONE_LITERAL = + /(?:\?\?|\|\||\b(?:timezone|timeZone)\s*:)\s*["'`](?:Africa|America|Antarctica|Asia|Atlantic|Australia|Europe|Indian|Pacific)\/[A-Za-z_/+-]+["'`]/g; + +const orgSpecificHits = (text: string): string[] => [ + ...[...text.matchAll(ORG_HOST)].map((m) => m[0]), + ...[...text.matchAll(ISSUE_LITERAL)].filter((m) => !NOT_ISSUE_KEYS.has(m[1])).map((m) => m[0]), + ...[...text.matchAll(ZONE_LITERAL)].map((m) => m[0]), +]; + +test("Given org-specific and look-alike snippets, When the guard checks them, Then only real org specifics are flagged", () => { + for (const hit of [ + 'const base = cfg.baseUrl ?? "https://acme.youtrack.cloud";', + 'const issue = String(config?.meetingIssue || "ABC-12");', + "const issue = cfg.meetingIssue ?? 'PROJ-7';", + 'const defaults = { meetingIssue: "ABC-12" };', + 'meetingIssues: { general: { issue: "TEAM-3" } }', + 'const tz = String(config.timezone ?? "America/Santiago");', + 'const draft = { timezone: "Europe/Madrid" };', + ]) + expect(orgSpecificHits(hit), hit).not.toEqual([]); + for (const clean of [ + 'const encoding = opts.encoding ?? "UTF-8";', + 'const algo = opts.algo || "SHA-256";', + 'placeholder: "e.g. https://example.youtrack.cloud"', + 'const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";', + 'const issue = String(config.meetingIssue ?? "");', + 'const docs = "https://www.jetbrains.com/help/youtrack/cloud/manage-permanent-token.html";', + "const ISSUE_RE = /^[A-Z]+-\\d+$/;", + ]) + expect(orgSpecificHits(clean), clean).toEqual([]); +}); + +test("Given the shipped package sources, When they are scanned, Then no organization host, issue-id literal or hard-coded zone remains", () => { const root = path.resolve(import.meta.dir, "../../packages"); - const orgHost = /https?:\/\/(?!example\.)[a-z0-9-]+\.youtrack\.cloud/i; - // A quoted ABC-123 literal used as a fallback value (`?? "X-1"`, `|| "X-1"`). - const issueDefault = /(?:\?\?|\|\|)\s*["'`][A-Z][A-Z0-9]+-\d+["'`]/; const hits: string[] = []; const walk = (dir: string): void => { for (const entry of readdirSync(dir, { withFileTypes: true })) { if (["node_modules", "dist", "build"].includes(entry.name)) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) walk(full); - else if ( - /\.(?:ts|tsx|md|json)$/.test(entry.name) && - full.includes(`${path.sep}src${path.sep}`) - ) { - const text = readFileSync(full, "utf8"); - if (orgHost.test(text) || issueDefault.test(text)) hits.push(path.relative(root, full)); + else if (/\.(?:ts|tsx)$/.test(entry.name) && full.includes(`${path.sep}src${path.sep}`)) { + for (const match of orgSpecificHits(readFileSync(full, "utf8"))) + hits.push(`${path.relative(root, full)}: ${match}`); } } };